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

836 lines
26 KiB
Python

"""
The test file for all standalone tests that doesn't
requires a shared Serve instance.
"""
import asyncio
import logging
import os
import random
import socket
import sys
import time
import httpx
import pytest
import ray
import ray._private.state as state
from ray import serve
from ray._common.test_utils import run_string_as_driver, wait_for_condition
from ray._raylet import GcsClient
from ray.cluster_utils import Cluster, cluster_not_supported
from ray.serve._private.api import serve_start_async
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
SERVE_PROXY_NAME,
)
from ray.serve._private.default_impl import create_cluster_node_info_cache
from ray.serve._private.http_util import set_socket_reuse_port
from ray.serve._private.test_utils import expected_proxy_actors
from ray.serve._private.utils import block_until_http_ready, format_actor_name
from ray.serve.config import (
ControllerOptions,
GangSchedulingConfig,
HTTPOptions,
ProxyLocation,
)
from ray.serve.context import _get_global_client
from ray.serve.schema import ServeApplicationSchema, ServeDeploySchema
from ray.util.state import list_actors
def _get_random_port() -> int:
return random.randint(10000, 65535)
@pytest.fixture
def ray_cluster():
if cluster_not_supported:
pytest.skip("Cluster not supported")
cluster = Cluster()
yield Cluster()
serve.shutdown()
ray.shutdown()
cluster.shutdown()
@pytest.fixture()
def lower_slow_startup_threshold_and_reset():
original_slow_startup_warning_s = os.environ.get("SERVE_SLOW_STARTUP_WARNING_S")
original_slow_startup_warning_period_s = os.environ.get(
"SERVE_SLOW_STARTUP_WARNING_PERIOD_S"
)
# Lower slow startup warning threshold to 1 second to reduce test duration
os.environ["SERVE_SLOW_STARTUP_WARNING_S"] = "1"
os.environ["SERVE_SLOW_STARTUP_WARNING_PERIOD_S"] = "1"
ray.init(num_cpus=2)
serve.start()
client = _get_global_client()
yield client
serve.shutdown()
ray.shutdown()
# Reset slow startup warning threshold to prevent state sharing across unit
# tests
if original_slow_startup_warning_s is not None:
os.environ["SERVE_SLOW_STARTUP_WARNING_S"] = original_slow_startup_warning_s
if original_slow_startup_warning_period_s is not None:
os.environ[
"SERVE_SLOW_STARTUP_WARNING_PERIOD_S"
] = original_slow_startup_warning_period_s
def test_deployment(ray_cluster):
# https://github.com/ray-project/ray/issues/11437
cluster = ray_cluster
head_node = cluster.add_node(num_cpus=6)
# Create first job, check we can run a simple serve endpoint
ray.init(head_node.address, namespace=SERVE_NAMESPACE)
first_job_id = ray.get_runtime_context().get_job_id()
serve.start()
@serve.deployment
def f(*args):
return "from_f"
handle = serve.run(f.bind(), name="f", route_prefix="/say_hi_f")
assert handle.remote().result() == "from_f"
assert httpx.get("http://localhost:8000/say_hi_f").text == "from_f"
serve.context._global_client = None
ray.shutdown()
# Create the second job, make sure we can still create new deployments.
ray.init(head_node.address, namespace="serve")
assert ray.get_runtime_context().get_job_id() != first_job_id
@serve.deployment
def g(*args):
return "from_g"
handle = serve.run(g.bind(), name="g", route_prefix="/say_hi_g")
assert handle.remote().result() == "from_g"
assert httpx.get("http://localhost:8000/say_hi_g").text == "from_g"
assert httpx.get("http://localhost:8000/say_hi_f").text == "from_f"
def test_connect(ray_shutdown):
# Check that you can make API calls from within a deployment.
ray.init(num_cpus=8, namespace="serve")
serve.start()
@serve.deployment
def connect_in_deployment(*args):
serve.run(
connect_in_deployment.options(name="deployment-ception").bind(),
name="app2",
route_prefix="/app2",
)
handle = serve.run(connect_in_deployment.bind())
handle.remote().result()
assert "deployment-ception" in serve.status().applications["app2"].deployments
def test_set_socket_reuse_port():
sock = socket.socket()
if hasattr(socket, "SO_REUSEPORT"):
# If the flag exists, we should be able to to use it
assert set_socket_reuse_port(sock)
elif sys.platform == "linux":
# If the flag doesn't exist, but we are only mordern version
# of linux, we should be able to force set this flag.
assert set_socket_reuse_port(sock)
else:
# Otherwise, it should graceful fail without exception.
assert not set_socket_reuse_port(sock)
def _reuse_port_is_available():
sock = socket.socket()
return set_socket_reuse_port(sock)
@pytest.mark.skipif(
not _reuse_port_is_available(),
reason=(
"Port sharing only works on newer verion of Linux. "
"This test can only be ran when port sharing is supported."
),
)
def test_multiple_routers(ray_cluster):
cluster = ray_cluster
head_node = cluster.add_node(num_cpus=4)
cluster.add_node(num_cpus=4)
ray.init(head_node.address)
assert len(ray.nodes()) == 2
serve.start(http_options=dict(port=8005, location="EveryNode"))
@serve.deployment(
num_replicas=2,
ray_actor_options={"num_cpus": 3},
)
class A:
def __call__(self, *args):
return "hi"
serve.run(A.bind())
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()
def get_proxy_names():
proxy_names = []
for node_id in cluster_node_info_cache.get_alive_node_ids():
proxy_names.append(
format_actor_name(
SERVE_PROXY_NAME,
node_id,
)
)
return proxy_names
wait_for_condition(lambda: len(get_proxy_names()) == 2)
original_proxy_names = get_proxy_names()
# Two actors should be started.
def get_first_two_actors():
try:
ray.get_actor(original_proxy_names[0], namespace=SERVE_NAMESPACE)
ray.get_actor(original_proxy_names[1], namespace=SERVE_NAMESPACE)
return True
except ValueError:
return False
wait_for_condition(get_first_two_actors)
# Wait for the actors to come up.
ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes"))
# Kill one of the servers, the HTTP server should still function.
ray.kill(
ray.get_actor(get_proxy_names()[0], namespace=SERVE_NAMESPACE), no_restart=True
)
ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes"))
# Add a new node to the cluster. This should trigger a new router to get
# started.
new_node = cluster.add_node(num_cpus=4)
cluster_node_info_cache.update()
wait_for_condition(lambda: len(get_proxy_names()) == 3)
(third_proxy,) = set(get_proxy_names()) - set(original_proxy_names)
serve.run(A.options(num_replicas=3).bind())
def get_third_actor():
try:
ray.get_actor(third_proxy, namespace=SERVE_NAMESPACE)
return True
# IndexErrors covers when cluster resources aren't updated yet.
except (IndexError, ValueError):
return False
wait_for_condition(get_third_actor)
# Remove the newly-added node from the cluster. The corresponding actor
# should be removed as well.
cluster.remove_node(new_node)
cluster_node_info_cache.update()
def third_actor_removed():
try:
ray.get_actor(third_proxy, namespace=SERVE_NAMESPACE)
return False
except ValueError:
return True
# Check that the actor is gone and the HTTP server still functions.
wait_for_condition(third_actor_removed)
ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes"))
@pytest.mark.skipif(
RAY_SERVE_ENABLE_HA_PROXY,
reason="HAProxy ingress: user HTTP middleware runs on the replica, but the "
"/-/routes endpoint is served by HAProxy, so middleware-injected headers "
"are absent there.",
)
def test_middleware(ray_shutdown):
from starlette.middleware import Middleware
from starlette.middleware.cors import CORSMiddleware
port = _get_random_port()
# `middlewares` in HTTPOptions has been removed; passing it raises an error.
# Use Serve's FastAPI integration to configure middlewares instead.
with pytest.raises(ValueError, match="`middlewares` in HTTPOptions"):
serve.start(
http_options=dict(
port=port,
middlewares=[
Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
],
)
)
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows")
def test_http_root_path(ray_shutdown):
@serve.deployment
def hello():
return "hello"
port = _get_random_port()
root_path = "/serve"
serve.start(http_options=dict(root_path=root_path, port=port))
serve.run(hello.bind(), route_prefix="/hello")
# check routing works as expected
resp = httpx.get(f"http://127.0.0.1:{port}{root_path}/hello")
assert resp.status_code == 200
assert resp.text == "hello"
# check advertized routes are prefixed correctly
resp = httpx.get(f"http://127.0.0.1:{port}{root_path}/-/routes")
assert resp.status_code == 200
assert resp.json() == {"/hello": "default"}
@pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows")
def test_http_proxy_fail_loudly(ray_shutdown):
# Test that if the http server fail to start, serve.start should fail.
with pytest.raises(RuntimeError):
serve.start(http_options={"host": "bad.ip.address"})
def test_no_http(ray_shutdown):
# The following should have the same effect.
# All of these should disable the HTTP proxy.
options = [
{"http_options": {"host": None}},
{"proxy_location": "Disabled"},
{"http_options": {"location": "NoServer"}}, # deprecated override
]
address = ray.init(num_cpus=8)["address"]
for i, option in enumerate(options):
print(f"[{i + 1}/{len(options)}] Running with {option}")
serve.start(**option)
# Only controller actor should exist
live_actors = list_actors(
address=address,
filters=[("state", "=", "ALIVE")],
)
assert len(live_actors) == 1
controller = serve.context._global_client._controller
assert len(ray.get(controller.get_proxies.remote())) == 0
# Test that the handle still works.
@serve.deployment
def hello(*args):
return "hello"
handle = serve.run(hello.bind())
assert handle.remote().result() == "hello"
serve.shutdown()
def test_http_head_only(ray_cluster):
cluster = ray_cluster
head_node = cluster.add_node(num_cpus=4, dashboard_port=_get_random_port())
cluster.add_node(num_cpus=4)
ray.init(head_node.address)
assert len(ray.nodes()) == 2
serve.start(proxy_location="HeadOnly", http_options={"port": _get_random_port()})
# Controller and proxy on the head node. Under HAProxy the proxy is the
# HAProxyManager alongside the fallback ProxyActor, which registers asynchronously.
expected_classes = {"ServeController", *expected_proxy_actors()}
def check_head_only_actors():
actors = list_actors(
address=head_node.address, filters=[("state", "=", "ALIVE")]
)
assert {actor.class_name for actor in actors} == expected_classes
assert all(actor.node_id == head_node.node_id for actor in actors)
return True
wait_for_condition(check_head_only_actors)
def test_instance_in_non_anonymous_namespace(ray_shutdown):
# Can start instance in non-anonymous namespace.
ray.init(namespace="foo")
serve.start()
def test_checkpoint_isolation_namespace(ray_shutdown):
info = ray.init(namespace="test_namespace1")
address = info["address"]
driver_template = """
import ray
from ray import serve
ray.init(address="{address}", namespace="{namespace}")
serve.start(http_options={{"port": {port}}})
@serve.deployment
class A:
pass
serve.run(A.bind())"""
run_string_as_driver(
driver_template.format(address=address, namespace="test_namespace1", port=8000)
)
run_string_as_driver(
driver_template.format(address=address, namespace="test_namespace2", port=8001)
)
def test_serve_start_different_http_checkpoint_options_warning(
ray_shutdown, propagate_logs, caplog
):
logger = logging.getLogger("ray.serve")
caplog.set_level(logging.WARNING, logger="ray.serve")
warning_msg = []
class WarningHandler(logging.Handler):
def emit(self, record):
warning_msg.append(self.format(record))
logger.addHandler(WarningHandler())
ray.init(namespace="serve-test")
serve.start()
# create a different config
test_http = dict(host="127.1.1.8", port=_get_random_port())
serve.start(http_options=test_http)
for test_config, msg in zip([["host", "port"]], warning_msg):
for test_msg in test_config:
if "Autoscaling metrics pusher thread" in msg:
continue
assert test_msg in msg
def test_recovering_controller_no_redeploy():
"""Ensure controller doesn't redeploy running deployments when recovering."""
ray_context = ray.init(namespace="x")
address = ray_context.address_info["address"]
serve.start()
client = _get_global_client()
@serve.deployment
def f():
pass
serve.run(f.bind())
num_actors = len(list_actors(address, filters=[("state", "=", "ALIVE")]))
assert num_actors > 0
pid = ray.get(client._controller.get_pid.remote())
ray.kill(client._controller, no_restart=False)
wait_for_condition(lambda: ray.get(client._controller.get_pid.remote()) != pid)
# Confirm that no new deployment is deployed over the next 5 seconds
with pytest.raises(RuntimeError):
wait_for_condition(
lambda: len(list_actors(address, filters=[("state", "=", "ALIVE")]))
> num_actors,
timeout=5,
)
serve.shutdown()
ray.shutdown()
def test_updating_status_message(lower_slow_startup_threshold_and_reset):
"""Check if status message says if a serve deployment has taken a long time"""
@serve.deployment(
num_replicas=5,
ray_actor_options={"num_cpus": 1},
)
def f(*args):
pass
serve._run(f.bind(), _blocking=False)
def updating_message():
deployment_status = (
serve.status().applications[SERVE_DEFAULT_APP_NAME].deployments["f"]
)
message_substring = "more than 1s to be scheduled."
return (deployment_status.status == "UPDATING") and (
message_substring in deployment_status.message
)
wait_for_condition(updating_message, timeout=20)
def test_gang_updating_status_message(lower_slow_startup_threshold_and_reset):
@serve.deployment(
num_replicas=4,
ray_actor_options={"num_cpus": 1},
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
)
def g(*args):
pass
serve._run(g.bind(), _blocking=False)
def updating_message():
deployment_status = (
serve.status().applications[SERVE_DEFAULT_APP_NAME].deployments["g"]
)
message_substring = "more than 1s to be scheduled."
return (deployment_status.status == "UPDATING") and (
message_substring in deployment_status.message
)
wait_for_condition(updating_message, timeout=20)
def test_unhealthy_override_updating_status(lower_slow_startup_threshold_and_reset):
"""
Check that if status is UNHEALTHY and there is a resource availability
issue, the status should not change. The issue that caused the deployment to
be unhealthy should be prioritized over this resource availability issue.
"""
@serve.deployment
class f:
def __init__(self):
self.num = 1 / 0
def __call__(self, request):
pass
serve._run(f.bind(), _blocking=False)
wait_for_condition(
lambda: serve.status()
.applications[SERVE_DEFAULT_APP_NAME]
.deployments["f"]
.status
== "DEPLOY_FAILED",
timeout=20,
)
with pytest.raises(RuntimeError):
wait_for_condition(
lambda: serve.status()
.applications[SERVE_DEFAULT_APP_NAME]
.deployments["f"]
.status
== "UPDATING",
timeout=10,
)
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Waiter:
def __init__(self):
time.sleep(5)
def __call__(self, *args):
return "May I take your order?"
WaiterNode = Waiter.bind()
def test_build_app_task_uses_zero_cpus(ray_shutdown):
"""Check that the task to build an app uses zero CPUs."""
ray.init(num_cpus=0)
serve.start()
_get_global_client().deploy_apps(
ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="default",
route_prefix="/",
import_path="ray.serve.tests.test_standalone.WaiterNode",
)
],
)
)
# If the task required any resources, this would fail.
wait_for_condition(
lambda: httpx.get("http://localhost:8000/").text == "May I take your order?"
)
serve.shutdown()
ray.shutdown()
def _deploy_flaky_app(counter_file, fail_count: int):
os.environ["FLAKY_BUILD_COUNTER_FILE"] = str(counter_file)
os.environ["FLAKY_BUILD_FAIL_COUNT"] = str(fail_count)
counter_file.write_text("0")
ray.init(num_cpus=1)
serve.start()
_get_global_client().deploy_apps(
ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="flaky_app",
route_prefix="/flaky",
import_path="ray.serve.tests.test_config_files.flaky_build.node",
)
]
)
)
def test_build_app_retries_until_success(ray_shutdown, tmp_path):
"""A flaky build that succeeds on the 4th attempt deploys cleanly."""
counter_file = tmp_path / "counter.txt"
try:
_deploy_flaky_app(counter_file, fail_count=3)
wait_for_condition(
lambda: serve.status().applications["flaky_app"].status == "RUNNING",
timeout=60,
)
assert int(counter_file.read_text()) == 4
finally:
os.environ.pop("FLAKY_BUILD_COUNTER_FILE", None)
os.environ.pop("FLAKY_BUILD_FAIL_COUNT", None)
def test_build_app_fails_after_retries_exhausted(ray_shutdown, tmp_path):
"""If the build keeps failing, the app status surfaces the user error."""
counter_file = tmp_path / "counter.txt"
try:
_deploy_flaky_app(counter_file, fail_count=10)
wait_for_condition(
lambda: serve.status().applications["flaky_app"].status == "DEPLOY_FAILED",
timeout=60,
)
assert "flaky build failure" in serve.status().applications["flaky_app"].message
assert int(counter_file.read_text()) == 4
finally:
os.environ.pop("FLAKY_BUILD_COUNTER_FILE", None)
os.environ.pop("FLAKY_BUILD_FAIL_COUNT", None)
@pytest.mark.parametrize(
"options",
[
# No proxy_location and no location -> default EveryNode.
{
"proxy_location": None,
"http_options": None,
"expected": ProxyLocation.EveryNode,
},
{
"proxy_location": None,
"http_options": {"test": "test"},
"expected": ProxyLocation.EveryNode,
},
# Explicit (deprecated) `location` is a back-compat override.
{
"proxy_location": None,
"http_options": {"location": "NoServer"},
"expected": ProxyLocation.Disabled,
},
# location=None now means "unset" -> defers to proxy_location (EveryNode),
# NOT Disabled (the old None-means-Disabled overload is gone).
{
"proxy_location": None,
"http_options": {"location": None},
"expected": ProxyLocation.EveryNode,
},
# Bare HTTPOptions() defers to proxy_location -> EveryNode (was HeadOnly).
{
"proxy_location": None,
"http_options": HTTPOptions(),
"expected": ProxyLocation.EveryNode,
},
{
"proxy_location": "Disabled",
"http_options": None,
"expected": ProxyLocation.Disabled,
},
{
"proxy_location": "Disabled",
"http_options": {},
"expected": ProxyLocation.Disabled,
},
{
"proxy_location": "Disabled",
"http_options": {"host": "foobar"},
"expected": ProxyLocation.Disabled,
},
# Explicit `location` overrides `proxy_location`.
{
"proxy_location": "Disabled",
"http_options": {"location": "HeadOnly"},
"expected": ProxyLocation.HeadOnly,
},
],
)
def test_serve_start_proxy_location(ray_shutdown, options):
expected = options.pop("expected")
serve.start(**options)
client = _get_global_client()
assert client.get_serve_details()["proxy_location"] == expected
@pytest.mark.parametrize(
"controller_options",
[
ControllerOptions(
runtime_env={
"env_vars": {
"RAY_SERVE_TEST_CONTROLLER_ENV": "from-model",
"RAY_SERVE_TEST_CONTROLLER_ENV_2": "second",
}
}
),
# Same options passed as a plain dict -- the API coerces it
# through ``ControllerOptions.model_validate``.
{
"runtime_env": {
"env_vars": {
"RAY_SERVE_TEST_CONTROLLER_ENV": "from-model",
"RAY_SERVE_TEST_CONTROLLER_ENV_2": "second",
}
}
},
],
)
def test_serve_start_controller_options(ray_shutdown, controller_options):
"""``ControllerOptions.runtime_env.env_vars`` lands on the controller actor.
Uses a custom RAY_SERVE_TEST_CONTROLLER_ENV var (not a real Serve knob)
so the assertion is decoupled from whatever the Anyscale env hook
auto-injects. The merge semantics are the env_hook's contract; this
test only asserts that *our* requested env_vars made it through.
"""
serve.start(controller_options=controller_options)
client = _get_global_client()
# Reach into the controller actor to read its own os.environ; the
# controller is a singleton named actor on the head node, so a remote
# task on the same handle runs in the same process.
def _read_env(self, *, keys):
import os as _os
return {k: _os.environ.get(k) for k in keys}
env_seen = ray.get(
client._controller.__ray_call__.remote(
_read_env,
keys=[
"RAY_SERVE_TEST_CONTROLLER_ENV",
"RAY_SERVE_TEST_CONTROLLER_ENV_2",
],
)
)
assert env_seen["RAY_SERVE_TEST_CONTROLLER_ENV"] == "from-model"
assert env_seen["RAY_SERVE_TEST_CONTROLLER_ENV_2"] == "second"
def test_serve_start_controller_options_rejects_disallowed_runtime_env(
ray_shutdown,
):
"""Bad runtime_env fails at the caller, not from a Ray task."""
from pydantic import ValidationError
with pytest.raises(ValidationError) as exc:
serve.start(controller_options={"runtime_env": {"pip": ["numpy"]}})
assert "only supports ['env_vars']" in str(exc.value)
def test_serve_start_does_not_leak_idle_worker(ray_shutdown):
"""Regression test for #63596 / PR #63597.
Before the fix, ``serve_start_async`` ran ``_start_controller`` as a remote
Ray task and returned the controller ``ActorHandle`` cross-process to the
caller (the Dashboard Agent). That transfer inserted the handle's
ObjectRefs into the executor worker's ``stored_in_objects``, pinning the
worker IDLE forever (it could never drain). Accumulated across calls in a
long-lived caller, this eventually OOM'd the head node.
With the inline fix, controller creation runs in the caller process — there
is no executor worker to pin. This test drives ``serve_start_async`` (the
exact #63596 path the Dashboard Agent uses) and asserts the symptom.
``ray._private.state.workers()`` reports all WORKER-type processes, which
includes the actor-hosting workers for the controller and HTTP proxies.
Those are created on ``serve_start_async`` and reaped on ``serve.shutdown()``
each cycle, so they do not accumulate. The leaked ``_start_controller``
executor, by contrast, was pinned IDLE and SURVIVED ``serve.shutdown()``
(its ``object_id_refs_`` never drained) — so it accumulated across cycles
and grew the count. The non-growth assertion below catches exactly that.
"""
def _worker_count() -> int:
return len(state.workers())
def _settled_worker_count() -> int:
# ``state.workers()`` reflects the GCS worker table, which updates
# asynchronously after processes exit and the raylet deregisters them.
# A fixed sleep is unsafe under CI contention: a not-yet-deregistered
# worker would false-fail the fixed code. Settle by polling until two
# consecutive reads agree, so reaping of the controller/proxy workers
# is complete before sampling. After shutdown only a leaked (pinned)
# executor survives.
seen = {"prev": None, "stable": False}
def _stable() -> bool:
cur = _worker_count()
if seen["prev"] is not None and cur == seen["prev"]:
seen["stable"] = True
seen["prev"] = cur
return seen["stable"]
wait_for_condition(_stable, timeout=30)
return _worker_count()
cycles = 3
counts = []
for _ in range(cycles):
asyncio.run(serve_start_async())
serve.shutdown()
counts.append(_settled_worker_count())
# The count must not grow across cycles — that growth was the leak.
# Allow equal-or-fewer (reaping may reduce it); forbid growth.
assert counts[-1] <= counts[0], (
f"Idle WORKER count grew across {cycles} serve_start_async()/shutdown() "
f"cycles: {counts}. Growth indicates a pinned (leaked) executor worker "
f"— the #63596 regression."
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))