import os import sys import time from collections import defaultdict from typing import Callable import httpx import pytest from pydantic import ValidationError import ray from ray import serve from ray._common.test_utils import SignalActor, wait_for_condition from ray.serve._private.test_utils import check_running, get_application_url from ray.serve._private.utils import get_random_string from ray.serve.exceptions import RayServeException @pytest.mark.parametrize("use_handle", [True, False]) def test_deploy_basic(serve_instance, use_handle): """Test basic serve.run(). 1. Deploy an application with one deployment `d`. 2. Redeploy `d` and check that a new actor is started. 3. Update the code for `d`, redeploy `d`, and check that a new actor is started with the new code. """ @serve.deployment def d(): return "code version 1", os.getpid() def call(): if use_handle: handle = serve.get_deployment_handle("d", "default") return handle.remote().result() else: url = get_application_url("HTTP") return httpx.get(f"{url}/d", timeout=None).json() serve.run(d.bind()) resp, pid1 = call() assert resp == "code version 1" # Redeploying should start a new actor. serve.run(d.bind()) resp, pid2 = call() assert resp == "code version 1" assert pid2 != pid1 # Redeploying with new code should start a new actor with new code @serve.deployment def d(): return "code version 2", os.getpid() serve.run(d.bind()) resp, pid3 = call() assert resp == "code version 2" assert pid3 != pid2 @pytest.mark.parametrize( "component_name_with_special_character", [ "test@component", "test#123", "component/name", "component.name", "component!name", "component$name", "component%name", "component^name", "component&name", "component*name", "component_name", ], ) def test_deploy_with_any_characters( serve_instance, component_name_with_special_character ): """The function should not fail when the deployment name contains special characters.""" # V1 blocks on signal @serve.deployment class V1: async def handler(self): return True async def __call__(self): return await self.handler() # Check that deployment succeeds with special characters deployment = V1.options(name=component_name_with_special_character).bind() serve.run(deployment, name="app") status = serve.status() handle_name = serve.get_deployment_handle( component_name_with_special_character, "app" ).deployment_name assert handle_name == component_name_with_special_character app = status.applications["app"] assert app.status == "RUNNING" def test_empty_decorator(serve_instance): @serve.deployment def func(*args): return "hi" @serve.deployment class Class: def ping(self, *args): return "pong" assert func.name == "func" assert Class.name == "Class" func_handle = serve.run(func.bind()) assert func_handle.remote().result() == "hi" class_handle = serve.run(Class.bind()) assert class_handle.ping.remote().result() == "pong" def test_reconfigure_with_exception(serve_instance): @serve.deployment class A: def __init__(self): self.config = "yoo" def reconfigure(self, config): if config == "hi": raise Exception("oops") self.config = config def __call__(self, *args): return self.config with pytest.raises(RuntimeError): serve.run(A.options(user_config="hi").bind()) @pytest.mark.parametrize("use_handle", [True, False]) def test_redeploy_single_replica(serve_instance, use_handle): """Tests redeploying a deployment with a single replica. The new replica should start without waiting for the old version replica to completely shut down. """ name = "test" @ray.remote def call(): if use_handle: handle = serve.get_deployment_handle(name, "app") return handle.handler.remote().result() else: url = get_application_url("HTTP", app_name="app") return httpx.get(f"{url}/", timeout=None).json() signal_name = f"signal-{get_random_string()}" signal = SignalActor.options(name=signal_name).remote() # V1 blocks on signal @serve.deployment(name=name) class V1: async def handler(self): await signal.wait.remote() return 1, os.getpid() async def __call__(self): return await self.handler() # V2 doesn't block on signal @serve.deployment(name=name) class V2: async def handler(self): return 2, os.getpid() async def __call__(self): return await self.handler() serve.run(V1.bind(), name="app") wait_for_condition(check_running, app_name="app", timeout=15) # Send unblocked signal first to get pid of running replica signal.send.remote() val1, pid1 = ray.get(call.remote()) assert val1 == 1 # blocked_ref will block until the signal is sent. signal.send.remote(clear=True) blocked_ref = call.remote() assert len(ray.wait([blocked_ref], timeout=2.1)[0]) == 0 # Redeploy new version. serve._run(V2.bind(), _blocking=False, name="app") start = time.time() while time.time() - start < 30: # The app is not supposed to be in RUNNING state here as V1 replica stopping # V2 replica running makes the app to be in DEPLOYING state so we don't check # if the app is in RUNNING state. ready, _ = ray.wait([call.remote()], timeout=2) # If the request doesn't block, it must be V2 which doesn't wait # for signal. Otherwise, it must have been sent to V1 which # waits on signal The request might have been sent to V1 if the # long poll broadcast was delayed if len(ready) == 1: val, pid = ray.get(ready[0]) assert val == 2 assert pid != pid1 break else: assert False, "Timed out waiting for new version to be called." # Unblock blocked_ref ray.get(signal.send.remote()) val2, pid2 = ray.get(blocked_ref) assert val2 == 1 assert pid2 == pid1 @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.") def test_redeploy_multiple_replicas(serve_instance): """ This test demonstrates zero downtime when redeploying a deployment with multiple replicas. The inflight requests to the old version are allowed to complete, it also shows that the old version stops accepting new requests. The new requests are routed to the new version. """ client = serve_instance name = "test" signal = SignalActor.remote() @serve.deployment(name=name, num_replicas=2) class V1: async def __call__(self, block: bool): if block: await signal.wait.remote() return "v1", os.getpid() @serve.deployment(name=name, num_replicas=2) class V2: async def __call__(self, **kwargs): return "v2", os.getpid() h = serve.run(V1.bind(), name="app") vals1, pids1 = zip(*[h.remote(block=False).result() for _ in range(10)]) assert set(vals1) == {"v1"} assert len(set(pids1)) == 2 # ref2 will block a single replica until the signal is sent. ref2 = h.remote(block=True) with pytest.raises(TimeoutError): ref2.result(timeout_s=1) # Redeploy new version. serve._run(V2.bind(), _blocking=False, name="app") while True: # Wait for the new version to be started and ready to handle requests. vals2, pids2 = zip(*[h.remote(block=False).result() for _ in range(10)]) if set(vals2) == {"v2"}: break time.sleep(1) # Signal the original call to exit. ray.get(signal.send.remote()) val, pid = ref2.result() assert val == "v1" assert pid in pids1 # Now the goal and requests to the new version should complete. # We should have two running replicas of the new version. client._wait_for_application_running("app") vals3, pids3 = zip(*[h.remote(block=False).result() for _ in range(10)]) assert set(vals3) == {"v2"} assert len(set(pids3)) == 2 @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.") @pytest.mark.parametrize("use_handle", [True, False]) def test_reconfigure_multiple_replicas(serve_instance, use_handle): # Tests that updating the user_config with multiple replicas performs a # rolling update. client = serve_instance name = "test" @ray.remote(num_cpus=0) def call(): if use_handle: handle = serve.get_deployment_handle(name, "app") ret = handle.handler.remote().result() else: url = get_application_url("HTTP", app_name="app") ret = httpx.get(url).text return ret.split("|")[0], ret.split("|")[1] signal_name = f"signal-{get_random_string()}" signal = SignalActor.options(name=signal_name).remote() @serve.deployment(name=name, num_replicas=2) class V1: def __init__(self): self.config = None async def reconfigure(self, config): # Don't block when the replica is first created. if self.config is not None: signal = ray.get_actor(signal_name) ray.get(signal.wait.remote()) self.config = config async def handler(self): val = self.config["test"] return f"{val}|{os.getpid()}" async def __call__(self, request): return await self.handler() V1 = V1.options(_internal=True, version="1") def make_nonblocking_calls(expected, expect_blocking=False): # Returns dict[val, set(pid)]. blocking = [] responses = defaultdict(set) start = time.time() while time.time() - start < 30: refs = [call.remote() for _ in range(10)] ready, not_ready = ray.wait(refs, timeout=10) for ref in ready: val, pid = ray.get(ref) responses[val].add(pid) for ref in not_ready: blocking.extend(not_ready) if all(len(responses[val]) == num for val, num in expected.items()) and ( expect_blocking is False or len(blocking) > 0 ): break else: assert False, f"Timed out, responses: {responses}." return responses, blocking serve.run(V1.options(user_config={"test": "1"}).bind(), name="app") wait_for_condition(check_running, app_name="app", timeout=15) responses1, _ = make_nonblocking_calls({"1": 2}) pids1 = responses1["1"] # Reconfigure should block one replica until the signal is sent. Check that # some requests are now blocking. serve._run( V1.options(user_config={"test": "2"}).bind(), name="app", _blocking=False ) # The app is not supposed to be in RUNNING state here as one of the replicas among the two # is updating with user_config. This makes the app to be in DEPLOYING state so we don't check # if the app is in RUNNING state. responses2, blocking2 = make_nonblocking_calls({"1": 1}, expect_blocking=True) assert list(responses2["1"])[0] in pids1 # Signal reconfigure to finish. Now the goal should complete and both # replicas should have the updated config. ray.get(signal.send.remote()) client._wait_for_application_running("app") make_nonblocking_calls({"2": 2}) def test_reconfigure_does_not_run_while_there_are_active_queries(serve_instance): """ This tests checks that reconfigure can't trigger while there are active requests, so that the actor's state is not mutated mid-request. https://github.com/ray-project/ray/pull/20315 """ signal = SignalActor.remote() @serve.deployment(max_ongoing_requests=10, num_replicas=1) class A: def __init__(self): self.state = None def reconfigure(self, config): self.state = config async def __call__(self): await signal.wait.remote() return self.state["a"] handle = serve.run( A.options(_internal=True, version="1", user_config={"a": 1}).bind() ) responses = [handle.remote() for _ in range(10)] def check(): assert ray.get(signal.cur_num_waiters.remote()) == len(responses) return True # Give the queries time to get to the replicas before the reconfigure. wait_for_condition(check) @ray.remote(num_cpus=0) def reconfigure(): serve.run(A.options(_internal=True, version="1", user_config={"a": 2}).bind()) # Start the reconfigure; # this will not complete until the signal is released # to allow the queries to complete. reconfigure_ref = reconfigure.remote() # Release the signal to allow the queries to complete. signal.send.remote() # Wait for the reconfigure to complete. ray.get(reconfigure_ref) # These should all be 1 because the queries were sent before the reconfigure, # the reconfigure blocks until they complete, # and we just waited for the reconfigure to finish. results = [r.result() for r in responses] print(results) assert all([r == 1 for r in results]) # If we query again, it should be 2, # because the reconfigure will have gone through after the # original queries completed. assert handle.remote().result() == 2 @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.") @pytest.mark.parametrize("use_handle", [True, False]) def test_redeploy_scale_down(serve_instance, use_handle): # Tests redeploying new code with lower num_replicas. name = "test" @serve.deployment(name=name, num_replicas=4) def v1(*args): return f"1|{os.getpid()}" @ray.remote(num_cpus=0) def call(): if use_handle: handle = serve.get_app_handle("app") ret = handle.remote().result() else: url = get_application_url("HTTP", app_name="app") ret = httpx.get(f"{url}/{name}").text return ret.split("|")[0], ret.split("|")[1] def make_calls(expected): # Returns dict[val, set(pid)]. responses = defaultdict(set) start = time.time() while time.time() - start < 30: refs = [call.remote() for _ in range(10)] ready, not_ready = ray.wait(refs, timeout=5) for ref in ready: val, pid = ray.get(ref) responses[val].add(pid) if all(len(responses[val]) == num for val, num in expected.items()): break else: assert False, f"Timed out, responses: {responses}." return responses serve.run(v1.bind(), name="app") responses1 = make_calls({"1": 4}) pids1 = responses1["1"] @serve.deployment(name=name, num_replicas=2) def v2(*args): return f"2|{os.getpid()}" serve.run(v2.bind(), name="app") responses2 = make_calls({"2": 2}) assert all(pid not in pids1 for pid in responses2["2"]) @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows.") @pytest.mark.parametrize("use_handle", [True, False]) def test_redeploy_scale_up(serve_instance, use_handle): # Tests redeploying new code with higher num_replicas. name = "test" @serve.deployment(name=name, num_replicas=2) def v1(*args): return f"1|{os.getpid()}" @ray.remote(num_cpus=0) def call(): if use_handle: handle = serve.get_app_handle("app") ret = handle.remote().result() else: url = get_application_url("HTTP", app_name="app") ret = httpx.get(f"{url}/{name}").text return ret.split("|")[0], ret.split("|")[1] def make_calls(expected): # Returns dict[val, set(pid)]. responses = defaultdict(set) start = time.time() while time.time() - start < 30: refs = [call.remote() for _ in range(10)] ready, not_ready = ray.wait(refs, timeout=5) for ref in ready: val, pid = ray.get(ref) responses[val].add(pid) if all(len(responses[val]) == num for val, num in expected.items()): break else: assert False, f"Timed out, responses: {responses}." return responses serve.run(v1.bind(), name="app") responses1 = make_calls({"1": 2}) pids1 = responses1["1"] @serve.deployment(name=name, num_replicas=4) def v2(*args): return f"2|{os.getpid()}" serve.run(v2.bind(), name="app") responses2 = make_calls({"2": 4}) assert all(pid not in pids1 for pid in responses2["2"]) def test_handle_method_name_validation(serve_instance): @serve.deployment class A: def b(self, *args): return "hello" handle = serve.run(A.bind(), name="app") # Specify method via `.options`. assert handle.options(method_name="b").remote().result() == "hello" # Specify method via attribute. assert handle.b.remote().result() == "hello" # Unknown method. with pytest.raises(RayServeException): handle.options(method_name="c").remote().result() with pytest.raises(RayServeException): handle.c.remote().result() def test_deploy_with_init_args(serve_instance): @serve.deployment() class D: def __init__(self, *args): self._args = args def get_args(self): return self._args handle = serve.run(D.bind(1, 2, 3)) assert handle.get_args.remote().result() == (1, 2, 3) def test_deploy_with_init_kwargs(serve_instance): @serve.deployment() class D: def __init__(self, **kwargs): self._kwargs = kwargs def get_kwargs(self, *args): return self._kwargs handle = serve.run(D.bind(a=1, b=2)) assert handle.get_kwargs.remote().result() == {"a": 1, "b": 2} def test_init_args_with_closure(serve_instance): @serve.deployment class Evaluator: def __init__(self, func: Callable): self._func = func def __call__(self, inp: int) -> int: return self._func(inp) handle = serve.run(Evaluator.bind(lambda a: a + 1)) assert handle.remote(41).result() == 42 def test_input_validation(): name = "test" @serve.deployment(name=name) class Base: pass with pytest.raises(RuntimeError): Base() with pytest.raises( ValueError, match=r"`version` in `@serve\.deployment` has been removed" ): @serve.deployment(name=name, version=1) class BadVersion: pass with pytest.raises( ValueError, match=r"`version` in `Deployment\.options\(\)` has been removed" ): Base.options(version=1) with pytest.raises(ValidationError): @serve.deployment(num_replicas="hi") class BadNumReplicas: pass with pytest.raises(ValidationError): Base.options(num_replicas="hi") with pytest.raises(ValueError): @serve.deployment(num_replicas=0) class ZeroNumReplicas: pass with pytest.raises(ValueError): Base.options(num_replicas=0) with pytest.raises(ValidationError): @serve.deployment(num_replicas=-1) class NegativeNumReplicas: pass with pytest.raises(ValidationError): Base.options(num_replicas=-1) with pytest.raises(TypeError): @serve.deployment(ray_actor_options=[1, 2, 3]) class BadActorOpts: pass with pytest.raises(TypeError): Base.options(ray_actor_options="hi") with pytest.raises(ValidationError): @serve.deployment(max_ongoing_requests="hi") class BadMaxQueries: pass with pytest.raises(ValidationError): Base.options(max_ongoing_requests=[1]) with pytest.raises(ValueError): @serve.deployment(max_ongoing_requests=0) class ZeroMaxQueries: pass with pytest.raises(ValueError): Base.options(max_ongoing_requests=0) with pytest.raises(ValueError): @serve.deployment(max_ongoing_requests=-1) class NegativeMaxQueries: pass with pytest.raises(ValueError): Base.options(max_ongoing_requests=-1) def test_deployment_properties(): class DClass: pass D = serve.deployment( name="name", num_replicas=2, user_config="hi", max_ongoing_requests=100, ray_actor_options={"num_cpus": 2}, )(DClass) assert D.name == "name" assert D.version is None assert D.num_replicas == 2 assert D.user_config == "hi" assert D.max_ongoing_requests == 100 assert D.ray_actor_options == {"num_cpus": 2} with pytest.raises( ValueError, match=r"`version` in `@serve\.deployment` has been removed" ): serve.deployment(version=None)(DClass) def test_deploy_multiple_apps_batched(serve_instance): @serve.deployment class A: def __call__(self): return "a" @serve.deployment class B: def __call__(self): return "b" serve.run_many( [ serve.RunTarget(A.bind(), name="a", route_prefix="/a"), serve.RunTarget(B.bind(), name="b", route_prefix="/b"), ] ) assert serve.get_app_handle("a").remote().result() == "a" assert serve.get_app_handle("b").remote().result() == "b" urla = get_application_url("HTTP", app_name="a", use_localhost=True) urlb = get_application_url("HTTP", app_name="b", use_localhost=True) assert httpx.get(urla).text == "a" assert httpx.get(urlb).text == "b" def test_redeploy_multiple_apps_batched(serve_instance): @serve.deployment class A: def __call__(self): return "a", os.getpid() @serve.deployment class V1: def __call__(self): return "version 1", os.getpid() @serve.deployment class V2: def __call__(self): return "version 2", os.getpid() serve.run_many( [ serve.RunTarget(A.bind(), name="a", route_prefix="/a"), serve.RunTarget(V1.bind(), name="v", route_prefix="/v"), ] ) a1, pida1 = serve.get_app_handle("a").remote().result() assert a1 == "a" v1, pid1 = serve.get_app_handle("v").remote().result() assert v1 == "version 1" serve.run_many( [ serve.RunTarget(V2.bind(), name="v", route_prefix="/v"), ] ) v2, pid2 = serve.get_app_handle("v").remote().result() assert v2 == "version 2" assert pid1 != pid2 # Redeploying "v" should not have affected "a" a2, pida2 = serve.get_app_handle("a").remote().result() assert a1 == a2 assert pida1 == pida2 if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))