552 lines
19 KiB
Python
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__]))
|