503 lines
16 KiB
Python
503 lines
16 KiB
Python
"""Tests for deployment-scoped actor kill-and-recovery and controller restart.
|
|
|
|
These tests are isolated from test_deployment_actors.py because they kill
|
|
deployment actors or the Serve controller, which can destabilize the Ray
|
|
cluster under CI resource constraints. Running them with a function-scoped
|
|
fixture (fresh Ray cluster per test) prevents cascading failures from
|
|
accumulated controller state.
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
|
|
import httpx
|
|
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 DeploymentStatus
|
|
from ray.serve._private.constants import (
|
|
SERVE_DEFAULT_APP_NAME,
|
|
SERVE_DEPLOYMENT_ACTOR_PREFIX,
|
|
SERVE_NAMESPACE,
|
|
)
|
|
from ray.serve._private.test_utils import get_application_url, request_with_retries
|
|
from ray.serve.config import DeploymentActorConfig
|
|
from ray.serve.context import _get_global_client
|
|
from ray.util.state import list_actors
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture: function-scoped Ray cluster for isolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def serve_instance_isolated():
|
|
"""Function-scoped Serve instance for kill-and-recovery tests.
|
|
|
|
Each test gets a fresh Ray cluster so that killing deployment actors
|
|
or the controller does not destabilize subsequent tests.
|
|
"""
|
|
ray.init(
|
|
num_cpus=36,
|
|
namespace="default_test_namespace",
|
|
_metrics_export_port=9999,
|
|
_system_config={"metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50},
|
|
)
|
|
serve.start(proxy_location="HeadOnly", http_options={"host": "0.0.0.0"})
|
|
yield _get_global_client()
|
|
serve.shutdown()
|
|
ray.shutdown()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Actor classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@ray.remote
|
|
class SharedCounter:
|
|
"""A trivial deployment-scoped actor used across several tests."""
|
|
|
|
def __init__(self, start: int = 0):
|
|
self.count = start
|
|
|
|
def increment(self):
|
|
self.count += 1
|
|
return self.count
|
|
|
|
def get(self):
|
|
return self.count
|
|
|
|
def ray_actor_id(self) -> str:
|
|
return ray.get_runtime_context().get_actor_id()
|
|
|
|
|
|
@ray.remote
|
|
class ConstructorGate:
|
|
"""Coordination actor: blocks ``__init__`` until opened."""
|
|
|
|
def __init__(self):
|
|
self._open = True
|
|
|
|
def close(self):
|
|
self._open = False
|
|
|
|
def open(self):
|
|
self._open = True
|
|
|
|
def wait_until_open(self):
|
|
import time
|
|
|
|
while not self._open:
|
|
time.sleep(0.01)
|
|
|
|
|
|
@ray.remote
|
|
class GatedSharedCounter:
|
|
"""Like ``SharedCounter`` but blocks in ``__init__`` until gate opens."""
|
|
|
|
def __init__(self, gate, start: int = 0):
|
|
ray.get(gate.wait_until_open.remote())
|
|
self.count = start
|
|
|
|
def get(self):
|
|
return self.count
|
|
|
|
def ray_actor_id(self) -> str:
|
|
return ray.get_runtime_context().get_actor_id()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _get_deployment_actor_names() -> list:
|
|
actors = list_actors(filters=[("state", "=", "ALIVE")])
|
|
return [
|
|
a["name"]
|
|
for a in actors
|
|
if a["name"] and a["name"].startswith(SERVE_DEPLOYMENT_ACTOR_PREFIX)
|
|
]
|
|
|
|
|
|
def _get_deployment_actor_names_for_app(app_name: str, deployment_name: str) -> list:
|
|
prefix = f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}{app_name}::{deployment_name}::"
|
|
return [
|
|
a["name"]
|
|
for a in list_actors(filters=[("state", "=", "ALIVE")])
|
|
if a.get("name", "").startswith(prefix)
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — Deployment actor crash and recovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_deployment_actor_restarts_on_crash(serve_instance_isolated):
|
|
"""When a deployment actor dies, the Serve controller recreates it.
|
|
|
|
Replicas call ``serve.get_deployment_actor()`` per request here, so they
|
|
resolve a current handle after recreation. HTTP bodies include
|
|
``ray_actor_id`` so we assert a new id after Serve recreates the
|
|
deployment actor.
|
|
"""
|
|
|
|
@serve.deployment(
|
|
name="CrashTestDeployment",
|
|
deployment_actors=[
|
|
DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class=SharedCounter,
|
|
init_kwargs={"start": 100},
|
|
),
|
|
],
|
|
)
|
|
class CrashTestDeployment:
|
|
def __call__(self):
|
|
counter = serve.get_deployment_actor("counter")
|
|
val = ray.get(counter.get.remote())
|
|
aid = ray.get(counter.ray_actor_id.remote())
|
|
return f"{val},{aid}"
|
|
|
|
serve.run(CrashTestDeployment.bind())
|
|
url = f"{get_application_url()}/"
|
|
|
|
def _parse(text: str) -> tuple[str, str]:
|
|
val, aid = text.split(",", 1)
|
|
return val, aid
|
|
|
|
wait_for_condition(lambda: _parse(httpx.get(url).text)[0] == "100")
|
|
_, old_actor_id = _parse(httpx.get(url).text)
|
|
|
|
actor_names = [
|
|
n
|
|
for n in _get_deployment_actor_names()
|
|
if "CrashTestDeployment" in n and "counter" in n
|
|
]
|
|
assert len(actor_names) == 1
|
|
handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
|
|
|
|
ray.kill(handle, no_restart=True)
|
|
|
|
def recovered_new_deployment_actor():
|
|
text = httpx.get(url, timeout=5).text
|
|
val, aid = _parse(text)
|
|
return val == "100" and aid != old_actor_id
|
|
|
|
wait_for_condition(recovered_new_deployment_actor, timeout=120)
|
|
|
|
|
|
def test_deployment_actor_health_check_failure_then_recovery_to_healthy(
|
|
serve_instance_isolated,
|
|
):
|
|
"""E2E: deployment actor fails health poll -> UNHEALTHY -> recreate -> HEALTHY.
|
|
|
|
After killing the deployment actor, we close a ConstructorGate so the
|
|
replacement actor blocks in __init__. While stuck, the deployment stays
|
|
UNHEALTHY. Opening the gate lets construction finish and recover.
|
|
"""
|
|
|
|
dep_name = "DAHealthRecoveryDeployment"
|
|
logical_actor = "counter"
|
|
gate = ConstructorGate.remote()
|
|
|
|
@serve.deployment(
|
|
name=dep_name,
|
|
deployment_actors=[
|
|
DeploymentActorConfig(
|
|
name=logical_actor,
|
|
actor_class=GatedSharedCounter,
|
|
init_kwargs={"gate": gate, "start": 42},
|
|
),
|
|
],
|
|
)
|
|
class DAHealthRecoveryDeployment:
|
|
def __call__(self):
|
|
counter = serve.get_deployment_actor(logical_actor)
|
|
val = ray.get(counter.get.remote())
|
|
aid = ray.get(counter.ray_actor_id.remote())
|
|
return f"{val},{aid}"
|
|
|
|
serve.run(DAHealthRecoveryDeployment.bind(), name=SERVE_DEFAULT_APP_NAME)
|
|
url = f"{get_application_url()}/"
|
|
|
|
def _parse(text: str) -> tuple[str, str]:
|
|
val, aid = text.split(",", 1)
|
|
return val, aid
|
|
|
|
def deployment_status():
|
|
return (
|
|
serve.status()
|
|
.applications[SERVE_DEFAULT_APP_NAME]
|
|
.deployments[dep_name]
|
|
.status
|
|
)
|
|
|
|
def counter_actor_name() -> str:
|
|
names = [
|
|
n
|
|
for n in _get_deployment_actor_names()
|
|
if dep_name in n and logical_actor in n
|
|
]
|
|
assert len(names) == 1
|
|
return names[0]
|
|
|
|
def counter_actor_id_for_name(name: str) -> str:
|
|
for a in list_actors(filters=[("state", "=", "ALIVE")]):
|
|
if a.get("name") == name:
|
|
return a.actor_id
|
|
raise AssertionError(f"No ALIVE actor record for name={name!r}")
|
|
|
|
wait_for_condition(lambda: _parse(httpx.get(url).text)[0] == "42")
|
|
assert deployment_status() == DeploymentStatus.HEALTHY
|
|
|
|
da_name = counter_actor_name()
|
|
old_actor_id = counter_actor_id_for_name(da_name)
|
|
_, http_actor_id_before = _parse(httpx.get(url).text)
|
|
assert http_actor_id_before == old_actor_id
|
|
|
|
ray.get(gate.close.remote())
|
|
ray.kill(
|
|
ray.get_actor(da_name, namespace=SERVE_NAMESPACE),
|
|
no_restart=True,
|
|
)
|
|
|
|
wait_for_condition(
|
|
lambda: deployment_status() == DeploymentStatus.UNHEALTHY,
|
|
timeout=120,
|
|
)
|
|
assert deployment_status() == DeploymentStatus.UNHEALTHY
|
|
|
|
ray.get(gate.open.remote())
|
|
|
|
wait_for_condition(
|
|
lambda: deployment_status() == DeploymentStatus.HEALTHY,
|
|
timeout=120,
|
|
)
|
|
|
|
def http_shows_new_deployment_actor():
|
|
val, aid = _parse(httpx.get(url, timeout=5).text)
|
|
return val == "42" and aid != old_actor_id
|
|
|
|
wait_for_condition(http_shows_new_deployment_actor, timeout=120)
|
|
|
|
new_name = counter_actor_name()
|
|
assert new_name == da_name
|
|
new_actor_id = counter_actor_id_for_name(new_name)
|
|
assert new_actor_id != old_actor_id
|
|
_, http_actor_id_after = _parse(httpx.get(url).text)
|
|
assert http_actor_id_after == new_actor_id
|
|
|
|
|
|
def test_cached_get_deployment_actor_handle_stale_after_recreation(
|
|
serve_instance_isolated,
|
|
):
|
|
"""Stale handle in __init__ vs cache + refresh after the deployment actor dies.
|
|
|
|
(1) A handle cached in __init__ still points at the dead actor after Serve
|
|
recreates it; HTTP stays 5xx.
|
|
|
|
(2) On RayActorError, call get_deployment_actor again to resolve the new
|
|
actor. Retry briefly until the controller finishes recreating.
|
|
"""
|
|
|
|
counter_cfg = DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class=SharedCounter,
|
|
init_kwargs={"start": 7},
|
|
)
|
|
|
|
def kill_stale_test_counter():
|
|
names = [
|
|
n
|
|
for n in _get_deployment_actor_names()
|
|
if "StaleHandleDeployment" in n and "counter" in n
|
|
]
|
|
assert len(names) == 1
|
|
ray.kill(
|
|
ray.get_actor(names[0], namespace=SERVE_NAMESPACE),
|
|
no_restart=True,
|
|
)
|
|
|
|
@serve.deployment(
|
|
name="StaleHandleDeployment",
|
|
deployment_actors=[counter_cfg],
|
|
)
|
|
class StaleHandleDeployment:
|
|
def __init__(self):
|
|
self._counter = serve.get_deployment_actor("counter")
|
|
|
|
def __call__(self):
|
|
return str(ray.get(self._counter.get.remote()))
|
|
|
|
serve.run(StaleHandleDeployment.bind())
|
|
url = f"{get_application_url()}/"
|
|
wait_for_condition(lambda: httpx.get(url).text == "7")
|
|
|
|
kill_stale_test_counter()
|
|
|
|
def replica_still_fails_after_recreation():
|
|
r = httpx.get(url, timeout=10)
|
|
return r.status_code >= 500
|
|
|
|
wait_for_condition(replica_still_fails_after_recreation, timeout=120)
|
|
|
|
@serve.deployment(
|
|
name="StaleHandleDeployment",
|
|
deployment_actors=[counter_cfg],
|
|
)
|
|
class CacheAndRefreshOnRayActorError:
|
|
def __init__(self):
|
|
self._counter = serve.get_deployment_actor("counter")
|
|
|
|
def _resolve_counter_after_actor_died(self):
|
|
deadline = time.monotonic() + 30.0
|
|
last_exc = None
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
self._counter = serve.get_deployment_actor("counter")
|
|
return
|
|
except ValueError as e:
|
|
last_exc = e
|
|
time.sleep(0.05)
|
|
if last_exc is not None:
|
|
raise last_exc
|
|
raise TimeoutError(
|
|
"Timed out waiting for deployment actor name after recreation."
|
|
)
|
|
|
|
def __call__(self):
|
|
try:
|
|
return str(ray.get(self._counter.get.remote()))
|
|
except RayActorError:
|
|
self._resolve_counter_after_actor_died()
|
|
return str(ray.get(self._counter.get.remote()))
|
|
|
|
serve.run(CacheAndRefreshOnRayActorError.bind())
|
|
wait_for_condition(lambda: httpx.get(url).text == "7")
|
|
kill_stale_test_counter()
|
|
wait_for_condition(lambda: httpx.get(url).text == "7", timeout=120)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests — Controller restart with deployment actors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_deployment_actor_survives_controller_restart(serve_instance_isolated):
|
|
"""Deployment actors are detached and survive controller restart.
|
|
|
|
After controller recovers from checkpoint, it discovers existing
|
|
deployment actors and marks them ready. App continues with the same
|
|
Ray actor process.
|
|
"""
|
|
|
|
@serve.deployment(
|
|
deployment_actors=[
|
|
DeploymentActorConfig(
|
|
name="counter",
|
|
actor_class=SharedCounter,
|
|
init_kwargs={"start": 42},
|
|
),
|
|
],
|
|
)
|
|
class MyDeployment:
|
|
def __call__(self):
|
|
counter = serve.get_deployment_actor("counter")
|
|
val = ray.get(counter.get.remote())
|
|
aid = ray.get(counter.ray_actor_id.remote())
|
|
return f"{val},{aid}"
|
|
|
|
serve.run(
|
|
MyDeployment.bind(),
|
|
name="app",
|
|
route_prefix="/survives_controller_restart_da",
|
|
)
|
|
|
|
def _parse(text: str) -> tuple[str, str]:
|
|
val, aid = text.split(",", 1)
|
|
return val, aid
|
|
|
|
actor_id_before = None
|
|
for _ in range(5):
|
|
resp = request_with_retries(timeout=30, app_name="app")
|
|
val, aid = _parse(resp.text)
|
|
assert val == "42"
|
|
if actor_id_before is None:
|
|
actor_id_before = aid
|
|
else:
|
|
assert aid == actor_id_before
|
|
|
|
actor_names_before = _get_deployment_actor_names_for_app("app", "MyDeployment")
|
|
assert len(actor_names_before) == 1
|
|
|
|
ray.kill(serve_instance_isolated._controller, no_restart=False)
|
|
|
|
wait_for_condition(
|
|
lambda: get_application_url("HTTP", "app", use_localhost=True) is not None
|
|
)
|
|
for _ in range(10):
|
|
resp = request_with_retries(timeout=30, app_name="app")
|
|
val, aid = _parse(resp.text)
|
|
assert val == "42"
|
|
assert aid == actor_id_before
|
|
|
|
actor_names_after = _get_deployment_actor_names_for_app("app", "MyDeployment")
|
|
assert actor_names_after == actor_names_before
|
|
|
|
|
|
def test_controller_restart_preserves_mutated_actor_state(serve_instance_isolated):
|
|
"""Controller restart preserves mutated deployment actor state.
|
|
|
|
Mutates the actor's state before the restart and verifies the mutations
|
|
survive — proving the same actor instance continues running.
|
|
"""
|
|
|
|
@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(),
|
|
name="app",
|
|
route_prefix="/preserves_mutated_state_da",
|
|
)
|
|
resp = request_with_retries(timeout=30, app_name="app")
|
|
assert resp.text == "0"
|
|
|
|
actor_names = _get_deployment_actor_names_for_app("app", "MyDeployment")
|
|
assert len(actor_names) == 1
|
|
handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
|
|
for _ in range(5):
|
|
ray.get(handle.increment.remote())
|
|
assert ray.get(handle.get.remote()) == 5
|
|
|
|
ray.kill(serve_instance_isolated._controller, no_restart=False)
|
|
|
|
wait_for_condition(
|
|
lambda: get_application_url("HTTP", "app", use_localhost=True) is not None
|
|
)
|
|
|
|
for _ in range(5):
|
|
resp = request_with_retries(timeout=30, app_name="app")
|
|
assert resp.text == "5"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|