import os import sys import time from collections import defaultdict import httpx import pytest import ray from ray import serve from ray._common.test_utils import SignalActor, wait_for_condition from ray.cluster_utils import Cluster from ray.exceptions import RayActorError from ray.serve._private.common import DeploymentID, DeploymentStatus, ReplicaState from ray.serve._private.constants import ( RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, SERVE_DEFAULT_APP_NAME, SERVE_NAMESPACE, ) from ray.serve._private.deployment_state import ReplicaStartupStatus from ray.serve._private.test_utils import ( check_deployment_status, expected_proxy_actors, skip_if_haproxy, ) from ray.serve._private.utils import calculate_remaining_timeout, get_head_node_id from ray.serve.config import GangSchedulingConfig from ray.serve.context import _get_global_client from ray.serve.handle import DeploymentHandle from ray.serve.schema import ServeDeploySchema from ray.util.state import list_actors def get_pids(expected, deployment_name="D", app_name="default", timeout=30): handle = serve.get_deployment_handle(deployment_name, app_name) pids = set() start = time.time() while len(pids) < expected: for r in [handle.remote() for _ in range(10)]: try: pids.add( r.result( timeout_s=calculate_remaining_timeout( timeout_s=timeout, start_time_s=start, curr_time_s=time.time(), ) ) ) except RayActorError: # Handle sent request to dead actor before running replicas were updated # This can happen because health check period = 1s pass if time.time() - start >= timeout: raise TimeoutError("Timed out waiting for pids.") return pids @serve.deployment(health_check_period_s=1, max_ongoing_requests=1) def pid(): time.sleep(0.1) return os.getpid() pid_app = pid.bind() def test_scale_up(ray_cluster): cluster = ray_cluster cluster.add_node(num_cpus=1) cluster.connect(namespace=SERVE_NAMESPACE) # By default, Serve controller and proxy actors use 0 CPUs, # so initially there should only be room for 1 replica. app_config = { "name": "default", "import_path": "ray.serve.tests.test_cluster.pid_app", "deployments": [{"name": "pid", "num_replicas": 1}], } serve.start() client = serve.context._connect() client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]})) client._wait_for_application_running("default") pids1 = get_pids(1, deployment_name="pid", app_name="default") app_config["deployments"][0]["num_replicas"] = 3 client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]})) # Check that a new replica has not started in 1.0 seconds. This # doesn't guarantee that a new replica won't ever be started, but # 1.0 seconds is a reasonable upper bound on replica startup time. with pytest.raises(TimeoutError): client._wait_for_application_running("default", timeout_s=1) assert get_pids(1, deployment_name="pid", app_name="default") == pids1 # Add a node with another CPU, another replica should get placed. cluster.add_node(num_cpus=1) with pytest.raises(TimeoutError): client._wait_for_application_running("default", timeout_s=1) pids2 = get_pids(2, deployment_name="pid", app_name="default") assert pids1.issubset(pids2) # Add a node with another CPU, the final replica should get placed # and the deploy goal should be done. cluster.add_node(num_cpus=1) client._wait_for_application_running("default") pids3 = get_pids(3, deployment_name="pid", app_name="default") assert pids2.issubset(pids3) @pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.") def test_node_failure(ray_cluster): cluster = ray_cluster cluster.add_node(num_cpus=3) cluster.connect(namespace=SERVE_NAMESPACE) # NOTE(edoakes): we need to start serve before adding the worker node to # guarantee that the controller is placed on the head node (we should be # able to tolerate being placed on workers, but there's currently a bug). # We should add an explicit test for that in the future when it's fixed. serve.start() worker_node = cluster.add_node(num_cpus=2) @serve.deployment(num_replicas=5, health_check_period_s=1, max_ongoing_requests=1) def D(*args): time.sleep(0.1) return os.getpid() print("Initial deploy.") serve.run(D.bind()) pids1 = get_pids(5) # Remove the node. There should still be three replicas running. print("Kill node.") cluster.remove_node(worker_node) pids2 = get_pids(3) assert pids2.issubset(pids1) # Add a worker node back. One replica should get placed. print("Add back first node.") cluster.add_node(num_cpus=1) pids3 = get_pids(4) assert pids2.issubset(pids3) # Add another worker node. One more replica should get placed. print("Add back second node.") cluster.add_node(num_cpus=1) pids4 = get_pids(5) assert pids3.issubset(pids4) @pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.") def test_replica_startup_status_transitions(ray_cluster): cluster = ray_cluster cluster.add_node(num_cpus=1) cluster.connect(namespace=SERVE_NAMESPACE) serve.start() client = _get_global_client() signal = SignalActor.remote() @serve.deployment(ray_actor_options={"num_cpus": 2}) class E: async def __init__(self): await signal.wait.remote() serve._run(E.bind(), _blocking=False) def get_replicas(replica_state): controller = client._controller replicas = ray.get( controller._dump_replica_states_for_testing.remote( DeploymentID(name=E.name) ) ) return replicas.get([replica_state]) # wait for serve to start the replica wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0) # currently there are no resources to allocate the replica def get_starting_replica(): replicas = get_replicas(ReplicaState.STARTING) return replicas[0] if replicas else None def is_pending_allocation(): replica = get_starting_replica() if replica is None: return False return replica.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION wait_for_condition(is_pending_allocation) # add the necessary resources to allocate the replica cluster.add_node(num_cpus=4) wait_for_condition(lambda: (ray.cluster_resources().get("CPU", 0) >= 4)) wait_for_condition(lambda: (ray.available_resources().get("CPU", 0) >= 2)) def is_replica_pending_initialization(): replica = get_starting_replica() if replica is None: return False status, _ = replica.check_started() return status == ReplicaStartupStatus.PENDING_INITIALIZATION wait_for_condition(is_replica_pending_initialization, timeout=25) # send signal to complete replica initialization ray.get(signal.send.remote()) def check_succeeded(): # After initialization succeeds, replica transitions to RUNNING state # So check both STARTING and RUNNING states replica = get_starting_replica() if replica: status, _ = replica.check_started() if status == ReplicaStartupStatus.SUCCEEDED: return True # Check if replica has moved to RUNNING state (which means it succeeded) running_replicas = get_replicas(ReplicaState.RUNNING) if running_replicas and len(running_replicas) > 0: return True return False wait_for_condition(check_succeeded) @pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.") def test_gang_replica_startup_status_transitions(ray_cluster): cluster = ray_cluster # Start with only 1 CPU — not enough for a gang of 2 replicas each needing 0.75 CPUs. cluster.add_node(num_cpus=1) cluster.connect(namespace=SERVE_NAMESPACE) serve.start() client = _get_global_client() signal = SignalActor.remote() @serve.deployment( ray_actor_options={"num_cpus": 0.75}, num_replicas=2, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) class GangDeployment: async def __init__(self): await signal.wait.remote() serve._run(GangDeployment.bind(), _blocking=False) def get_replicas(replica_state): controller = client._controller replicas = ray.get( controller._dump_replica_states_for_testing.remote( DeploymentID(name="GangDeployment") ) ) return replicas.get([replica_state]) # Wait for replicas to be created in STARTING state. wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0) # With only 1 CPU available and each replica needing 0.75, replicas should # be stuck in PENDING_ALLOCATION. def is_pending_allocation(): replicas = get_replicas(ReplicaState.STARTING) if not replicas: return False return all( r.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION for r in replicas ) wait_for_condition(is_pending_allocation) # Add enough resources for the gang cluster.add_node(num_cpus=1) wait_for_condition(lambda: ray.cluster_resources().get("CPU", 0) == 2) # Replicas should transition to PENDING_INITIALIZATION def is_pending_initialization(): replicas = get_replicas(ReplicaState.STARTING) if not replicas: return False return all( r.check_started()[0] == ReplicaStartupStatus.PENDING_INITIALIZATION for r in replicas ) wait_for_condition(is_pending_initialization, timeout=30) # Complete initialization ray.get(signal.send.remote()) # Replicas should transition to RUNNING def check_running(): running_replicas = get_replicas(ReplicaState.RUNNING) return len(running_replicas) == 2 wait_for_condition(check_running, timeout=30) @serve.deployment def f(): pass f_app = f.bind() def test_intelligent_scale_down(ray_cluster): cluster = ray_cluster # Head node cluster.add_node(num_cpus=0) cluster.connect(namespace=SERVE_NAMESPACE) cluster.add_node(num_cpus=2) cluster.add_node(num_cpus=2) serve.start() client = _get_global_client() def get_actor_distributions(): node_to_actors = defaultdict(list) for actor in list_actors( address=cluster.address, filters=[("STATE", "=", "ALIVE")] ): if "ServeReplica" not in actor.class_name: continue node_to_actors[actor.node_id].append(actor) return set(map(len, node_to_actors.values())) def check_app_running_with_replicas(num_replicas): status = serve.status().applications["default"] assert status.status == "RUNNING" assert status.deployments["f"].replica_states["RUNNING"] == num_replicas return True app_config = { "name": "default", "import_path": "ray.serve.tests.test_cluster.f_app", "deployments": [{"name": "f", "num_replicas": 3}], } client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]})) wait_for_condition(check_app_running_with_replicas, num_replicas=3) assert get_actor_distributions() == {2, 1} app_config["deployments"][0]["num_replicas"] = 2 client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]})) wait_for_condition(check_app_running_with_replicas, num_replicas=2) assert get_actor_distributions() == {2} @pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.") @pytest.mark.skipif( RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs spread strategy." ) def test_replica_spread(ray_cluster): cluster = ray_cluster cluster.add_node(num_cpus=2) # NOTE(edoakes): we need to start serve before adding the worker node to # guarantee that the controller is placed on the head node (we should be # able to tolerate being placed on workers, but there's currently a bug). # We should add an explicit test for that in the future when it's fixed. cluster.connect(namespace=SERVE_NAMESPACE) serve.start() worker_node = cluster.add_node(num_cpus=2) @serve.deployment( num_replicas=2, health_check_period_s=1, ) def D(): return "hi" serve.run(D.bind()) def get_num_nodes(): client = _get_global_client() details = client.get_serve_details() dep = details["applications"]["default"]["deployments"]["D"] nodes = {r["node_id"] for r in dep["replicas"]} print("replica nodes", nodes) return len(nodes) # Check that the two replicas are spread across the two nodes. wait_for_condition(lambda: get_num_nodes() == 2) # Kill the worker node. The second replica should get rescheduled on # the head node. print("Removing worker node. Replica should be rescheduled.") cluster.remove_node(worker_node) # Check that the replica on the dead node can be rescheduled. wait_for_condition(lambda: get_num_nodes() == 1) def test_autoscale_upscaling_stuck_then_healthy(ray_cluster): """Test that deployment stuck in upscaling (due to insufficient cluster resources) recovers to healthy when ongoing requests drop to zero. Setup: Head with 0 CPUs + 1 worker with 1 CPU. 1 replica using 1 CPU, target_ongoing_requests=1. Send 2 requests via handle -> autoscaler wants 2 replicas but can't add one (no CPU). Deployment stuck in UPSCALING. Release requests -> deployment HEALTHY. """ cluster = ray_cluster cluster.add_node(num_cpus=0) # Head node (controller/proxy use 0 CPU) cluster.connect(namespace=SERVE_NAMESPACE) serve.start() # Start before adding worker so controller goes on head cluster.add_node(num_cpus=1) # Worker with 1 CPU for replica cluster.wait_for_nodes() signal = SignalActor.remote() @serve.deployment( autoscaling_config={ "min_replicas": 1, "max_replicas": 2, "target_ongoing_requests": 1, "metrics_interval_s": 0.1, "look_back_period_s": 0.5, "upscale_delay_s": 0, # If delay is large then the test will be stuck in UPSCALING state. "downscale_delay_s": 1, }, max_ongoing_requests=1, ray_actor_options={"num_cpus": 1}, graceful_shutdown_timeout_s=2, ) def blocking_replica(): ray.get(signal.wait.remote()) return "ok" handle = serve.run(blocking_replica.bind()) wait_for_condition( check_deployment_status, name="blocking_replica", expected_status=DeploymentStatus.HEALTHY, ) # Send 2 requests - first occupies the replica, second queues. With # target_ongoing_requests=1 and 1 replica, 2 requests triggers scale to 2. responses = [handle.remote() for _ in range(2)] # Deployment should get stuck in UPSCALING: autoscaler wants 2 replicas # but cluster only has 1 CPU (replica uses it all). wait_for_condition( check_deployment_status, name="blocking_replica", expected_status=DeploymentStatus.UPSCALING, timeout=15, ) # Release the signal so running requests complete and go to zero. ray.get(signal.send.remote()) for r in responses: assert r.result() == "ok" # Deployment should recover to HEALTHY as load drops (may go through # DOWNSCALING first if a second replica was briefly added). wait_for_condition( check_deployment_status, name="blocking_replica", expected_status=DeploymentStatus.HEALTHY, timeout=30, ) def test_handle_prefers_replicas_on_same_node(ray_cluster): """Verify that handle calls prefer replicas on the same node when possible. If all replicas on the same node are occupied (at `max_ongoing_requests` limit), requests should spill to other nodes. """ cluster = ray_cluster cluster.add_node(num_cpus=1) cluster.add_node(num_cpus=1) signal = SignalActor.remote() @serve.deployment(num_replicas=2, max_ongoing_requests=1) def inner(block_on_signal): if block_on_signal: ray.get(signal.wait.remote()) return ray.get_runtime_context().get_node_id() @serve.deployment(num_replicas=1, ray_actor_options={"num_cpus": 0}) class Outer: def __init__(self, inner_handle: DeploymentHandle): self._h = inner_handle.options(_prefer_local_routing=True) def get_node_id(self) -> str: return ray.get_runtime_context().get_node_id() async def call_inner(self, block_on_signal: bool = False) -> str: return await self._h.remote(block_on_signal) # The inner deployment's two replicas will be spread across the two nodes and # the outer deployment's single replica will be placed on one of them. h = serve.run(Outer.bind(inner.bind())) # When sending requests sequentially, all requests to the inner deployment should # go to the replica on the same node as the outer deployment replica. outer_node_id = h.get_node_id.remote().result() for _ in range(10): assert h.call_inner.remote().result() == outer_node_id # Make a blocking request to the inner deployment replica on the same node. blocked_response = h.call_inner.remote(block_on_signal=True) with pytest.raises(TimeoutError): blocked_response.result(timeout_s=1) # Because there's a blocking request and `max_ongoing_requests` is set to 1, all # requests should now spill to the other node. for _ in range(10): assert h.call_inner.remote().result() != outer_node_id ray.get(signal.send.remote()) assert blocked_response.result() == outer_node_id # TODO: HAProxy's default ingress balances across all replicas with no # node-local preference. prefer-local routing could be wired under HAProxy via # the ingress_request_router use-server delegation, then this skip dropped. @skip_if_haproxy("balances across replicas without node-local preference") @pytest.mark.parametrize("set_flag", [True, False]) def test_proxy_prefers_replicas_on_same_node(ray_cluster: Cluster, set_flag): """When the feature flag is turned on via env var, verify that http proxy routes to replicas on the same node when possible. Otherwise if env var is not set, http proxy should route to all replicas equally. """ if not set_flag: os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"] = "0" cluster = ray_cluster cluster.add_node(num_cpus=1) cluster.add_node(num_cpus=1) # Only start one HTTP proxy on the head node. serve.start(http_options={"location": "HeadOnly"}) head_node_id = get_head_node_id() @serve.deployment(num_replicas=2, max_ongoing_requests=1) def f(): return ray.get_runtime_context().get_node_id() # The deployment's two replicas will be spread across the two nodes serve.run(f.bind()) # Since they're sent sequentially, all requests should be routed to # the replica on the head node responses = [httpx.post("http://localhost:8000").text for _ in range(10)] if set_flag: assert all(resp == head_node_id for resp in responses) else: assert len(set(responses)) == 2 if "RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING" in os.environ: del os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"] class TestHealthzAndRoutes: def test_head_node_proxy_healthy(self, ray_cluster: Cluster): """When a new cluster is started with no replicas, head node proxy should respond with 200 at /-/healthz and /-/routes""" cluster = ray_cluster cluster.add_node(num_cpus=0) # Head node cluster.wait_for_nodes() ray.init(address=cluster.address) serve.start(http_options={"location": "EveryNode"}) @serve.deployment(ray_actor_options={"num_cpus": 0}) class Dummy: pass serve.run(Dummy.bind()) # Head node proxy /-/healthz and /-/routes should return 200 r = httpx.post("http://localhost:8000/-/healthz") assert r.status_code == 200 r = httpx.post("http://localhost:8000/-/routes") assert r.status_code == 200 def test_head_and_worker_nodes_no_replicas(self, ray_cluster: Cluster): """Test `/-/healthz` and `/-/routes` return the correct responses for head and worker nodes. When there are replicas on all nodes, `/-/healthz` and `/-/routes` on all nodes should return 200. When there are no replicas on any nodes, `/-/healthz` and `/-/routes` on the head node should continue to return 200. `/-/healthz` and `/-/routes` on the worker node should start to return 503 """ # Setup worker http proxy to be pointing to port 8001. Head node http proxy will # continue to be pointing to the default port 8000. cluster = ray_cluster cluster.add_node(num_cpus=0) cluster.add_node( num_cpus=2, env_vars={"RAY_SERVE_WORKER_PROXY_HTTP_PORT": "8001"} ) cluster.wait_for_nodes() ray.init(address=cluster.address) serve.start(http_options={"location": "EveryNode"}) # Deploy 2 replicas, both should be on the worker node. @serve.deployment(num_replicas=2) class HelloModel: def __call__(self): return "hello" model = HelloModel.bind() serve.run(target=model) # Ensure worker node has both replicas. def check_replicas_on_worker_nodes(): return ( len( { a.node_id for a in list_actors(address=cluster.address) if a.class_name.startswith("ServeReplica") } ) == 1 ) wait_for_condition(check_replicas_on_worker_nodes) # Total alive actors: EveryNode proxies on both nodes + 1 controller + # 2 replicas. Under HAProxy each proxy node runs an HAProxyManager and # the head node adds a fallback ProxyActor. expected_num_actors = ( sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1 + 2 ) wait_for_condition( lambda: len(list_actors(address=cluster.address)) == expected_num_actors ) assert len(ray.nodes()) == 2 # Ensure `/-/healthz` and `/-/routes` return 200 and expected responses # on both nodes. def check_request(url: str, expected_code: int, expected_text: str): req = httpx.get(url) assert req.status_code == expected_code assert req.text == expected_text return True wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8000/-/healthz", expected_code=200, expected_text="success", ) assert httpx.get("http://127.0.0.1:8000/-/routes").status_code == 200 assert httpx.get("http://127.0.0.1:8000/-/routes").text == '{"/":"default"}' wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8001/-/healthz", expected_code=200, expected_text="success", ) assert httpx.get("http://127.0.0.1:8001/-/routes").status_code == 200 assert httpx.get("http://127.0.0.1:8001/-/routes").text == '{"/":"default"}' # Deleting the deployment drops the replicas on all nodes. The proxies and # controller stay alive (the worker proxy drains), so the count is the # pre-delete total minus the 2 replicas. serve.delete(name=SERVE_DEFAULT_APP_NAME) expected_num_actors_after_delete = ( sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1 ) wait_for_condition( lambda: len( list_actors(address=cluster.address, filters=[("STATE", "=", "ALIVE")]) ) == expected_num_actors_after_delete, ) # Ensure head node `/-/healthz` and `/-/routes` continue to # return 200 and expected responses. Also, the worker node # `/-/healthz` and `/-/routes` should return 503 and unavailable # responses. wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8000/-/healthz", expected_code=200, expected_text="success", ) wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8000/-/routes", expected_code=200, expected_text="{}", ) wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8001/-/healthz", expected_code=503, expected_text="This node is being drained.", ) wait_for_condition( condition_predictor=check_request, url="http://127.0.0.1:8001/-/routes", expected_code=503, expected_text="This node is being drained.", ) if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))