Files
ray-project--ray/python/ray/serve/tests/test_failure.py
T
2026-07-13 13:17:40 +08:00

221 lines
6.9 KiB
Python

import ctypes
import os
import random
import sys
import time
import pytest
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.exceptions import RayActorError
from ray.serve._private.common import DeploymentID
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve._private.test_utils import (
Counter,
SharedCounter,
check_num_replicas_eq,
get_deployment_details,
request_with_retries,
tlog,
)
@pytest.mark.skip(reason="Consistently failing.")
def test_controller_failure(serve_instance):
@serve.deployment(name="controller_failure")
def function(_):
return "hello1"
serve.run(function.bind())
assert request_with_retries(timeout=1).text == "hello1"
for _ in range(10):
response = request_with_retries(timeout=30)
assert response.text == "hello1"
ray.kill(serve.context._global_client._controller, no_restart=False)
for _ in range(10):
response = request_with_retries(timeout=30)
assert response.text == "hello1"
def function2(_):
return "hello2"
ray.kill(serve.context._global_client._controller, no_restart=False)
serve.run(function.options(func_or_class=function2).bind())
def check_controller_failure():
response = request_with_retries(timeout=30)
return response.text == "hello2"
wait_for_condition(check_controller_failure)
@serve.deployment(name="controller_failure_2")
def function3(_):
return "hello3"
ray.kill(serve.context._global_client._controller, no_restart=False)
serve.run(function3.bind())
ray.kill(serve.context._global_client._controller, no_restart=False)
for _ in range(10):
response = request_with_retries(timeout=30)
assert response.text == "hello2"
response = request_with_retries(timeout=30)
assert response.text == "hello3"
def _get_worker_handles(deployment_name: str, app_name: str = SERVE_DEFAULT_APP_NAME):
id = DeploymentID(name=deployment_name, app_name=app_name)
controller = serve.context._global_client._controller
deployment_dict = ray.get(controller._all_running_replicas.remote())
return [replica.get_actor_handle() for replica in deployment_dict[id]]
# Test that a worker dying unexpectedly causes it to restart and continue
# serving requests.
def test_worker_restart(serve_instance):
@serve.deployment(name="worker_failure")
class Worker1:
def __call__(self, *args):
return os.getpid()
serve.run(Worker1.bind())
# Get the PID of the worker.
old_pid = request_with_retries(timeout=1).text
# Kill the worker.
handles = _get_worker_handles("worker_failure")
assert len(handles) == 1
ray.kill(handles[0], no_restart=False)
# Wait until the worker is killed and a one is started.
start = time.time()
while time.time() - start < 30:
response = request_with_retries(timeout=30)
if response.text != old_pid:
break
else:
assert False, "Timed out waiting for worker to die."
# Test that if there are multiple replicas for a worker and one dies
# unexpectedly, the others continue to serve requests.
def test_worker_replica_failure(serve_instance):
@serve.deployment(name="replica_failure")
class Worker:
# Assumes that two replicas are started. Will hang forever in the
# constructor for any workers that are restarted.
def __init__(self, counter):
self.should_hang = False
self.index = ray.get(counter.inc.remote())
if self.index > 2:
while True:
pass
def __call__(self, *args):
return self.index
counter = SharedCounter.remote()
serve.run(Worker.options(num_replicas=2).bind(counter))
# Wait until both replicas have been started.
responses = set()
start = time.time()
while time.time() - start < 30:
time.sleep(0.1)
response = request_with_retries(timeout=1).text
assert response in ["1", "2"]
responses.add(response)
if len(responses) > 1:
break
else:
raise TimeoutError("Timed out waiting for replicas after 30s.")
# Kill one of the replicas.
handles = _get_worker_handles("replica_failure")
assert len(handles) == 2
ray.kill(handles[0], no_restart=False)
# Check that the other replica still serves requests.
for _ in range(10):
while True:
try:
# The timeout needs to be small here because the request to
# the restarting worker will hang.
request_with_retries(timeout=0.1)
break
except TimeoutError:
time.sleep(0.1)
@pytest.mark.skipif(
sys.platform == "win32", reason="ActorDiedError not raised properly on windows."
)
@pytest.mark.parametrize("die_during_request", [False, True])
def test_replica_actor_died(serve_instance, die_during_request):
"""Test replica death paired with delayed handle notification.
If a replica died, but controller hasn't health-checked it and
broadcasted it to handle routers yet, the router should be able
to remove it from its replica set.
"""
counter = Counter.remote(2)
@serve.deployment
class Dummy:
def __call__(self, crash: bool = False):
if crash:
ctypes.string_at(0)
return os.getpid()
def check_health(self):
ray.get(counter.inc.remote())
h = serve.run(Dummy.options(num_replicas=2, health_check_period_s=1000).bind())
deployment_details = get_deployment_details("Dummy", _client=serve_instance)
replicas = [r["actor_name"] for r in deployment_details["replicas"]]
# Wait for controller to health check both replicas
ray.get(counter.wait.remote(), timeout=1)
tlog("Controller has health checked both replicas")
# Send some requests, both replicas should be up and running
assert len({h.remote().result() for _ in range(10)}) == 2
tlog("Sent 10 warmup requests.")
# Kill one replica.
if die_during_request:
with pytest.raises(RayActorError):
h.remote(crash=True).result()
else:
replica_to_kill = random.choice(replicas)
tlog(f"Killing replica {replica_to_kill}")
ray.kill(ray.get_actor(replica_to_kill, namespace="serve"))
wait_for_condition(check_num_replicas_eq, name="Dummy", target=1)
# The controller just health checked both of them, so it should not
# be able to health check and notify the handle router in time. Then
# we test that router can properly recognize that the replica has
# died and not send requests to that replica.
pids_returned = set()
for _ in range(10):
pids_returned.add(h.remote().result())
assert len(pids_returned) == 1
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))