import asyncio import threading import pytest import ray from ray import serve from ray._common.test_utils import async_wait_for_condition, wait_for_condition from ray.exceptions import RayError from ray.serve._private.common import DeploymentStatus from ray.serve._private.constants import ( REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD, SERVE_DEFAULT_APP_NAME, ) from ray.serve.config import GangSchedulingConfig class Counter: def __init__(self): self._count = 0 def get(self): return self._count def inc(self): self._count += 1 return self._count def reset(self): self._count = 0 @serve.deployment(health_check_period_s=1, health_check_timeout_s=1) class Patient: def __init__(self): self.healthy = True self.should_hang = False def check_health(self): if self.should_hang: import time time.sleep(10000) elif not self.healthy: raise Exception("intended to fail") def __call__(self, *args): return ray.get_runtime_context().current_actor def set_should_fail(self): self.healthy = False return ray.get_runtime_context().current_actor def set_should_hang(self): self.should_hang = True return ray.get_runtime_context().current_actor async def check_new_actor_started(handle, original_actors): if not isinstance(original_actors, set): original_actors = {original_actors._actor_id} try: return (await handle.remote())._actor_id not in original_actors except RayError: return False @pytest.mark.parametrize("use_class", [True, False]) def test_no_user_defined_method(serve_instance, use_class): """Check the default behavior when an actor crashes.""" if use_class: @serve.deployment class A: def __call__(self, *args): return ray.get_runtime_context().current_actor else: @serve.deployment def A(*args): return ray.get_runtime_context().current_actor h = serve.run(A.bind()) actor = h.remote().result() ray.kill(actor) # This would time out if we wait for multiple health check failures. wait_for_condition(check_new_actor_started, handle=h, original_actors=actor) @pytest.mark.asyncio async def test_user_defined_method_fails(serve_instance): h = serve.run(Patient.bind()) actor = await h.remote() await h.set_should_fail.remote() await async_wait_for_condition( check_new_actor_started, handle=h, original_actors=actor ) await asyncio.gather(*[h.remote() for _ in range(100)]) @pytest.mark.asyncio async def test_user_defined_method_hangs(serve_instance): h = serve.run(Patient.options(graceful_shutdown_timeout_s=0).bind()) actor = await h.remote() await h.set_should_hang.remote() await async_wait_for_condition( check_new_actor_started, handle=h, original_actors=actor ) await asyncio.gather(*[h.remote() for _ in range(100)]) @pytest.mark.asyncio async def test_multiple_replicas(serve_instance): h = serve.run(Patient.options(num_replicas=2).bind()) actors = { a._actor_id for a in await asyncio.gather(*[h.remote() for _ in range(100)]) } assert len(actors) == 2 await h.set_should_fail.remote() await async_wait_for_condition( check_new_actor_started, handle=h, original_actors=actors ) new_actors = { a._actor_id for a in await asyncio.gather(*[h.remote() for _ in range(100)]) } assert len(new_actors) == 2 assert len(new_actors.intersection(actors)) == 1 def test_inherit_healthcheck(serve_instance): class Parent: def __init__(self): self.should_fail = False def check_health(self): if self.should_fail: raise Exception("intended to fail") def set_should_fail(self): self.should_fail = True @serve.deployment(health_check_period_s=1) class Child(Parent): def __call__(self, *args): return ray.get_runtime_context().current_actor h = serve.run(Child.bind()) actors = {h.remote().result()._actor_id for _ in range(100)} assert len(actors) == 1 h.set_should_fail.remote().result() wait_for_condition(check_new_actor_started, handle=h, original_actors=actors) def test_nonconsecutive_failures(serve_instance): counter = ray.remote(Counter).remote() # Test that a health check failing every other call isn't marked unhealthy. @serve.deployment(health_check_period_s=0.1) class FlakyHealthCheck: def check_health(self): curr_count = ray.get(counter.inc.remote()) if curr_count % 2 == 0: raise Exception("Ah! I had evens!") def __call__(self, *args): return ray.get_runtime_context().current_actor h = serve.run(FlakyHealthCheck.bind()) a1 = h.remote().result() # Wait for 10 health check periods, should never get marked unhealthy. wait_for_condition(lambda: ray.get(counter.get.remote()) > 10) assert h.remote().result()._actor_id == a1._actor_id def test_consecutive_failures(serve_instance): # Test that the health check must fail N times before being restarted. counter = ray.remote(Counter).remote() @serve.deployment(health_check_period_s=1) class ChronicallyUnhealthy: def __init__(self): self._actor_id = ray.get_runtime_context().current_actor._actor_id self._should_fail = False def check_health(self): if self._should_fail: ray.get(counter.inc.remote()) raise Exception("intended to fail") def set_should_fail(self): self._should_fail = True return self._actor_id def __call__(self, *args): return self._actor_id h = serve.run(ChronicallyUnhealthy.bind()) def check_fails_3_times(): original_actor_id = h.set_should_fail.remote().result() # Wait until a new actor is started. wait_for_condition(lambda: h.remote().result() != original_actor_id) # Check that the health check failed N times before replica was killed. assert ray.get(counter.get.remote()) == REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD # Run the check twice to see that the counter gets reset after a # replica is killed. check_fails_3_times() ray.get(counter.reset.remote()) check_fails_3_times() def test_health_check_failure_cause_deploy_failure(serve_instance): """If a deployment always fails health check, the deployment should be unhealthy.""" @serve.deployment class AlwaysUnhealthy: def check_health(self): raise Exception("intended to fail") def __call__(self, *args): return ray.get_runtime_context().current_actor with pytest.raises(RuntimeError): serve.run(AlwaysUnhealthy.bind()) app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert ( app_status.deployments["AlwaysUnhealthy"].status == DeploymentStatus.DEPLOY_FAILED ) def test_health_check_failure_makes_deployment_unhealthy_transition(serve_instance): """ If a deployment transitions to unhealthy, then continues to fail health check after being restarted, the deployment should be unhealthy. """ class Toggle: def __init__(self): self._should_fail = False def set_should_fail(self): self._should_fail = True def should_fail(self): return self._should_fail @serve.deployment(health_check_period_s=1, health_check_timeout_s=1) class WillBeUnhealthy: def __init__(self, toggle): self._toggle = toggle def check_health(self): if ray.get(self._toggle.should_fail.remote()): raise Exception("intended to fail") def __call__(self, *args): return ray.get_runtime_context().current_actor def check_status(expected_status: DeploymentStatus): app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert app_status.deployments["WillBeUnhealthy"].status == expected_status return True toggle = ray.remote(Toggle).remote() serve.run(WillBeUnhealthy.bind(toggle)) # Check that deployment is healthy initially assert check_status(DeploymentStatus.HEALTHY) ray.get(toggle.set_should_fail.remote()) # Check that deployment is now unhealthy wait_for_condition(check_status, expected_status=DeploymentStatus.UNHEALTHY) # Check that deployment stays unhealthy for _ in range(5): assert check_status(DeploymentStatus.UNHEALTHY) def test_replica_stalled_in_user_code_marked_unhealthy(serve_instance): """ When a replica stalls in the request-serving path and the user-loop watchdog is enabled (RAY_SERVE_USER_HEALTH_CHECK_PROBE_MAX_FAIL > 0), repeated probe timeouts cause call_user_health_check() to raise, the controller marks the replica unhealthy, and a fresh replica is started (issue #61263). The watchdog is on by default (MAX_FAIL=3). We use short intervals here so probe failures accumulate quickly within the test window. """ # threading.Event.wait() blocks the asyncio event loop (unlike asyncio.Event which # yields control). This simulates a replica stuck in a long synchronous call. @serve.deployment( health_check_period_s=1, health_check_timeout_s=3, graceful_shutdown_timeout_s=0, ray_actor_options={ "runtime_env": { "env_vars": { # Enable the user-loop watchdog with short intervals so that # probe failures accumulate quickly within the test window. "RAY_SERVE_USER_HEALTH_CHECK_PROBE_MAX_FAIL": "2", "RAY_SERVE_USER_HEALTH_CHECK_PROBE_INTERVAL_S": "0.5", "RAY_SERVE_USER_HEALTH_CHECK_PROBE_TIMEOUT_S": "1", } } }, ) class StalledReplica: def __init__(self): self._unblock = threading.Event() async def __call__(self): self._unblock.wait() return "ok" def set_unblock(self): self._unblock.set() handle = serve.run(StalledReplica.bind()) # Send a request so the replica blocks on _unblock.wait(), wedging the user loop. ref = handle.remote() def deployment_unhealthy(): app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] return ( app_status.deployments["StalledReplica"].status == DeploymentStatus.UNHEALTHY ) wait_for_condition(deployment_unhealthy, timeout=60) # Unblock so replicas can finish (stalled replica may already be replaced). handle.set_unblock.remote() try: ray.get(ref, timeout=2) except Exception: pass # Wait for deployment to recover (new replica is healthy). def deployment_healthy(): app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] return ( app_status.deployments["StalledReplica"].status == DeploymentStatus.HEALTHY ) wait_for_condition(deployment_healthy, timeout=30) def test_gang_health_check_restarts_gang(serve_instance): """RESTART_GANG tears down the entire gang on failure while the deployment keeps serving traffic with no downtime.""" class Toggle: def __init__(self): self._should_fail = False def set_should_fail(self): self._should_fail = True def unset_should_fail(self): self._should_fail = False def should_fail(self): return self._should_fail toggle = ray.remote(Toggle).remote() @serve.deployment(health_check_period_s=1, health_check_timeout_s=1) class GangPatient: def __init__(self): self._fail = False def check_health(self): if self._fail and ray.get(toggle.should_fail.remote()): raise Exception("intended to fail") def __call__(self): ctx = ray.serve.context._get_internal_replica_context() gc = ctx.gang_context return { "replica_id": ctx.replica_id.unique_id, "gang_id": gc.gang_id if gc else None, } def set_should_fail(self): self._fail = True ctx = ray.serve.context._get_internal_replica_context() gc = ctx.gang_context return { "replica_id": ctx.replica_id.unique_id, "gang_id": gc.gang_id if gc else None, } num_replicas = 4 gang_size = 2 num_gangs = num_replicas // gang_size h = serve.run( GangPatient.options( num_replicas=num_replicas, gang_scheduling_config=GangSchedulingConfig(gang_size=gang_size), ).bind() ) # Collect initial replica state. initial_replicas = {} for _ in range(100): result = h.remote().result() initial_replicas[result["replica_id"]] = result if len(initial_replicas) == num_replicas: break assert len(initial_replicas) == num_replicas # Identify the two distinct gang IDs. gang_ids = {r["gang_id"] for r in initial_replicas.values()} assert len(gang_ids) == num_gangs # Make one replica fail health checks. fail_info = h.set_should_fail.remote().result() target_gang_id = fail_info["gang_id"] surviving_gang_id = (gang_ids - {target_gang_id}).pop() ray.get(toggle.set_should_fail.remote()) # Wait for deployment to become UNHEALTHY. def check_unhealthy(): app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert ( app_status.deployments["GangPatient"].status == DeploymentStatus.UNHEALTHY ) return True wait_for_condition(check_unhealthy, timeout=10) # Zero-downtime check. # While the failed gang is being torn down and before the replacement # gang comes up, the surviving gang must keep serving traffic. for _ in range(30): result = h.remote().result() assert result["gang_id"] == surviving_gang_id # Turn off failures so replacement replicas start healthy. ray.get(toggle.unset_should_fail.remote()) # Wait for deployment to recover. def check_healthy(): app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME] assert app_status.status == "RUNNING" assert app_status.deployments["GangPatient"].status == DeploymentStatus.HEALTHY return True wait_for_condition(check_healthy, timeout=10) # Collect final replica state. final_replicas = {} for _ in range(100): result = h.remote().result() final_replicas[result["replica_id"]] = result if len(final_replicas) == num_replicas: break assert len(final_replicas) == num_replicas # Both replicas from the failed gang should have been replaced. old_gang_ids = { r["replica_id"] for r in initial_replicas.values() if r["gang_id"] == target_gang_id } assert len(old_gang_ids) == gang_size assert old_gang_ids.isdisjoint(final_replicas.keys()) if __name__ == "__main__": import sys sys.exit(pytest.main(["-v", "-s", __file__]))