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

552 lines
19 KiB
Python

import asyncio
import logging
import os
import re
import sys
import time
import httpx
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.exceptions import RayTaskError
from ray.serve._private.common import DeploymentID, ReplicaState
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_CONTROLLER_NAME,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
SERVE_PROXY_NAME,
)
from ray.serve._private.test_utils import (
check_replica_counts,
get_application_url,
request_with_retries,
)
from ray.serve.schema import LoggingConfig, ServeDeploySchema
from ray.util.state import list_actors
@pytest.mark.parametrize(
"deployment_options",
[
{"num_replicas": 2},
{"autoscaling_config": {"min_replicas": 2, "max_replicas": 2}},
],
)
def test_recover_start_from_replica_actor_names(serve_instance, deployment_options):
"""Test controller is able to recover starting -> running replicas from
actor names.
"""
# Test failed to deploy with total of 2 replicas,
# but first constructor call fails.
@serve.deployment(
name="recover_start_from_replica_actor_names", **deployment_options
)
class TransientConstructorFailureDeployment:
def __init__(self):
pass
def __call__(self, *args):
return "hii"
serve.run(TransientConstructorFailureDeployment.bind(), name="app")
for _ in range(10):
response = request_with_retries(timeout=30, app_name="app")
assert response.text == "hii"
# Assert 2 replicas are running in deployment deployment after partially
# successful deploy() call with transient error
deployment_dict = ray.get(serve_instance._controller._all_running_replicas.remote())
id = DeploymentID(name="recover_start_from_replica_actor_names", app_name="app")
assert len(deployment_dict[id]) == 2
replica_version_hash = None
for replica in deployment_dict[id]:
ref = replica.get_actor_handle().initialize_and_get_metadata.remote()
_, version, *_ = ray.get(ref)
if replica_version_hash is None:
replica_version_hash = hash(version)
assert replica_version_hash == hash(version), (
"Replica version hash should be the same for "
"same code version and user config."
)
# Sample: [
# 'TransientConstructorFailureDeployment#xlituP',
# 'SERVE_CONTROLLER_ACTOR',
# 'TransientConstructorFailureDeployment#NosHNA',
# 'SERVE_CONTROLLER_ACTOR:SERVE_PROXY_ACTOR-node:192.168.86.165-0']
actor_infos = list_actors(filters=[("state", "=", "ALIVE")])
replica_names = [
actor_info["name"]
for actor_info in actor_infos
if (
SERVE_CONTROLLER_NAME not in actor_info["name"]
and SERVE_PROXY_NAME not in actor_info["name"]
)
]
assert (
len(replica_names) == 2
), "Should have two running replicas fetched from ray API."
# Kill controller and wait for endpoint to be available again
ray.kill(serve.context._global_client._controller, no_restart=False)
wait_for_condition(
lambda: get_application_url("HTTP", "app", use_localhost=True) is not None
)
for _ in range(10):
response = request_with_retries(timeout=30, app_name="app")
assert response.text == "hii"
# Ensure recovered replica names are the same
recovered_actor_infos = list_actors(filters=[("state", "=", "ALIVE")])
recovered_replica_names = [
actor_info["name"]
for actor_info in recovered_actor_infos
if (
SERVE_CONTROLLER_NAME not in actor_info["name"]
and SERVE_PROXY_NAME not in actor_info["name"]
)
]
assert (
recovered_replica_names == replica_names
), "Running replica actor names after recovery must match"
# Ensure recovered replica version has are the same
for replica_name in recovered_replica_names:
actor_handle = ray.get_actor(replica_name, namespace=SERVE_NAMESPACE)
ref = actor_handle.initialize_and_get_metadata.remote()
_, version, *_ = ray.get(ref)
assert replica_version_hash == hash(
version
), "Replica version hash should be the same after recover from actor names"
def test_recover_rolling_update_from_replica_actor_names(serve_instance):
"""Test controller can recover replicas during rolling update.
Replicas starting -> updating -> running
replicas from actor names, with right replica versions during rolling
update.
"""
signal = SignalActor.remote()
@serve.deployment(name="test", num_replicas=2)
class V1:
async def __call__(self):
await signal.wait.remote()
return "1", os.getpid()
@serve.deployment(name="test", num_replicas=2)
class V2:
async def __call__(self):
return "2", os.getpid()
h = serve.run(V1.bind(), name="app")
# Send requests to get pids of initial 2 replicas
signal.send.remote()
refs = [h.remote() for _ in range(10)]
versions, pids = zip(*[ref.result() for ref in refs])
assert versions.count("1") == 10
initial_pids = set(pids)
assert len(initial_pids) == 2
# blocked_ref will block a single replica until the signal is sent.
signal.send.remote(clear=True)
blocked_ref = h.remote()
# Kill the controller
ray.kill(serve.context._global_client._controller, no_restart=False)
# Redeploy new version.
serve._run(V2.bind(), _blocking=False, name="app")
# One replica of the old version should be stuck in stopping because
# of the blocked request. Two replicas of the new version should be
# brought up without waiting for the old replica to stop.
wait_for_condition(
check_replica_counts,
controller=serve_instance._controller,
deployment_id=DeploymentID(name="test", app_name="app"),
total=3,
by_state=[
(ReplicaState.STOPPING, 1, lambda r: r._actor.pid in initial_pids),
(ReplicaState.RUNNING, 2, lambda r: r._actor.pid not in initial_pids),
],
)
# All new requests should be sent to the new running replicas
refs = [h.remote() for _ in range(10)]
versions, pids = zip(*[ref.result(timeout_s=5) for ref in refs])
assert versions.count("2") == 10
pids2 = set(pids)
assert len(pids2 & initial_pids) == 0
# Kill the controller
ray.kill(serve.context._global_client._controller, no_restart=False)
# Release the signal so that the old replica can shutdown
ray.get(signal.send.remote())
val, pid = blocked_ref.result()
assert val == "1"
assert pid in initial_pids
# Now the goal and requests to the new version should complete.
# We should have two running replicas of the new version.
serve_instance._wait_for_application_running("app")
check_replica_counts(
controller=serve_instance._controller,
deployment_id=DeploymentID(name="test", app_name="app"),
total=2,
by_state=(
[(ReplicaState.RUNNING, 2, lambda r: r._actor.pid not in initial_pids)]
),
)
def test_controller_recover_initializing_actor(serve_instance):
"""Controller crash while a replica is still in `__init__`.
The previous controller never finished sending the replica its initial
`initialize_and_get_metadata(rank=...)` call, so the live actor has
neither a rank nor a fully-initialized user callable. The new
controller must detect this via the `was_initialized` probe and
replace the half-initialized actor with a fresh one rather than try to
recover it (which would silently complete its initialization with
`rank=None` and break rank tracking).
"""
signal = SignalActor.remote()
init_started = SignalActor.remote()
client = serve_instance
@ray.remote
def pending_init_indicator():
ray.get(init_started.wait.remote())
return True
@serve.deployment
class V1:
async def __init__(self):
ray.get(init_started.send.remote())
await signal.wait.remote()
def __call__(self, request):
return f"1|{os.getpid()}"
serve._run(V1.bind(), _blocking=False, name="app")
ray.get(pending_init_indicator.remote())
def get_actor_info(name: str):
all_current_actors = list_actors(filters=[("state", "=", "ALIVE")])
for actor in all_current_actors:
if SERVE_PROXY_NAME in actor["name"]:
continue
if name in actor["name"]:
return actor["name"], actor["pid"]
original_actor_tag, _ = get_actor_info(f"app#{V1.name}")
_, controller1_pid = get_actor_info(SERVE_CONTROLLER_NAME)
ray.kill(serve.context._global_client._controller, no_restart=False)
# Wait for the controller to be replaced. `list_actors` can briefly
# report the killed controller as ALIVE (and any new controller as
# PENDING_CREATION) right after `ray.kill`, so wait specifically for
# the pid to change.
def controller_replaced():
info = get_actor_info(SERVE_CONTROLLER_NAME)
return info is not None and info[1] != controller1_pid
wait_for_condition(controller_replaced)
# The new controller's `was_initialized` probe will report False for
# the half-initialized actor, so it is killed and replaced. Wait for
# the replacement replica to start and report it has reached its
# constructor. Unblock its `__init__` once it has.
ray.get(pending_init_indicator.remote())
ray.get(signal.send.remote())
client._wait_for_application_running("app")
# The original half-initialized actor should have been replaced with a
# fresh one (different replica id baked into the actor name).
new_actor_tag, _ = get_actor_info(f"app#{V1.name}")
assert new_actor_tag != original_actor_tag
# And the original actor should actually be dead -- not just hidden by
# the ALIVE filter on a different replica id. `list_actors` may take a
# moment to reflect the kill in its state.
def original_actor_dead():
matching = list_actors(filters=[("name", "=", original_actor_tag)])
# Either the entry has been pruned entirely, or it is reported DEAD.
return not matching or all(a["state"] == "DEAD" for a in matching)
wait_for_condition(original_actor_dead)
def test_replica_deletion_after_controller_recover(serve_instance):
"""Test that replicas are deleted when controller is recovered"""
controller = serve.context._global_client._controller
@serve.deployment(graceful_shutdown_timeout_s=3)
class V1:
async def __call__(self):
while True:
await asyncio.sleep(0.1)
handle = serve.run(V1.bind(), name="app")
_ = handle.remote()
serve.delete("app", _blocking=False)
def check_replica(replica_state=None):
id = DeploymentID(name="V1", app_name="app")
try:
replicas = ray.get(controller._dump_replica_states_for_testing.remote(id))
except RayTaskError as ex:
# Deployment is not existed any more.
if isinstance(ex, KeyError):
return []
# Unexpected exception raised.
raise ex
if replica_state is None:
replica_state = list(ReplicaState)
else:
replica_state = [replica_state]
return replicas.get(replica_state)
# Make sure the replica is in STOPPING state.
wait_for_condition(lambda: len(check_replica(ReplicaState.STOPPING)) > 0)
ray.kill(serve.context._global_client._controller, no_restart=False)
# Make sure the replica is in STOPPING state.
wait_for_condition(lambda: len(check_replica(ReplicaState.STOPPING)) > 0)
# The graceful shutdown timeout of 3 seconds should be used
wait_for_condition(lambda: len(check_replica()) == 0, timeout=20)
# Application should be removed soon after
wait_for_condition(lambda: "app" not in serve.status().applications, timeout=20)
def test_recover_deleting_application(serve_instance):
"""Test that replicas that are stuck on __del__ when the controller crashes,
is properly recovered when the controller is recovered.
This is similar to the test test_replica_deletion_after_controller_recover,
except what's blocking the deployment is __del__ instead of ongoing requests
"""
signal = SignalActor.remote()
@serve.deployment
class A:
async def __del__(self):
await signal.wait.remote()
id = DeploymentID(name="A")
serve.run(A.bind())
@ray.remote
def delete_task():
serve.delete(SERVE_DEFAULT_APP_NAME)
# Delete application and make sure it is stuck on deleting
delete_ref = delete_task.remote()
print("Started task to delete application `default`")
def application_deleting():
# Confirm application is in deleting state
app_status = serve.status().applications[SERVE_DEFAULT_APP_NAME]
assert app_status.status == "DELETING"
# Confirm deployment is in updating state
status = serve_instance.get_all_deployment_statuses()[0]
assert status.name == "A" and status.status == "UPDATING"
# Confirm replica is stopping
replicas = ray.get(
serve_instance._controller._dump_replica_states_for_testing.remote(id)
)
assert replicas.count(states=[ReplicaState.STOPPING]) == 1
# Confirm delete task is still blocked
finished, pending = ray.wait([delete_ref], timeout=0)
assert pending and not finished
return True
def check_deleted():
deployment_statuses = serve_instance.get_all_deployment_statuses()
if len(deployment_statuses) != 0:
return False
finished, pending = ray.wait([delete_ref], timeout=0)
return finished and not pending
wait_for_condition(application_deleting)
for _ in range(10):
time.sleep(0.1)
assert application_deleting()
print("Confirmed that application `default` is stuck on deleting.")
# Kill controller while the application is stuck on deleting
ray.kill(serve.context._global_client._controller, no_restart=False)
print("Finished killing the controller (with restart).")
def check_controller_alive():
all_current_actors = list_actors(filters=[("state", "=", "ALIVE")])
for actor in all_current_actors:
if actor["class_name"] == "ServeController":
return True
return False
wait_for_condition(check_controller_alive)
print("Controller is back alive.")
wait_for_condition(application_deleting)
# Before we send the signal, the application should still be deleting
for _ in range(10):
time.sleep(0.1)
assert application_deleting()
print("Confirmed that application is still stuck on deleting.")
# Since we've confirmed the replica is in a stopping state, we can grab
# the reference to the in-progress graceful shutdown task
replicas = ray.get(
serve_instance._controller._dump_replica_states_for_testing.remote(id)
)
graceful_shutdown_ref = replicas.get()[0]._actor._graceful_shutdown_ref
signal.send.remote()
print("Sent signal to unblock deletion of application")
wait_for_condition(check_deleted)
print("Confirmed that application finished deleting and delete task has returned.")
# Make sure graceful shutdown ran successfully
ray.get(graceful_shutdown_ref)
print("Confirmed that graceful shutdown ran successfully.")
def test_controller_crashes_with_logging_config(serve_instance):
"""Controller persists logging config into kv store, and when controller recover
from crash, it will read logging config from kv store and apply to the
controller and proxy.
"""
@serve.deployment
class Model:
def __init__(self):
self.logger = logging.getLogger("ray.serve")
def __call__(self):
self.logger.debug("this_is_debug_info")
return
serve.run(Model.bind())
client = serve_instance
# Update the logging config
client.update_global_logging_config(
LoggingConfig(encoding="JSON", log_level="DEBUG")
)
def check_log_file(log_file: str, expected_regex: list):
with open(log_file, "r") as f:
s = f.read()
for regex in expected_regex:
assert re.findall(regex, s) != []
return True
# Check the controller update
def check_log_state():
logging_config, _ = ray.get(client._controller._get_logging_config.remote())
assert logging_config.encoding == "JSON"
assert logging_config.log_level == "DEBUG"
return True
wait_for_condition(check_log_state, timeout=60)
_, log_file_path = ray.get(client._controller._get_logging_config.remote())
# DEBUG level check & JSON check
check_log_file(
log_file_path,
[".*Configure the serve controller logger.*", '.*"component_name":.*'],
)
ray.kill(client._controller, no_restart=False)
def check_controller_alive():
all_current_actors = list_actors(filters=[("state", "=", "ALIVE")])
for actor in all_current_actors:
if actor["class_name"] == "ServeController":
return True
return False
wait_for_condition(check_controller_alive)
# Check the controller log config
wait_for_condition(check_log_state)
_, new_log_file_path = ray.get(client._controller._get_logging_config.remote())
assert new_log_file_path != log_file_path
# Check again, make sure the logging config is recovered.
check_log_file(new_log_file_path, ['.*"component_name":.*'])
# Check proxy logging
def check_proxy_handle_in_controller():
proxy_handles = ray.get(client._controller.get_proxies.remote())
expected_num_proxies = 1
if RAY_SERVE_ENABLE_HA_PROXY:
# fallback proxy
expected_num_proxies += 1
assert len(proxy_handles) == expected_num_proxies
return True
wait_for_condition(check_proxy_handle_in_controller)
proxy_handles = ray.get(client._controller.get_proxies.remote())
proxy_handle = list(proxy_handles.values())[0]
file_path = ray.get(proxy_handle._get_logging_config.remote())
# We should see the health check debug log in the proxy logs.
wait_for_condition(
check_log_file,
log_file=file_path,
expected_regex=['"message": "Received health check."'],
timeout=15, # The health check period is 10 seconds.
)
def test_controller_recover_and_deploy(serve_instance):
"""Ensure that in-progress deploy can finish even after controller dies."""
client = serve_instance
signal = SignalActor.options(name="signal123").remote()
config_json = {
"applications": [
{
"name": SERVE_DEFAULT_APP_NAME,
"import_path": "ray.serve.tests.test_config_files.hangs.app",
}
]
}
config = ServeDeploySchema.model_validate(config_json)
client.deploy_apps(config)
wait_for_condition(
lambda: serve.status().applications["default"].status == "DEPLOYING"
)
ray.kill(client._controller, no_restart=False)
signal.send.remote()
# When controller restarts, it should redeploy config automatically
wait_for_condition(
lambda: httpx.get(f"{get_application_url()}/").text == "hello world"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))