Files
2026-07-13 13:17:40 +08:00

1782 lines
57 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import concurrent.futures
import os
import sys
import time
import httpx
import pytest
import yaml
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import DeploymentID, DeploymentStatus, ReplicaState
from ray.serve._private.constants import (
REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
SERVE_DEFAULT_APP_NAME,
SERVE_DEPLOYMENT_ACTOR_PREFIX,
SERVE_NAMESPACE,
)
from ray.serve._private.test_utils import (
check_replica_counts,
check_running,
get_application_url,
)
from ray.serve.config import (
AutoscalingConfig,
DeploymentActorConfig,
GangSchedulingConfig,
)
from ray.serve.exceptions import RayServeException
from ray.serve.schema import ApplicationStatus, ServeDeploySchema
from ray.util.state import list_actors
# ---------------------------------------------------------------------------
# Test actor classes must be at module level for importability via string
# paths in the config-based (declarative) tests.
# ---------------------------------------------------------------------------
@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:
"""Stable Ray actor id for tests that must detect recreation vs same process."""
return ray.get_runtime_context().get_actor_id()
@ray.remote
class ConstructorGate:
"""Coordination actor: deployment-scoped tests can block ``__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):
while not self._open:
time.sleep(0.01)
@ray.remote
class GatedSharedCounter:
"""Like ``SharedCounter`` but blocks in ``__init__`` until ``ConstructorGate`` 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()
@ray.remote
class SharedCache:
def __init__(self):
self.data = {}
def put(self, key, value):
self.data[key] = value
def get(self, key):
return self.data.get(key)
@ray.remote
class EnvReportingActor:
"""Reports env vars for testing runtime_env inheritance."""
def get_env(self, key: str) -> str:
import os
return os.environ.get(key, "NOT_SET")
@ray.remote
class AltCounter:
"""Alternative counter with different behavior, for testing actor class swap."""
def __init__(self, start: int = 0):
self.count = start * 10
def get(self):
return self.count
@ray.remote
class InitArgsActor:
"""Accepts init_args for testing positional constructor args."""
def __init__(self, prefix: str, suffix: str = "default"):
self.prefix = prefix
self.suffix = suffix
def get_value(self) -> str:
return f"{self.prefix}:{self.suffix}"
@ray.remote
class FailingDeploymentActor:
"""Raises in __init__ for testing deployment actor constructor failure."""
def __init__(self, should_fail: bool = True):
if should_fail:
raise RuntimeError("Deployment actor init failed")
@ray.remote
class DeploymentActorContextActor:
"""Reads deployment actor runtime context for testing."""
def get_context(self):
ctx = serve.get_deployment_actor_context()
return {
"deployment": ctx.deployment,
"app_name": ctx.app_name,
"actor_name": ctx.actor_name,
"code_version": ctx.code_version,
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_deployment_actor_names() -> list:
"""Return Ray actor names matching the deployment-actor naming prefix."""
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 _check_no_deployment_actors():
return len(_get_deployment_actor_names()) == 0
def _check_deployment_actor_count(expected: int):
return len(_get_deployment_actor_names()) == expected
def _get_deployment_actor_names_for_app(app_name: str, deployment_name: str) -> list:
"""Return deployment actor names for a specific app and deployment."""
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 Imperative (code-based) API
# ---------------------------------------------------------------------------
def test_imperative_deploy_with_actor_class(serve_instance):
"""Imperative API: deploy with @ray.remote actor class object.
Verifies the actor is created, accessible from the replica via
serve.get_deployment_actor, and that state is shared across requests.
"""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 10},
),
],
)
class MyDeployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
val = ray.get(counter.increment.remote())
return str(val)
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
resp1 = httpx.get(url)
assert resp1.status_code == 200
assert resp1.text == "11"
resp2 = httpx.get(url)
assert resp2.status_code == 200
assert resp2.text == "12"
def test_imperative_deploy_with_string_import_path(serve_instance):
"""Imperative API: deploy with actor_class as a string import path.
The build process should import and serialize the class by value so
it is usable on the controller without the original module.
"""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=("ray.serve.tests.test_deployment_actors: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())
url = f"{get_application_url()}/"
resp = httpx.get(url)
assert resp.status_code == 200
assert resp.text == "0"
def test_deployment_actor_inherits_runtime_env(serve_instance):
"""Deployment actors inherit runtime_env from deployment ray_actor_options."""
@serve.deployment(
ray_actor_options={
"runtime_env": {"env_vars": {"DEPLOYMENT_RUNTIME_ENV": "inherited"}}
},
deployment_actors=[
DeploymentActorConfig(
name="env_actor",
actor_class=EnvReportingActor,
init_kwargs={},
),
],
)
class MyDeployment:
def __call__(self):
actor = serve.get_deployment_actor("env_actor")
return ray.get(actor.get_env.remote("DEPLOYMENT_RUNTIME_ENV"))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "inherited")
def test_imperative_deploy_multiple_actors(serve_instance):
"""Imperative API: deploy with multiple deployment-scoped actors.
Verifies each actor is independently accessible from the replica.
"""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 100},
),
DeploymentActorConfig(
name="cache",
actor_class=SharedCache,
),
],
)
class MyDeployment:
def __call__(self, request):
counter = serve.get_deployment_actor("counter")
cache = serve.get_deployment_actor("cache")
val = ray.get(counter.get.remote())
ray.get(cache.put.remote("count", val))
cached = ray.get(cache.get.remote("count"))
return str(cached)
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
resp = httpx.get(url)
assert resp.status_code == 200
assert resp.text == "100"
# ---------------------------------------------------------------------------
# Tests Declarative (config-based) API
# ---------------------------------------------------------------------------
def test_declarative_deploy_with_deployment_actors(serve_instance):
"""Declarative API: deploy via config dict with string actor_class paths.
This tests the full build-task serialization path where actor classes
are serialized by value inside build_serve_application.
"""
client = serve_instance
config = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": ("ray.serve.tests.test_config_files.world.DagNode"),
"deployments": [
{
"name": "BasicDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 0},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
wait_for_condition(lambda: _check_deployment_actor_count(1))
actor_names = [
n
for n in _get_deployment_actor_names()
if "BasicDriver" in n and "counter" in n
]
assert len(actor_names) == 1
handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
assert ray.get(handle.get.remote()) == 0
assert ray.get(handle.increment.remote()) == 1
def test_declarative_config_only_actors(serve_instance):
"""Config-only deployment actors: actors defined only in config YAML,
not in the code.
The actor class string is extracted on the controller, sent to the build
task for serialization, and the bytes are injected during
override_deployment_info.
"""
client = serve_instance
config = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": ("ray.serve.tests.test_config_files.world.DagNode"),
"deployments": [
{
"name": "BasicDriver",
"deployment_actors": [
{
"name": "shared_counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 42},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
wait_for_condition(lambda: _check_deployment_actor_count(1))
actor_names = [
n
for n in _get_deployment_actor_names()
if "BasicDriver" in n and "shared_counter" in n
]
assert len(actor_names) == 1
handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
assert ray.get(handle.get.remote()) == 42
assert ray.get(handle.increment.remote()) == 43
def test_declarative_config_only_actors_no_version_change(serve_instance):
"""Config-only deployment actors: redeploy with same config (no version change).
Same config redeploy preserves deployment actors; no orphan cleanup.
"""
client = serve_instance
config = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 10},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "10")
actor_names_before = _get_deployment_actor_names()
assert len(actor_names_before) == 1
client.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
wait_for_condition(lambda: httpx.get(url).text == "10")
actor_names_after = _get_deployment_actor_names()
assert actor_names_after == actor_names_before
def test_declarative_config_only_actors_version_change(serve_instance):
"""Config-only deployment actors: redeploy with different config (version change).
Changing init_kwargs triggers new code version; old actors cleaned up,
new actors created.
"""
client = serve_instance
config_v1 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 100},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v1))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "100")
actor_names_v1 = _get_deployment_actor_names()
assert len(actor_names_v1) == 1
config_v2 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 200},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v2))
wait_for_condition(check_running, timeout=30)
wait_for_condition(lambda: httpx.get(url).text == "200")
actor_names_v2 = _get_deployment_actor_names()
assert len(actor_names_v2) == 1
assert actor_names_v1[0] != actor_names_v2[0]
def test_incremental_rollout_version_isolation(serve_instance):
"""During incremental rollout, v1 replicas use v1 deployment actors and
v2 replicas use v2 deployment actors.
Uses SignalActor to synchronize: block 1 v1 replica so it stays during
rollout. New requests go to v2; blocked request proves v1 used v1 actor.
get_deployment_actor resolves by code_version.
"""
signal = SignalActor.remote()
@serve.deployment(
name="test",
num_replicas=2,
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 1},
),
],
)
class V1:
async def __call__(self):
await signal.wait.remote()
counter = serve.get_deployment_actor("counter")
return "1", ray.get(counter.get.remote()), os.getpid()
@serve.deployment(
name="test",
num_replicas=2,
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 2},
),
],
)
class V2:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return "2", ray.get(counter.get.remote()), os.getpid()
h = serve.run(V1.bind(), name="app", route_prefix="/incremental_rollout_da")
signal.send.remote()
refs = [h.remote() for _ in range(10)]
results = [ref.result() for ref in refs]
versions, counter_vals, _ = zip(*results)
assert versions.count("1") == 10
assert counter_vals.count(1) == 10
initial_pids = {r[2] for r in results}
assert len(initial_pids) == 2
# Block 1 v1 replica so it stays during rollout
signal.send.remote(clear=True)
blocked_ref = h.remote()
serve._run(
V2.bind(),
_blocking=False,
name="app",
route_prefix="/incremental_rollout_da",
)
# 1 v1 STOPPING (blocked) + 2 v2 RUNNING
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),
],
)
wait_for_condition(lambda: _check_deployment_actor_count(2))
# New requests go to v2; verify v2 uses v2 actor
refs = [h.remote() for _ in range(10)]
versions, counter_vals, _ = zip(*[ref.result(timeout_s=5) for ref in refs])
assert versions.count("2") == 10
assert counter_vals.count(2) == 10
# Release blocked v1; its response proves v1 used v1 actor
ray.get(signal.send.remote())
version, counter_val, pid = blocked_ref.result(timeout_s=5)
assert version == "1" and counter_val == 1 and pid in initial_pids
# ---------------------------------------------------------------------------
# Tests Actor cleanup and redeployment
# ---------------------------------------------------------------------------
def test_actors_cleaned_up_on_app_delete(serve_instance):
"""Deployment actors are killed when the application is deleted."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
),
],
)
class MyDeployment:
def __call__(self):
return "ok"
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).status_code == 200)
wait_for_condition(lambda: _check_deployment_actor_count(1))
serve.delete(name="default")
wait_for_condition(_check_no_deployment_actors, timeout=15)
def test_redeployment_replaces_actors(serve_instance):
"""Redeploying with updated deployment_actors creates new actors and
cleans up old ones (orphan cleanup).
"""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter_v1",
actor_class=SharedCounter,
init_kwargs={"start": 1},
),
],
)
class MyDeployment:
def __call__(self):
counter = serve.get_deployment_actor("counter_v1")
return str(ray.get(counter.get.remote()))
MyDeployment = MyDeployment.options(_internal=True, version="v1")
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "1")
actor_names_v1 = _get_deployment_actor_names()
assert len(actor_names_v1) == 1
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter_v2",
actor_class=SharedCounter,
init_kwargs={"start": 2},
),
],
)
class MyDeployment: # noqa: F811
def __call__(self):
counter = serve.get_deployment_actor("counter_v2")
return str(ray.get(counter.get.remote()))
MyDeployment = MyDeployment.options(_internal=True, version="v2")
serve.run(MyDeployment.bind())
wait_for_condition(lambda: httpx.get(url).text == "2")
actor_names_v2 = _get_deployment_actor_names()
assert len(actor_names_v2) == 1
assert actor_names_v1[0] != actor_names_v2[0]
def test_replica_context_includes_code_version(serve_instance):
"""Replica context exposes deployment code version."""
@serve.deployment(
num_replicas=1,
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 1},
),
],
)
class MyDeployment:
def __call__(self):
ctx = serve.get_replica_context()
counter = serve.get_deployment_actor("counter")
return f"{ctx.code_version}|{ray.get(counter.get.remote())}"
MyDeployment = MyDeployment.options(_internal=True, version="v1")
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "v1|1")
@serve.deployment(
num_replicas=1,
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 2},
),
],
)
class MyDeployment: # noqa: F811
def __call__(self):
ctx = serve.get_replica_context()
counter = serve.get_deployment_actor("counter")
return f"{ctx.code_version}|{ray.get(counter.get.remote())}"
MyDeployment = MyDeployment.options(_internal=True, version="v2")
serve.run(MyDeployment.bind())
wait_for_condition(lambda: httpx.get(url).text == "v2|2")
def test_redeployment_with_no_actors_cleans_up_old(serve_instance):
"""Redeploying with NO deployment actors cleans up old actors.
Regression test: previously the orphan cleanup early-returned if the
new version had no deployment actors.
"""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
),
],
)
class MyDeployment:
def __call__(self):
return "v1"
MyDeployment = MyDeployment.options(_internal=True, version="v1")
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "v1")
wait_for_condition(lambda: _check_deployment_actor_count(1))
@serve.deployment
class MyDeployment: # noqa: F811
def __call__(self):
return "v2"
MyDeployment = MyDeployment.options(_internal=True, version="v2")
serve.run(MyDeployment.bind())
wait_for_condition(lambda: httpx.get(url).text == "v2")
wait_for_condition(_check_no_deployment_actors, timeout=15)
def test_deployment_actor_constructor_failure_app_status(serve_instance):
"""When a deployment actor's constructor fails, app deploy status is DEPLOY_FAILED."""
@serve.deployment(
max_constructor_retry_count=3,
deployment_actors=[
DeploymentActorConfig(
name="bad_actor",
actor_class=FailingDeploymentActor,
init_kwargs={"should_fail": True},
),
],
)
class MyDeployment:
def __call__(self):
return "ok"
# Non-default app: avoid leaving `default` in DEPLOY_FAILED (teardown).
serve._run(
MyDeployment.bind(),
name="da_ctor_fail_app",
route_prefix="/da_ctor_fail",
_blocking=False,
)
wait_for_condition(
lambda: any(
"ctor_fail" in n and a.status == ApplicationStatus.DEPLOY_FAILED
for n, a in serve.status().applications.items()
),
timeout=60,
)
status = serve.status()
app_status = next(a for n, a in status.applications.items() if "ctor_fail" in n)
assert app_status.status == ApplicationStatus.DEPLOY_FAILED
assert "MyDeployment" in app_status.deployments
assert (
app_status.deployments["MyDeployment"].status == DeploymentStatus.DEPLOY_FAILED
)
serve.delete(
next(n for n in status.applications if "ctor_fail" in n),
_blocking=True,
)
def test_actor_remote_call_error_causes_replica_fail_and_restart(serve_instance):
"""When a replica's health check fails due to an error in an actor remote call
(e.g. deployment actor raises), the replica is marked unhealthy and restarted.
"""
@ray.remote
class SignalGatedHealthCheckActor:
"""Deployment actor whose ping() fails after a signal is sent.
Starts healthy so the test can capture the original replica PID.
After the signal fires, fails exactly `fail_count` times then recovers,
so the replacement replica's health checks succeed.
"""
def __init__(self, fail_count: int = 3):
self._fail_count = fail_count
self._triggered = False
self._fail_calls = 0
def trigger(self):
self._triggered = True
def ping(self):
if not self._triggered:
return "ok"
self._fail_calls += 1
if self._fail_calls <= self._fail_count:
raise RuntimeError("Deployment actor remote call failed")
return "ok"
@serve.deployment(
name="HealthCheckFailDeployment",
deployment_actors=[
DeploymentActorConfig(
name="health_actor",
actor_class=SignalGatedHealthCheckActor,
init_kwargs={
"fail_count": REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
},
),
],
health_check_period_s=0.5,
health_check_timeout_s=1,
)
class HealthCheckFailDeployment:
def __call__(self):
return str(__import__("os").getpid())
def check_health(self):
actor = serve.get_deployment_actor("health_actor")
ray.get(actor.ping.remote())
serve.run(
HealthCheckFailDeployment.bind(),
name=SERVE_DEFAULT_APP_NAME,
route_prefix="/health_check_fail_da",
)
url = f"{get_application_url(app_name=SERVE_DEFAULT_APP_NAME)}/"
wait_for_condition(lambda: httpx.get(url).status_code == 200)
old_pid = httpx.get(url).text
# Trigger the deployment actor to start failing health checks. After
# REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD consecutive failures the
# replica is marked unhealthy and restarted. The replacement replica's
# health checks succeed (actor stops raising after fail_count).
actor_names = [
n
for n in _get_deployment_actor_names()
if "HealthCheckFailDeployment" in n and "health_actor" in n
]
assert len(actor_names) == 1
da_handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
ray.get(da_handle.trigger.remote())
wait_for_condition(
lambda: httpx.get(url, timeout=5).text != old_pid,
timeout=60,
)
for _ in range(5):
assert httpx.get(url).status_code == 200
def test_deployment_actor_killed_no_restart_documents_behavior(serve_instance):
"""Deployment actor killed with no_restart=True: requests fail.
Controller does not detect or recreate. Documents current behavior.
"""
@serve.deployment(
name="NoRestartDeployment",
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 200},
),
],
)
class MyDeployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "200")
actor_names = [
n
for n in _get_deployment_actor_names()
if "NoRestartDeployment" 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)
# Requests fail (500 or RayActorError) when calling the dead actor
resp = httpx.get(url, timeout=10)
assert resp.status_code == 500
def test_app_delete_cleans_up_deployment_actors(serve_instance):
"""Application delete cleans up deployment actors."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
actor_options={"num_cpus": 0.01},
),
],
)
class MyDeployment:
def __call__(self):
return "ok"
serve.run(MyDeployment.bind(), name="delete_test_app", route_prefix="/delete_test")
wait_for_condition(
lambda: len(
_get_deployment_actor_names_for_app("delete_test_app", "MyDeployment")
)
== 1
)
serve.delete(name="delete_test_app")
wait_for_condition(
lambda: len(
_get_deployment_actor_names_for_app("delete_test_app", "MyDeployment")
)
== 0,
timeout=15,
)
def test_deployment_actor_actor_options_override_runtime_env(serve_instance):
"""Deployment actor actor_options override deployment runtime_env."""
@serve.deployment(
ray_actor_options={"runtime_env": {"env_vars": {"ENV_KEY": "from_deployment"}}},
deployment_actors=[
DeploymentActorConfig(
name="env_actor",
actor_class=EnvReportingActor,
actor_options={"runtime_env": {"env_vars": {"ENV_KEY": "from_actor"}}},
),
],
)
class MyDeployment:
def __call__(self):
actor = serve.get_deployment_actor("env_actor")
return ray.get(actor.get_env.remote("ENV_KEY"))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "from_actor")
def test_deployment_actor_with_init_args(serve_instance):
"""Deployment actor with init_args (not just init_kwargs)."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="init_args_actor",
actor_class=InitArgsActor,
init_args=("my_prefix",),
init_kwargs={"suffix": "my_suffix"},
),
],
)
class MyDeployment:
def __call__(self):
actor = serve.get_deployment_actor("init_args_actor")
return ray.get(actor.get_value.remote())
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "my_prefix:my_suffix")
def test_deployment_actor_with_resource_constraints(serve_instance):
"""Deployment actor with actor_options num_cpus schedules correctly."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
actor_options={"num_cpus": 0.01},
),
],
)
class MyDeployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "0")
def test_multiple_apps_same_named_deployment_actors(serve_instance):
"""Multiple apps with same-named deployment actors are isolated."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 1},
),
],
)
class App1Deployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return f"app1:{ray.get(counter.get.remote())}"
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 2},
),
],
)
class App2Deployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return f"app2:{ray.get(counter.get.remote())}"
serve.run(App1Deployment.bind(), name="app1", route_prefix="/multi_app1")
serve.run(App2Deployment.bind(), name="app2", route_prefix="/multi_app2")
url1 = get_application_url("HTTP", "app1", use_localhost=True)
url2 = get_application_url("HTTP", "app2", use_localhost=True)
wait_for_condition(lambda: httpx.get(url1).text == "app1:1")
wait_for_condition(lambda: httpx.get(url2).text == "app2:2")
# Each app has its own deployment actor (isolated by app name)
app1_actors = _get_deployment_actor_names_for_app("app1", "App1Deployment")
app2_actors = _get_deployment_actor_names_for_app("app2", "App2Deployment")
assert len(app1_actors) == 1
assert len(app2_actors) == 1
assert app1_actors[0] != app2_actors[0]
def test_multi_app_deployment_actor_cleanup(serve_instance):
"""Deleting one app cleans only that app's deployment actors."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 1},
),
],
)
class App1Deployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 2},
),
],
)
class App2Deployment:
def __call__(self):
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
serve.run(App1Deployment.bind(), name="app1", route_prefix="/cleanup1")
serve.run(App2Deployment.bind(), name="app2", route_prefix="/cleanup2")
wait_for_condition(
lambda: (
len(_get_deployment_actor_names_for_app("app1", "App1Deployment")) == 1
and len(_get_deployment_actor_names_for_app("app2", "App2Deployment")) == 1
)
)
serve.delete(name="app1")
wait_for_condition(
lambda: (
len(_get_deployment_actor_names_for_app("app1", "App1Deployment")) == 0
and len(_get_deployment_actor_names_for_app("app2", "App2Deployment")) == 1
),
timeout=15,
)
url2 = get_application_url("HTTP", "app2", use_localhost=True)
assert httpx.get(url2).text == "2"
def test_autoscaling_deployment_with_deployment_actors(serve_instance):
"""Autoscaling deployment with deployment actors: replicas share actors."""
@serve.deployment(
autoscaling_config=AutoscalingConfig(
min_replicas=1,
max_replicas=4,
target_ongoing_requests=1,
),
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.increment.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
# Send enough requests to trigger scale-up
for _ in range(20):
httpx.get(url)
# All replicas share the same counter - values should be sequential
values = [int(httpx.get(url).text) for _ in range(5)]
assert values == sorted(values)
assert len(set(values)) == len(values)
def test_multiple_replicas_share_deployment_actor(serve_instance):
"""Multiple replicas share one deployment actor; state is consistent."""
@serve.deployment(
num_replicas=3,
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.increment.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
values = set()
for _ in range(15):
values.add(httpx.get(url).text)
# All replicas share same counter; we should see sequential values
assert len(values) >= 2
sorted_vals = sorted(int(v) for v in values)
assert sorted_vals == list(range(min(sorted_vals), max(sorted_vals) + 1))
def test_concurrent_requests_multiple_replicas_to_deployment_actor(serve_instance):
"""Concurrent requests from multiple replicas to shared actor: no races."""
@serve.deployment(
num_replicas=2,
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.increment.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
futures = [ex.submit(lambda: httpx.get(url).text) for _ in range(20)]
results = [f.result() for f in futures]
# All increments should be unique (no lost updates)
assert len(results) == len(set(results))
def test_get_deployment_actor_invalid_name(serve_instance):
"""get_deployment_actor with nonexistent name causes request to fail."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
),
],
)
class MyDeployment:
def __call__(self):
serve.get_deployment_actor("nonexistent")
return "ok"
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
resp = httpx.get(url)
assert resp.status_code == 500
def test_get_deployment_actor_outside_replica_raises(serve_instance):
"""get_deployment_actor called outside replica raises RayServeException."""
with pytest.raises(RayServeException, match="may only be called from within"):
serve.get_deployment_actor("counter")
def test_get_deployment_actor_context_outside_actor_raises(serve_instance):
"""get_deployment_actor_context called outside actor raises RayServeException."""
with pytest.raises(RayServeException, match="may only be called from within"):
serve.get_deployment_actor_context()
def test_get_deployment_actor_context_returns_runtime_metadata(serve_instance):
"""Deployment actors can read deployment metadata via context API."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="ctx_actor",
actor_class=DeploymentActorContextActor,
),
],
)
class MyDeployment:
def __call__(self):
actor = serve.get_deployment_actor("ctx_actor")
return ray.get(actor.get_context.remote())
handle = serve.run(MyDeployment.bind(), name="metadata-app")
result = handle.remote().result()
assert result["deployment"] == "MyDeployment"
assert result["app_name"] == "metadata-app"
assert result["actor_name"] == "ctx_actor"
assert isinstance(result["code_version"], str)
def test_deploy_from_yaml_config_file_with_deployment_actors(serve_instance):
"""Deploy from YAML config file with deployment_actors."""
config_path = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"deployment_actors.yaml",
)
with open(config_path) as f:
config = yaml.safe_load(f)
serve_instance.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "88", timeout=30)
wait_for_condition(
lambda: len(_get_deployment_actor_names_for_app("default", "ConfigOnlyDriver"))
== 1,
timeout=15,
)
def test_config_only_deployment_actors_with_actor_options(serve_instance):
"""Config-only deployment actors with actor_options in declarative config."""
config = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 99},
"actor_options": {"num_cpus": 0.01},
},
],
},
],
},
],
}
serve_instance.deploy_apps(ServeDeploySchema.model_validate(config))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "99")
def test_gang_scheduling_with_deployment_actors(serve_instance):
"""Gang scheduling and deployment actors work together."""
@serve.deployment(
num_replicas=2,
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
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.increment.remote()))
serve.run(MyDeployment.bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).status_code == 200)
vals = [httpx.get(url).text for _ in range(5)]
assert all(v.isdigit() for v in vals)
def test_user_config_update_with_deployment_actors(serve_instance):
"""user_config update does not affect deployment actors."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 50},
),
],
)
class MyDeployment:
def __init__(self):
self.config_val = None
def reconfigure(self, config):
self.config_val = config
def __call__(self):
counter = serve.get_deployment_actor("counter")
return f"{self.config_val}:{ray.get(counter.get.remote())}"
serve.run(MyDeployment.options(user_config=None).bind())
url = f"{get_application_url()}/"
wait_for_condition(lambda: "None:50" in httpx.get(url).text)
serve.run(MyDeployment.options(user_config="updated").bind(), blocking=False)
wait_for_condition(lambda: "updated:50" in httpx.get(url).text, timeout=30)
def test_redeployment_adds_actors_to_existing_deployment(serve_instance):
"""Redeploying adds deployment actors to a deployment that previously had none.
Uses the declarative path (deploy_apps) so the controller diffs old vs.
new config and exercises the actor-creation branch, unlike serve.run()
which always creates a fresh deployment.
"""
client = serve_instance
config_v1 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.flex_deployment_actor:app"
),
"deployments": [{"name": "FlexDriver"}],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v1))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "no_actor")
assert _check_deployment_actor_count(0)
config_v2 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.flex_deployment_actor:app"
),
"deployments": [
{
"name": "FlexDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 42},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v2))
wait_for_condition(check_running, timeout=30)
wait_for_condition(lambda: httpx.get(url).text == "42")
wait_for_condition(lambda: _check_deployment_actor_count(1))
def test_redeployment_changes_actor_class(serve_instance):
"""Redeploying with a different actor class (same logical name) replaces the actor.
'Actor class changed → Kill old, create new'.
Uses the declarative path so the controller diffs old vs. new actor class.
SharedCounter.get() returns start; AltCounter.get() returns start * 10.
"""
client = serve_instance
config_v1 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.flex_deployment_actor:app"
),
"deployments": [
{
"name": "FlexDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 10},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v1))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "10")
v1_actor_names = _get_deployment_actor_names()
assert len(v1_actor_names) == 1
config_v2 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files.flex_deployment_actor:app"
),
"deployments": [
{
"name": "FlexDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":AltCounter"
),
"init_kwargs": {"start": 10},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v2))
wait_for_condition(check_running, timeout=30)
# AltCounter.get() returns start * 10 = 100, proving the class changed
wait_for_condition(lambda: httpx.get(url).text == "100", timeout=10)
v2_actor_names = _get_deployment_actor_names()
assert len(v2_actor_names) == 1
assert v1_actor_names[0] != v2_actor_names[0]
def test_redeployment_same_name_changed_init_kwargs_resets_state(serve_instance):
"""Changed init_kwargs with the same actor name kills and recreates the actor.
'init_args/kwargs changed → Kill and recreate
(state loss expected)'. Uses the declarative path so the controller diffs
old vs. new init_kwargs. Mutates actor state before redeploy and verifies
the accumulated state is discarded.
"""
client = serve_instance
config_v1 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files"
".config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 0},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v1))
wait_for_condition(check_running, timeout=30)
url = f"{get_application_url()}/"
wait_for_condition(lambda: httpx.get(url).text == "0")
# Mutate actor state directly so it diverges from init value
actor_names = _get_deployment_actor_names()
assert len(actor_names) == 1
handle = ray.get_actor(actor_names[0], namespace=SERVE_NAMESPACE)
for _ in range(5):
ray.get(handle.increment.remote())
wait_for_condition(lambda: httpx.get(url).text == "5")
config_v2 = {
"applications": [
{
"name": "default",
"route_prefix": "/",
"import_path": (
"ray.serve.tests.test_config_files"
".config_only_deployment_actor:app"
),
"deployments": [
{
"name": "ConfigOnlyDriver",
"deployment_actors": [
{
"name": "counter",
"actor_class": (
"ray.serve.tests.test_deployment_actors"
":SharedCounter"
),
"init_kwargs": {"start": 100},
},
],
},
],
},
],
}
client.deploy_apps(ServeDeploySchema.model_validate(config_v2))
wait_for_condition(check_running, timeout=30)
# State reset: returns 100 (new init value), not 5 (old mutated state)
wait_for_condition(lambda: httpx.get(url).text == "100", timeout=30)
def test_partial_actor_creation_failure(serve_instance):
"""When one of multiple deployment actors fails, the deployment fails.
A deployment with two actors — one healthy, one whose constructor raises —
should transition to DEPLOY_FAILED, not partially succeed.
"""
@serve.deployment(
max_constructor_retry_count=3,
deployment_actors=[
DeploymentActorConfig(
name="good_counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
),
DeploymentActorConfig(
name="bad_actor",
actor_class=FailingDeploymentActor,
init_kwargs={"should_fail": True},
),
],
)
class MyDeployment:
def __call__(self):
return "ok"
serve._run(
MyDeployment.bind(),
name="da_partial_fail_app",
route_prefix="/da_partial_fail",
_blocking=False,
)
wait_for_condition(
lambda: any(
"partial_fail" in n and a.status == ApplicationStatus.DEPLOY_FAILED
for n, a in serve.status().applications.items()
),
timeout=60,
)
status = serve.status()
app_status = next(a for n, a in status.applications.items() if "partial_fail" in n)
assert app_status.status == ApplicationStatus.DEPLOY_FAILED
serve.delete(
next(n for n in status.applications if "partial_fail" in n),
_blocking=True,
)
def test_deployment_actors_outlive_replicas_during_deletion(serve_instance):
"""Deployment actors remain alive while replicas are still stopping.
Deployment actors are killed only after all replicas have stopped. This ensures replicas can still reach their deployment
actors during graceful shutdown / request draining.
"""
signal = SignalActor.remote()
@serve.deployment(
name="OutliveTest",
graceful_shutdown_timeout_s=120,
deployment_actors=[
DeploymentActorConfig(
name="counter",
actor_class=SharedCounter,
init_kwargs={"start": 0},
),
],
)
class SlowDrainDeployment:
async def __call__(self):
await signal.wait.remote()
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
h = serve.run(
SlowDrainDeployment.bind(),
name="app",
route_prefix="/outlive_replicas_da",
)
wait_for_condition(lambda: _check_deployment_actor_count(1))
# Send a request that blocks — keeps replica busy during deletion
_ = h.remote()
serve.delete(name="app", _blocking=False)
# Replica should enter STOPPING while draining the in-flight request
wait_for_condition(
lambda: check_replica_counts(
controller=serve_instance._controller,
deployment_id=DeploymentID(name="OutliveTest", app_name="app"),
total=1,
by_state=[(ReplicaState.STOPPING, 1, None)],
),
timeout=15,
)
# Deployment actor must still be alive while replica is stopping
assert _check_deployment_actor_count(1)
# Release the blocked request so replica can finish draining
ray.get(signal.send.remote())
# After replica stops, deployment actor should be cleaned up
wait_for_condition(_check_no_deployment_actors, timeout=30)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))