567 lines
16 KiB
Python
567 lines
16 KiB
Python
"""Tests for Serve shutdown behavior (isolated from test_standalone)."""
|
|
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
import ray
|
|
from ray import serve
|
|
from ray._common.test_utils import wait_for_condition
|
|
from ray._raylet import GcsClient
|
|
from ray.serve._private.constants import (
|
|
SERVE_CONTROLLER_NAME,
|
|
SERVE_DEPLOYMENT_ACTOR_PREFIX,
|
|
SERVE_NAMESPACE,
|
|
SERVE_PROXY_NAME,
|
|
)
|
|
from ray.serve._private.default_impl import create_cluster_node_info_cache
|
|
from ray.serve._private.test_utils import expected_proxy_actors
|
|
from ray.serve._private.utils import format_actor_name
|
|
from ray.serve.config import DeploymentActorConfig
|
|
from ray.util.state import list_actors
|
|
|
|
|
|
def test_shutdown(ray_shutdown):
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
|
|
cluster_node_info_cache = create_cluster_node_info_cache(gcs_client)
|
|
cluster_node_info_cache.update()
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind())
|
|
|
|
actor_names = [
|
|
SERVE_CONTROLLER_NAME,
|
|
format_actor_name(
|
|
SERVE_PROXY_NAME,
|
|
cluster_node_info_cache.get_alive_nodes()[0][0],
|
|
),
|
|
]
|
|
|
|
def check_alive():
|
|
alive = True
|
|
for actor_name in actor_names:
|
|
try:
|
|
ray.get_actor(actor_name, namespace=SERVE_NAMESPACE)
|
|
except ValueError:
|
|
alive = False
|
|
return alive
|
|
|
|
wait_for_condition(check_alive)
|
|
|
|
serve.shutdown()
|
|
|
|
def check_dead():
|
|
for actor_name in actor_names:
|
|
try:
|
|
ray.get_actor(actor_name, namespace=SERVE_NAMESPACE)
|
|
return False
|
|
except ValueError:
|
|
pass
|
|
return True
|
|
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_async(ray_shutdown):
|
|
ray.init(num_cpus=8)
|
|
serve.start(http_options=dict(port=8003))
|
|
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
|
|
cluster_node_info_cache = create_cluster_node_info_cache(gcs_client)
|
|
cluster_node_info_cache.update()
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind())
|
|
|
|
actor_names = [
|
|
SERVE_CONTROLLER_NAME,
|
|
format_actor_name(
|
|
SERVE_PROXY_NAME,
|
|
cluster_node_info_cache.get_alive_nodes()[0][0],
|
|
),
|
|
]
|
|
|
|
def check_alive():
|
|
alive = True
|
|
for actor_name in actor_names:
|
|
try:
|
|
ray.get_actor(actor_name, namespace=SERVE_NAMESPACE)
|
|
except ValueError:
|
|
alive = False
|
|
return alive
|
|
|
|
wait_for_condition(check_alive)
|
|
|
|
await serve.shutdown_async()
|
|
|
|
def check_dead():
|
|
for actor_name in actor_names:
|
|
try:
|
|
ray.get_actor(actor_name, namespace=SERVE_NAMESPACE)
|
|
return False
|
|
except ValueError:
|
|
pass
|
|
return True
|
|
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
def test_single_app_shutdown_actors(ray_shutdown):
|
|
"""Tests serve.shutdown() works correctly in single-app case
|
|
|
|
Ensures that after deploying a (nameless) app using serve.run(), serve.shutdown()
|
|
deletes all actors (controller, http proxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
serve.shutdown()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_app_shutdown_actors_async(ray_shutdown):
|
|
"""Tests serve.shutdown_async() works correctly in single-app case
|
|
|
|
Ensures that after deploying a (nameless) app using serve.run(), serve.shutdown_async()
|
|
deletes all actors (controller, http proxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
await serve.shutdown_async()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
def test_multi_app_shutdown_actors(ray_shutdown):
|
|
"""Tests serve.shutdown() works correctly in multi-app case.
|
|
|
|
Ensures that after deploying multiple distinct applications, serve.shutdown()
|
|
deletes all actors (controller, http proxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app1", route_prefix="/app1")
|
|
serve.run(f.bind(), name="app2", route_prefix="/app2")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app1:f",
|
|
"ServeReplica:app2:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
serve.shutdown()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multi_app_shutdown_actors_async(ray_shutdown):
|
|
"""Tests serve.shutdown_async() works correctly in multi-app case.
|
|
|
|
Ensures that after deploying multiple distinct applications, serve.shutdown_async()
|
|
deletes all actors (controller, http proxy, all replicas) in the "serve" namespace.
|
|
"""
|
|
address = ray.init(num_cpus=8)["address"]
|
|
serve.start(http_options=dict(port=8003))
|
|
|
|
@serve.deployment
|
|
def f():
|
|
pass
|
|
|
|
serve.run(f.bind(), name="app1", route_prefix="/app1")
|
|
serve.run(f.bind(), name="app2", route_prefix="/app2")
|
|
|
|
actor_names = {
|
|
"ServeController",
|
|
*expected_proxy_actors(),
|
|
"ServeReplica:app1:f",
|
|
"ServeReplica:app2:f",
|
|
}
|
|
|
|
def check_alive():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return {actor["class_name"] for actor in actors} == actor_names
|
|
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_alive)
|
|
await serve.shutdown_async()
|
|
wait_for_condition(check_dead)
|
|
|
|
|
|
def test_registered_cleanup_actors_killed_on_shutdown(ray_shutdown):
|
|
"""Test that actors registered via _register_shutdown_cleanup_actor are killed.
|
|
|
|
This tests the internal actor registration API that allows deployments to register
|
|
auxiliary actors (like caches, coordinators, etc.) for cleanup on serve.shutdown().
|
|
"""
|
|
ray.init(num_cpus=4)
|
|
serve.start()
|
|
|
|
# Create a detached actor that we'll register for cleanup
|
|
@ray.remote
|
|
class DummyActor:
|
|
def ping(self):
|
|
return "pong"
|
|
|
|
dummy_actor_name = "test_registered_cleanup_dummy"
|
|
dummy = DummyActor.options(
|
|
name=dummy_actor_name, namespace=SERVE_NAMESPACE, lifetime="detached"
|
|
).remote()
|
|
|
|
# Verify actor is alive
|
|
assert ray.get(dummy.ping.remote()) == "pong"
|
|
|
|
# Register the actor with the controller for cleanup
|
|
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
|
ray.get(controller._register_shutdown_cleanup_actor.remote(dummy))
|
|
|
|
# Shutdown serve
|
|
serve.shutdown()
|
|
|
|
# Verify the registered actor is killed
|
|
def check_actor_dead():
|
|
try:
|
|
ray.get_actor(dummy_actor_name, namespace=SERVE_NAMESPACE)
|
|
return False
|
|
except ValueError:
|
|
return True
|
|
|
|
wait_for_condition(check_actor_dead)
|
|
|
|
|
|
def test_serve_shutdown(ray_shutdown):
|
|
ray.init(namespace="serve")
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
class A:
|
|
def __call__(self, *args):
|
|
return "hi"
|
|
|
|
serve.run(A.bind())
|
|
|
|
assert len(serve.status().applications) == 1
|
|
|
|
serve.shutdown()
|
|
serve.start()
|
|
|
|
assert len(serve.status().applications) == 0
|
|
|
|
serve.run(A.bind())
|
|
|
|
assert len(serve.status().applications) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_shutdown_async(ray_shutdown):
|
|
ray.init(namespace="serve")
|
|
serve.start()
|
|
|
|
@serve.deployment
|
|
class A:
|
|
def __call__(self, *args):
|
|
return "hi"
|
|
|
|
serve.run(A.bind())
|
|
|
|
assert len(serve.status().applications) == 1
|
|
|
|
await serve.shutdown_async()
|
|
serve.start()
|
|
|
|
assert len(serve.status().applications) == 0
|
|
|
|
serve.run(A.bind())
|
|
|
|
assert len(serve.status().applications) == 1
|
|
|
|
|
|
def test_shutdown_after_driver_reconnect(ray_shutdown):
|
|
"""serve.shutdown() should work after the driver disconnects and reconnects.
|
|
|
|
Regression test for https://github.com/ray-project/ray/issues/61608.
|
|
When using `with ray.init()` as a context manager, ray.shutdown() is called
|
|
on exit but the module-level _global_client in ray.serve.context is not
|
|
cleared. On the next ray.init(), serve.shutdown() must health-check the
|
|
cached controller handle so it can reconnect instead of using a stale
|
|
handle from the previous session.
|
|
"""
|
|
|
|
@serve.deployment
|
|
class Greeter:
|
|
def __call__(self, *args):
|
|
return "hi"
|
|
|
|
# Session 1: start Serve and deploy. The context manager calls
|
|
# ray.shutdown() on exit, but the detached controller survives.
|
|
with ray.init(namespace="serve"):
|
|
serve.start()
|
|
serve.run(Greeter.bind())
|
|
assert len(serve.status().applications) == 1
|
|
|
|
# Session 2: reconnect and shut down Serve. This must not fail due to
|
|
# a stale _global_client from session 1.
|
|
with ray.init(namespace="serve"):
|
|
serve.shutdown()
|
|
|
|
# Session 3: verify Serve is actually shut down — starting fresh should
|
|
# show zero applications.
|
|
with ray.init(namespace="serve"):
|
|
serve.start()
|
|
assert len(serve.status().applications) == 0
|
|
serve.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shutdown_async_after_driver_reconnect(ray_shutdown):
|
|
"""Async variant: serve.shutdown_async() should work after driver reconnect.
|
|
|
|
Regression test for https://github.com/ray-project/ray/issues/61608.
|
|
"""
|
|
|
|
@serve.deployment
|
|
class Greeter:
|
|
def __call__(self, *args):
|
|
return "hi"
|
|
|
|
with ray.init(namespace="serve"):
|
|
serve.start()
|
|
serve.run(Greeter.bind())
|
|
assert len(serve.status().applications) == 1
|
|
|
|
with ray.init(namespace="serve"):
|
|
await serve.shutdown_async()
|
|
|
|
with ray.init(namespace="serve"):
|
|
serve.start()
|
|
assert len(serve.status().applications) == 0
|
|
await serve.shutdown_async()
|
|
|
|
|
|
def test_shutdown_after_reconnect_shuts_down_live_serve(ray_cluster):
|
|
"""serve.shutdown() must shut down a still-running Serve after the driver
|
|
reconnects to a long-lived cluster.
|
|
|
|
Regression test for https://github.com/ray-project/ray/issues/64647.
|
|
Unlike test_shutdown_after_driver_reconnect (which starts a fresh in-process
|
|
cluster on each ray.init(), so no controller ever survives), this uses a
|
|
persistent cluster. The detached controller survives the driver's
|
|
ray.shutdown(), so a stale cached _global_client left over from the first
|
|
session must not make serve.shutdown() skip shutting the live controller down.
|
|
"""
|
|
cluster = ray_cluster
|
|
head_node = cluster.add_node(num_cpus=8)
|
|
|
|
@serve.deployment
|
|
class First:
|
|
def __call__(self):
|
|
return "first"
|
|
|
|
# Session 1: start Serve. The detached controller survives disconnect.
|
|
ray.init(head_node.address, namespace=SERVE_NAMESPACE)
|
|
serve.start()
|
|
serve.run(First.bind())
|
|
ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE) # sanity: exists
|
|
# Intentionally do NOT clear serve.context._global_client: the stale cached
|
|
# client left behind here is exactly what triggers the bug.
|
|
ray.shutdown()
|
|
|
|
# Session 2: reconnect to the same cluster and shut Serve down.
|
|
ray.init(head_node.address, namespace=SERVE_NAMESPACE)
|
|
serve.shutdown()
|
|
|
|
# The live controller must actually be gone now.
|
|
def controller_gone():
|
|
try:
|
|
ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
|
return False
|
|
except ValueError:
|
|
return True
|
|
|
|
wait_for_condition(controller_gone, timeout=20)
|
|
|
|
|
|
def test_serve_shutdown_cleans_up_deployment_actors(ray_shutdown):
|
|
"""serve.shutdown() kills all deployment actors.
|
|
|
|
Deployment actors are detached, so they must be explicitly killed during
|
|
shutdown.
|
|
"""
|
|
|
|
ray_context = ray.init(num_cpus=4, namespace="default_test_namespace")
|
|
address = ray_context.address_info["address"]
|
|
serve.start()
|
|
|
|
@ray.remote
|
|
class SharedCounter:
|
|
def __init__(self, start: int = 0):
|
|
self.count = start
|
|
|
|
def get(self):
|
|
return self.count
|
|
|
|
@serve.deployment(
|
|
deployment_actors=[
|
|
DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class=SharedCounter,
|
|
init_kwargs={"start": 0},
|
|
),
|
|
],
|
|
)
|
|
class MyDeployment:
|
|
def __call__(self):
|
|
counter = serve.get_deployment_actor("counter")
|
|
return str(ray.get(counter.get.remote()))
|
|
|
|
serve.run(MyDeployment.bind())
|
|
|
|
def _check_deployment_actor_count(expected: int):
|
|
actors = list_actors(address=address, filters=[("state", "=", "ALIVE")])
|
|
names = [
|
|
a["name"]
|
|
for a in actors
|
|
if a["name"] and a["name"].startswith(SERVE_DEPLOYMENT_ACTOR_PREFIX)
|
|
]
|
|
return len(names) == expected
|
|
|
|
wait_for_condition(lambda: _check_deployment_actor_count(1))
|
|
|
|
serve.shutdown()
|
|
|
|
wait_for_condition(lambda: _check_deployment_actor_count(0), timeout=15)
|
|
|
|
|
|
def test_crash_during_shutdown_no_orphaned_actors(ray_shutdown):
|
|
"""Controller crash mid-shutdown should not orphan replica actors."""
|
|
address = ray.init(num_cpus=4)["address"]
|
|
serve.start()
|
|
|
|
@serve.deployment(num_replicas=2)
|
|
class MyModel:
|
|
def __call__(self, request):
|
|
return "hello"
|
|
|
|
serve.run(MyModel.bind(), name="my_app")
|
|
|
|
def replicas_running():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
replicas = [a for a in actors if "ServeReplica" in a.get("class_name", "")]
|
|
return len(replicas) == 2
|
|
|
|
wait_for_condition(replicas_running, timeout=30)
|
|
|
|
# Start shutdown, then immediately kill controller to simulate crash.
|
|
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
|
ray.get(controller.graceful_shutdown.remote(wait=False), timeout=10)
|
|
ray.kill(controller, no_restart=False)
|
|
|
|
# Restarted controller should recover and clean up all actors.
|
|
def check_dead():
|
|
actors = list_actors(
|
|
address=address,
|
|
filters=[("ray_namespace", "=", SERVE_NAMESPACE), ("state", "=", "ALIVE")],
|
|
)
|
|
return len(actors) == 0
|
|
|
|
wait_for_condition(check_dead, timeout=60)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|