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

2789 lines
100 KiB
Python

import asyncio
import json
import os
import socket
import sys
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from typing import Iterable, Optional, Tuple
from uuid import UUID
import grpc
import httpx
import pytest
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import PlainTextResponse
import ray
from ray import serve
from ray._common.test_utils import Semaphore, SignalActor, wait_for_condition
from ray.actor import ActorHandle
from ray.dashboard.modules.serve.sdk import ServeSubmissionClient
from ray.serve._private.common import DeploymentID
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import (
DEFAULT_AUTOSCALING_POLICY_NAME,
HEALTHY_MESSAGE,
RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
RAY_SERVE_DIRECT_INGRESS_PORT_RETRY_COUNT,
RAY_SERVE_ENABLE_DIRECT_INGRESS,
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_DEFAULT_APP_NAME,
SERVE_HTTP_REQUEST_TIMEOUT_S_HEADER,
SERVE_NAMESPACE,
)
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve._private.test_utils import (
check_deployment_status,
check_num_replicas_gte,
check_num_replicas_lte,
get_application_url,
get_application_urls,
ping_grpc_list_applications,
send_signal_on_cancellation,
)
from ray.serve.autoscaling_policy import default_autoscaling_policy
from ray.serve.config import ProxyLocation
from ray.serve.context import _get_global_client
from ray.serve.exceptions import RayServeException
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.generated.serve_pb2 import DeploymentRoute
from ray.serve.schema import (
ApplicationStatus,
DeploymentStatus,
RequestProtocol,
ServeDeploySchema,
ServeInstanceDetails,
)
from ray.serve.tests.conftest import TEST_GRPC_SERVICER_FUNCTIONS
@ray.remote
class Collector:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def get(self):
return self.items
@pytest.fixture
def _skip_if_ff_not_enabled():
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
pytest.skip(
reason="RAY_SERVE_ENABLE_DIRECT_INGRESS not set.",
)
@pytest.fixture
def _skip_if_haproxy_enabled():
if RAY_SERVE_ENABLE_HA_PROXY:
pytest.skip(
reason="RAY_SERVE_ENABLE_HA_PROXY is set.",
)
@pytest.fixture(scope="module")
def _shared_serve_instance():
"""Module-scoped serve instance fixture."""
# Save original env var value
env_var_name = "RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S"
original_value = os.environ.get(env_var_name)
if RAY_SERVE_ENABLE_HA_PROXY:
# Setting a longer minimum draining period ensures that the client connecting
# to the uvicorn server closes the connection first. This prevents the socket
# used by the uvicorn server from entering the TIME_WAIT tcp state, which blocks
# the port from being immediately reused and causes failures in subsequent tests
# that condition on specific ports assignments.
os.environ[env_var_name] = "6"
# These tests assert specific, immediately-reused port assignments. Disable
# the freed-port quarantine so reuse is deterministic; the quarantine itself
# is covered by test_node_port_manager.py.
quarantine_env_var = "RAY_SERVE_PORT_QUARANTINE_S"
quarantine_original = os.environ.get(quarantine_env_var)
os.environ[quarantine_env_var] = "0"
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=ProxyLocation.HeadOnly,
http_options={"host": "0.0.0.0"},
grpc_options={
"port": 9000,
"grpc_servicer_functions": TEST_GRPC_SERVICER_FUNCTIONS,
},
)
yield _get_global_client()
# Cleanup after all tests in this module complete
serve.shutdown()
ray.shutdown()
# Restore original env var value
if original_value is not None:
os.environ[env_var_name] = original_value
elif env_var_name in os.environ:
del os.environ[env_var_name]
# Restore the quarantine env var.
if quarantine_original is not None:
os.environ[quarantine_env_var] = quarantine_original
elif quarantine_env_var in os.environ:
del os.environ[quarantine_env_var]
@pytest.fixture
def serve_instance(_shared_serve_instance):
yield _shared_serve_instance
# Clear all state for 2.x applications and deployments.
_shared_serve_instance.delete_all_apps()
# Clear the ServeHandle cache between tests to avoid them piling up.
_shared_serve_instance.shutdown_cached_handles()
@serve.deployment(name="default-deployment")
class Hybrid:
def __init__(
self,
*,
message: str = "",
raise_error: bool = False,
wait_signal: Optional[ActorHandle] = None,
fail_hc_signal: Optional[ActorHandle] = None,
shutdown_signal: Optional[ActorHandle] = None,
initialize_signal: Optional[ActorHandle] = None,
):
self._message = message
self._raise_error = raise_error
self._wait_signal = wait_signal
self._fail_hc_signal = fail_hc_signal
self._shutdown_signal = shutdown_signal
if initialize_signal is not None:
ray.get(initialize_signal.wait.remote())
async def check_health(self):
# Fail health check once the signal is sent, else pass.
if self._fail_hc_signal is not None:
try:
await asyncio.wait_for(
asyncio.gather(self._fail_hc_signal.wait.remote()), timeout=0.1
)
raise RuntimeError("Failing health check!")
except asyncio.TimeoutError:
pass
async def __del__(self):
if self._shutdown_signal is not None:
await self._shutdown_signal.wait.remote()
async def __call__(self, request: Request):
if self._raise_error:
raise RuntimeError("oops!")
if self._wait_signal:
await self._wait_signal.wait.remote()
return self._message
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
if self._raise_error:
raise RuntimeError("oops!")
if self._wait_signal:
await self._wait_signal.wait.remote()
return serve_pb2.UserDefinedResponse(greeting=self._message)
def get_target_groups(
app_name: str = SERVE_DEFAULT_APP_NAME,
from_proxy_manager: bool = False,
):
client = _get_global_client()
target_groups = ray.get(
client._controller.get_target_groups.remote(app_name, from_proxy_manager)
)
return target_groups
def test_proxy_is_started_on_head_only_mode(_skip_if_ff_not_enabled, serve_instance):
assert len(serve.status().proxies) == 1
def get_http_ports(route_prefix=None, first_only=True):
target_groups = get_target_groups(app_name=None, from_proxy_manager=True)
if first_only:
http_target_group = next(
(
tg
for tg in target_groups
if tg.protocol == RequestProtocol.HTTP
and (route_prefix is None or tg.route_prefix == route_prefix)
)
)
http_targets = http_target_group.targets
http_ports = [target.port for target in http_targets]
return http_ports
else:
http_ports = []
for target_group in target_groups:
if target_group.protocol == RequestProtocol.HTTP and (
route_prefix is None or target_group.route_prefix == route_prefix
):
http_ports.extend([target.port for target in target_group.targets])
return http_ports
def get_grpc_ports(route_prefix=None, first_only=True):
target_groups = get_target_groups(app_name=None, from_proxy_manager=True)
if first_only:
grpc_target_group = next(
(
tg
for tg in target_groups
if tg.protocol == RequestProtocol.GRPC
and (route_prefix is None or tg.route_prefix == route_prefix)
)
)
grpc_targets = grpc_target_group.targets
grpc_ports = [target.port for target in grpc_targets]
return grpc_ports
else:
grpc_ports = []
for target_group in target_groups:
if target_group.protocol == RequestProtocol.GRPC and (
route_prefix is None or target_group.route_prefix == route_prefix
):
grpc_ports.extend([target.port for target in target_group.targets])
return grpc_ports
def _can_bind_to_port(port):
"""Check if we can bind to the port (not just if nothing is listening)."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", port))
sock.close()
return True
except OSError:
sock.close()
return False
# Wait until all ports can be bound (not just until nothing is listening).
# This ensures the ports are fully released from TIME_WAIT state.
def all_ports_can_be_bound(ports: Iterable[int]) -> bool:
for port in ports:
if not _can_bind_to_port(port):
return False
return True
def test_basic(_skip_if_ff_not_enabled, serve_instance):
serve.run(Hybrid.bind(message="Hello world!"))
http_urls = get_application_urls("HTTP")
grpc_urls = get_application_urls("gRPC", from_proxy_manager=True)
# Basic HTTP request.
for http_url in http_urls:
r = httpx.get(http_url)
r.raise_for_status()
assert r.text == "Hello world!"
# Basic gRPC request.
for grpc_url in grpc_urls:
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
assert stub.Method1(serve_pb2.UserDefinedMessage()).greeting == "Hello world!"
channel.close()
def test_internal_server_error(_skip_if_ff_not_enabled, serve_instance):
serve.run(Hybrid.bind(raise_error=True))
http_urls = get_application_urls("HTTP")
grpc_urls = get_application_urls("gRPC", from_proxy_manager=True)
# Basic HTTP request.
for http_url in http_urls:
r = httpx.get(http_url)
assert r.status_code == 500
assert r.text == "Internal Server Error"
# Basic gRPC request.
for grpc_url in grpc_urls:
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
try:
with pytest.raises(grpc.RpcError) as exception_info:
stub.Method1(serve_pb2.UserDefinedMessage())
rpc_error = exception_info.value
assert rpc_error.code() == grpc.StatusCode.INTERNAL
finally:
# Force close the gRPC channel to ensure ports are released
channel.close()
def test_fastapi_app(_skip_if_ff_not_enabled, serve_instance):
fastapi_app = FastAPI()
@serve.deployment
@serve.ingress(fastapi_app)
class FastAPIDeployment:
@fastapi_app.get("/")
def root(self) -> PlainTextResponse:
return PlainTextResponse("Hello from root!")
@fastapi_app.post("/{wildcard}")
def post(self, wildcard: str) -> PlainTextResponse:
return PlainTextResponse(
f"Hello from {wildcard}!",
status_code=201,
)
serve.run(FastAPIDeployment.bind())
http_urls = get_application_urls("HTTP")
# Test GET /.
for http_url in http_urls:
r = httpx.get(http_url)
r.raise_for_status()
assert r.text == "Hello from root!"
# Test POST /{wildcard}.
for http_url in http_urls:
r = httpx.post(f"{http_url}/foobar")
assert r.status_code == 201
assert r.text == "Hello from foobar!"
@pytest.mark.parametrize("use_fastapi", [False, True])
def test_http_request_id(_skip_if_ff_not_enabled, serve_instance, use_fastapi: bool):
if use_fastapi:
fastapi_app = FastAPI()
@serve.deployment
@serve.ingress(fastapi_app)
class EchoRequestID:
@fastapi_app.get("/")
async def root(self, request: Request) -> PlainTextResponse:
return PlainTextResponse(request.headers.get("x-request-id", ""))
else:
@serve.deployment
class EchoRequestID:
async def __call__(self, request: Request) -> str:
return PlainTextResponse(request.headers.get("x-request-id", ""))
serve.run(EchoRequestID.bind())
http_url = get_application_url("HTTP")
# Case 1: no x-request-id passed, should get populated and returned as a header.
r = httpx.get(http_url)
r.raise_for_status()
assert r.text != "" and r.text == r.headers["x-request-id"]
# This call would raise if the request ID isn't a valid UUID.
UUID(r.text, version=4)
# Case 2: x-request-id passed, result and header should match it.
r = httpx.get(http_url, headers={"x-request-id": "TEST-HEADER"})
r.raise_for_status()
assert r.text == "TEST-HEADER" and r.text == r.headers["x-request-id"]
def test_multiplexed_model_id(_skip_if_ff_not_enabled, serve_instance):
pytest.skip("TODO: test that sends a MM ID and checks that it's set correctly")
def test_multiplexing_on_ingress_not_supported(_skip_if_ff_not_enabled, serve_instance):
"""Model multiplexing on the ingress deployment is unsupported with direct ingress.
The multiplexed model ID is propagated through the proxy, which direct ingress
bypasses, so deploying such an app is rejected at build time with a clear error.
"""
@serve.deployment(name="multiplexed-ingress")
class MultiplexedIngress:
@serve.multiplexed(max_num_models_per_replica=2)
async def load_model(self, model_id: str) -> str:
return model_id
async def __call__(self, request: Request) -> str:
return await self.load_model(serve.get_multiplexed_model_id())
with pytest.raises(RayServeException, match="model multiplexing"):
serve.run(MultiplexedIngress.bind())
def test_health_check(_skip_if_ff_not_enabled, serve_instance):
http_port = RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT
grpc_port = RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT
# Previous test's replica may still be releasing its port (app status
# transitions to NOT_STARTED before the OS fully releases the socket).
# Wait for the ports to be free so the new replica actually binds to them.
wait_for_condition(
all_ports_can_be_bound,
ports=[http_port, grpc_port],
timeout=120,
)
wait_signal = SignalActor.remote()
fail_hc_signal = SignalActor.remote()
shutdown_signal = SignalActor.remote()
initialize_signal = SignalActor.remote()
# Use private `_run` API so we can test the behavior before replicas initialize.
serve._run(
# Set a high health check period so we have time to check behavior before the
# controller restarts the replica.
Hybrid.options(health_check_period_s=1).bind(
wait_signal=wait_signal,
fail_hc_signal=fail_hc_signal,
shutdown_signal=shutdown_signal,
initialize_signal=initialize_signal,
),
_blocking=False,
)
# Reuse persistent HTTP client and gRPC channel across retries to avoid
# ephemeral port exhaustion from rapid socket churn in wait_for_condition
# loops (each short-lived connection enters TIME_WAIT for 60s).
http_client = httpx.Client()
grpc_channel = grpc.insecure_channel(f"localhost:{grpc_port}")
grpc_stub = serve_pb2_grpc.RayServeAPIServiceStub(grpc_channel)
def _do_grpc_hc() -> Tuple[grpc.StatusCode, str]:
try:
response, call = grpc_stub.Healthz.with_call(serve_pb2.HealthzRequest())
return call.code(), response.message
except grpc.RpcError as e:
return e.code(), ""
try:
# Wait for replica constructor to start. The direct ingress server should not
# be listening on the port at all yet.
wait_for_condition(
lambda: ray.get(initialize_signal.cur_num_waiters.remote()) == 1
)
for _ in range(10):
with pytest.raises(httpx.ConnectError):
http_client.get(f"http://localhost:{http_port}/-/healthz")
code, _ = _do_grpc_hc()
assert code == grpc.StatusCode.UNAVAILABLE
def _verify_health_check(
*,
passing: bool,
message: str,
) -> bool:
# Check HTTP health check.
expected_status = 200 if passing else 503
r = http_client.get(f"http://localhost:{http_port}/-/healthz")
assert r.status_code == expected_status
assert r.text == message
# Check gRPC health check.
expected_code = (
grpc.StatusCode.OK if passing else grpc.StatusCode.UNAVAILABLE
)
code, response_message = _do_grpc_hc()
assert code == expected_code
# NOTE(edoakes): we can't access the response message if the gRPC call
# fails due to StatusCode.UNAVAILABLE.
if passing:
assert response_message == message
return True
# Signal the constructor to finish and verify that health checks start to pass.
ray.get(initialize_signal.send.remote())
wait_for_condition(
lambda: _verify_health_check(passing=True, message=HEALTHY_MESSAGE),
)
# Signal the health check method to fail and verify that health checks fail.
ray.get(fail_hc_signal.send.remote())
wait_for_condition(
lambda: _verify_health_check(passing=False, message="UNHEALTHY"),
)
# Signal the health check method to pass and verify that health checks pass.
ray.get(fail_hc_signal.send.remote(clear=True))
wait_for_condition(
lambda: _verify_health_check(passing=True, message=HEALTHY_MESSAGE),
)
# Initiate graceful shutdown while a request is in flight: the drain
# waits for it, and during the entire draining phase the direct
# ingress servers must keep answering health checks with 503/DRAINING
# so load balancers can deregister the replica.
with ThreadPoolExecutor() as executor:
in_flight = executor.submit(
httpx.get, f"http://localhost:{http_port}/", timeout=60
)
wait_for_condition(
lambda: ray.get(wait_signal.cur_num_waiters.remote()) == 1
)
serve.delete("default", _blocking=False)
wait_for_condition(
lambda: _verify_health_check(passing=False, message="DRAINING"),
)
for _ in range(10):
assert _verify_health_check(passing=False, message="DRAINING")
# Unblock the in-flight request so the drain can complete.
ray.get(wait_signal.send.remote())
assert in_flight.result(timeout=30).status_code == 200
# Once the drain completes, the replica quiesces: the direct ingress
# servers are shut down gracefully BEFORE the replica reports
# shutdown complete, so by the time the destructor runs (blocked on
# `shutdown_signal` below) the health endpoints must be unreachable
# rather than still serving 503s.
wait_for_condition(
lambda: ray.get(shutdown_signal.cur_num_waiters.remote()) == 1,
)
for _ in range(10):
with pytest.raises(
(httpx.ConnectError, httpx.ReadError, httpx.RemoteProtocolError)
):
http_client.get(f"http://localhost:{http_port}/-/healthz")
code, _ = _do_grpc_hc()
assert code == grpc.StatusCode.UNAVAILABLE
ray.get(shutdown_signal.send.remote())
wait_for_condition(
lambda: len(serve.status().applications) == 0,
)
finally:
http_client.close()
grpc_channel.close()
def _occupy_ports(ports: list) -> list:
"""
Waits up to 120s for all ports to become bindable (previous replicas may
still be shutting down). If some ports remain occupied (e.g. by Ray
internal services that were assigned random ephemeral ports), we proceed
anyway and bind what we can — those ports are equally blocked for replicas.
"""
deadline = time.monotonic() + 120
while True:
sockets = []
for port in ports:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("localhost", port))
sock.listen(1)
sockets.append(sock)
except OSError:
sock.close()
if len(sockets) == len(ports) or time.monotonic() >= deadline:
return sockets
for s in sockets:
s.close()
time.sleep(1)
def test_port_retry_logic(_skip_if_ff_not_enabled, serve_instance):
"""Test that replicas retry port allocation when ports are in use."""
# Start occupying the min HTTP and gRPC ports
sockets = _occupy_ports(
[RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT, RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT]
)
try:
# Deploy an app - it should retry port allocation and eventually fall back
# to shared ingress since we're occupying the ports
serve.run(Hybrid.bind(message="Hello world!"))
target_groups = get_target_groups(from_proxy_manager=True)
# Check HTTP target group
http_target_group = next(
(tg for tg in target_groups if tg.protocol == RequestProtocol.HTTP)
)
assert (
http_target_group.targets[0].port != RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT
)
# Check gRPC target group
grpc_target_group = next(
(tg for tg in target_groups if tg.protocol == RequestProtocol.GRPC)
)
assert (
grpc_target_group.targets[0].port != RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT
)
http_url = get_application_url("HTTP")
# Verify the service still works through shared ingress
r = httpx.get(http_url)
r.raise_for_status()
assert r.text == "Hello world!"
finally:
for s in sockets:
s.close()
def test_replica_gives_up_after_max_port_retries_for_http(
_skip_if_ff_not_enabled, serve_instance
):
"""Test that replicas give up after max port retries."""
# TODO(sheikh): Control env variables
ports = list(
range(
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT
+ RAY_SERVE_DIRECT_INGRESS_PORT_RETRY_COUNT,
)
)
_ = _occupy_ports(ports)
serve._run(Hybrid.bind(message="Hello world!"), _blocking=False)
# wait to deployment to be DEPLOY_FAILED
def _func():
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
status = (
serve_details.applications["default"]
.deployments["default-deployment"]
.status
)
assert status == DeploymentStatus.DEPLOY_FAILED
return True
wait_for_condition(_func, timeout=20)
serve.delete("default", _blocking=True)
def test_replica_gives_up_after_max_port_retries_for_grpc(
_skip_if_ff_not_enabled, serve_instance
):
"""Test that replicas give up after max port retries."""
ports = list(
range(
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT
+ RAY_SERVE_DIRECT_INGRESS_PORT_RETRY_COUNT,
)
)
_ = _occupy_ports(ports)
serve._run(Hybrid.bind(message="Hello world!"), _blocking=False)
# wait to deployment to be DEPLOY_FAILED
def _func():
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
status = (
serve_details.applications["default"]
.deployments["default-deployment"]
.status
)
assert status == DeploymentStatus.DEPLOY_FAILED
return True
wait_for_condition(_func, timeout=20)
serve.delete("default", _blocking=True)
def test_no_port_available(_skip_if_ff_not_enabled, serve_instance):
"""Test that replicas give up after max port retries."""
ports = list(
range(
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT,
)
)
_ = _occupy_ports(ports)
"""Test that multiple replicas on the same node occupy unique ports."""
serve._run(
Hybrid.options(name="default-deployment").bind(message="Hello world!"),
_blocking=False,
)
# check that the deployment failed
def _func():
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
assert (
serve_details.applications["default"]
.deployments["default-deployment"]
.status
== DeploymentStatus.DEPLOY_FAILED
)
assert (
serve_details.applications["default"].status
== ApplicationStatus.DEPLOY_FAILED
)
return True
wait_for_condition(_func, timeout=20)
def test_replica_releases_ports_on_shutdown(_skip_if_ff_not_enabled, serve_instance):
"""Test that replicas release ports on shutdown."""
expected_http_ports = {30000, 30001, 30002, 30003}
expected_grpc_ports = {40000, 40001, 40002, 40003}
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
http_ports = get_http_ports()
grpc_ports = get_grpc_ports()
assert set(http_ports) == expected_http_ports
assert set(grpc_ports) == expected_grpc_ports
assert len(http_ports) == 4
assert len(grpc_ports) == 4
def _is_port_in_use(port):
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
return sock.connect_ex(("0.0.0.0", port)) == 0
# Check that the ports are occupied
for http_port in http_ports:
assert _is_port_in_use(http_port)
for grpc_port in grpc_ports:
assert _is_port_in_use(grpc_port)
# make requests to the application
for http_port in http_ports:
req = httpx.get(f"http://localhost:{http_port}/")
assert req.status_code == 200
assert req.text == "Hello world!"
for grpc_port in grpc_ports:
channel = grpc.insecure_channel(f"localhost:{grpc_port}")
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
assert stub.Method1(serve_pb2.UserDefinedMessage()).greeting == "Hello world!"
channel.close()
# Shutdown the replica
serve.delete("default", _blocking=True)
# Wait until ports are fully out of TIME_WAIT before redeploying, otherwise
# the port allocator may skip a port that is still in TIME_WAIT and the
# expected port set won't match on the second deployment.
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
# redeploy the application
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
http_ports = get_http_ports()
grpc_ports = get_grpc_ports()
assert set(http_ports) == expected_http_ports
assert set(grpc_ports) == expected_grpc_ports
assert len(http_ports) == 4
assert len(grpc_ports) == 4
def test_get_serve_instance_details(_skip_if_ff_not_enabled, serve_instance):
"""Test that get_serve_instance_details returns the correct information."""
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
target_groups = get_target_groups(from_proxy_manager=True)
assert len(target_groups) == 2
assert len(target_groups[0].targets) == 4
assert len(target_groups[1].targets) == 4
def test_only_ingress_deployment_replicas_are_used_for_target_groups(
_skip_if_ff_not_enabled, serve_instance
):
@serve.deployment(num_replicas=2)
class DownstreamDeployment:
def __init__(self):
pass
def __call__(self):
return "downstream-deployment"
@serve.deployment(num_replicas=3)
class IngressDeployment:
def __init__(self, downstream_deployment: DownstreamDeployment):
self.downstream_deployment = downstream_deployment
async def __call__(self):
res = await self.downstream_deployment.remote()
return f"ingress-deployment-{res}"
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
res = await self.downstream_deployment.remote()
return serve_pb2.UserDefinedResponse(greeting=f"ingress-deployment-{res}")
serve.run(
IngressDeployment.options(name="ingress-deployment").bind(
DownstreamDeployment.options(name="downstream-deployment").bind()
)
)
target_groups = get_target_groups(from_proxy_manager=True)
assert len(target_groups) == 2
assert len(target_groups[0].targets) == 3
assert len(target_groups[1].targets) == 3
# test that the target groups are unique and contain the correct ports for ingress deployment
http_ports = get_http_ports()
grpc_ports = get_grpc_ports()
assert len(set(http_ports) & {30000, 30001, 30002, 30003, 30004}) == 3
assert len(set(grpc_ports) & {40000, 40001, 40002, 40003, 40004}) == 3
http_urls = get_application_urls("HTTP")
grpc_urls = get_application_urls("gRPC", from_proxy_manager=True)
for http_url in http_urls:
req = httpx.get(http_url)
assert req.status_code == 200
assert req.text == "ingress-deployment-downstream-deployment"
for grpc_url in grpc_urls:
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
assert (
stub.Method1(serve_pb2.UserDefinedMessage()).greeting
== "ingress-deployment-downstream-deployment"
)
channel.close()
def test_crashed_replica_port_is_released_and_reused(
_skip_if_ff_not_enabled, serve_instance
):
"""Test that crashed replica port is released and reused."""
expected_http_ports = {30000, 30001, 30002, 30003}
expected_grpc_ports = {40000, 40001, 40002, 40003}
# Wait until all ports can be bound (not just until nothing is listening).
# This ensures the ports are fully released from TIME_WAIT state.
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
http_ports = get_http_ports()
grpc_ports = get_grpc_ports()
assert set(http_ports) == expected_http_ports
assert set(grpc_ports) == expected_grpc_ports
# delete the application
serve.delete("default", _blocking=True)
# Wait until ports are fully out of TIME_WAIT before redeploying.
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
# run the deployment again
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
new_http_ports = get_http_ports()
new_grpc_ports = get_grpc_ports()
assert set(http_ports) == set(new_http_ports)
assert set(grpc_ports) == set(new_grpc_ports)
# get pid of the replicas
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
replicas = (
serve_details.applications["default"].deployments["default-deployment"].replicas
)
pids = [replica.pid for replica in replicas]
# kill the replicas
import os
import signal
# force kill the replicas
os.kill(pids[0], signal.SIGKILL)
# keyboard interrupt the replicas
os.kill(pids[1], signal.SIGINT)
# TODO(sheikh): Find a way to gracefully stop the replicas
def _func():
# get pid of the replicas
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
replicas = (
serve_details.applications["default"]
.deployments["default-deployment"]
.replicas
)
new_pids = [replica.pid for replica in replicas]
assert new_pids != pids and len(new_pids) == 4
return True
wait_for_condition(_func, timeout=20)
# wait for deployment to be running
def _func2():
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
assert (
serve_details.applications["default"]
.deployments["default-deployment"]
.status
== DeploymentStatus.HEALTHY
)
return True
wait_for_condition(lambda: _func2(), timeout=30)
# check that the ports are released
after_crash_http_ports = get_http_ports()
after_crash_grpc_ports = get_grpc_ports()
assert len(after_crash_http_ports) == 4
assert len(after_crash_grpc_ports) == 4
# Show that smart port selection is working even with crashed ports.
# We expect the used ports to be within 4 ports of the original ports since
# we have 4 replicas and they can all be in TIME_WAIT state after deletion.
assert set(after_crash_http_ports).issubset(
range(
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT + 8,
)
)
assert set(after_crash_grpc_ports).issubset(
range(
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT,
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT + 8,
)
)
# make requests to the application
for http_port in after_crash_http_ports:
req = httpx.get(f"http://localhost:{http_port}/")
assert req.status_code == 200
assert req.text == "Hello world!"
for grpc_port in after_crash_grpc_ports:
channel = grpc.insecure_channel(f"localhost:{grpc_port}")
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
assert stub.Method1(serve_pb2.UserDefinedMessage()).greeting == "Hello world!"
channel.close()
def test_multiple_applications_on_same_node(_skip_if_ff_not_enabled, serve_instance):
"""Test that multiple applications, such that each app has a ingress deployment"""
expected_http_ports = {30000, 30001, 30002, 30003}
expected_grpc_ports = {40000, 40001, 40002, 40003}
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
@serve.deployment(num_replicas=2)
def deployment_1():
return "deployment-1"
@serve.deployment(num_replicas=2)
def deployment_2():
return "deployment-2"
serve.run(
deployment_1.options(name="deployment-1").bind(),
name="app-1",
route_prefix="/app-1",
)
serve.run(
deployment_2.options(name="deployment-2").bind(),
name="app-2",
route_prefix="/app-2",
)
http_ports_1 = get_http_ports("/app-1")
http_ports_2 = get_http_ports("/app-2")
grpc_ports_1 = get_grpc_ports("/app-1")
grpc_ports_2 = get_grpc_ports("/app-2")
assert set(http_ports_1) == {30000, 30001}
assert set(http_ports_2) == {30002, 30003}
assert set(grpc_ports_1) == {40000, 40001}
assert set(grpc_ports_2) == {40002, 40003}
# make a request to the ingress deployment
for http_port in http_ports_1:
req = httpx.get(f"http://localhost:{http_port}/app-1")
assert req.status_code == 200
assert req.text == "deployment-1"
# make a request to the other ingress deployment
for http_port in http_ports_2:
req = httpx.get(f"http://localhost:{http_port}/app-2")
assert req.status_code == 200
assert req.text == "deployment-2"
def test_app_with_composite_deployments(_skip_if_ff_not_enabled, serve_instance):
"""Test that an app with composite deployments can be deployed. verify
that ports are occupied by all deployments in the app but only the ingress
deployment is used for the target groups"""
expected_http_ports = {30000, 30001}
expected_grpc_ports = {40000, 40001}
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
@serve.deployment(num_replicas=3)
class ChildDeployment:
def __call__(self):
return "child-deployment"
@serve.deployment(num_replicas=2)
class IngressDeployment:
def __init__(self, child_deployment: ChildDeployment):
self.child_deployment = child_deployment
async def __call__(self):
return await self.child_deployment.remote()
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
res = await self.child_deployment.remote()
return serve_pb2.UserDefinedResponse(greeting=res)
serve.run(
IngressDeployment.options(name="ingress-deployment").bind(
ChildDeployment.options(name="child-deployment").bind()
),
name="app-1",
route_prefix="/app-1",
)
# test that the target groups are unique and contain the correct ports for ingress deployment
http_ports = get_http_ports()
grpc_ports = get_grpc_ports()
# difficult to say which ports are used for the target groups
assert set(http_ports) == expected_http_ports
assert set(grpc_ports) == expected_grpc_ports
http_urls = get_application_urls("HTTP", app_name="app-1")
grpc_urls = get_application_urls("gRPC", app_name="app-1", from_proxy_manager=True)
# make a request to the ingress deployment
for http_url in http_urls:
req = httpx.get(http_url)
assert req.status_code == 200
assert req.text == "child-deployment"
# grpc request
for grpc_url in grpc_urls:
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
assert (
stub.Method1(serve_pb2.UserDefinedMessage()).greeting == "child-deployment"
)
channel.close()
def _is_port_in_use(ports):
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
for port in ports:
if sock.connect_ex(("0.0.0.0", port)) == 0:
return True
return False
# assert that child deployment is not occupying ports
assert not _is_port_in_use(
[30002, 30003, 30004]
), "Child deployment is occupying ports"
assert not _is_port_in_use(
[40002, 40003, 40004]
), "Child deployment is occupying ports"
def test_only_running_apps_are_used_for_target_groups(
_skip_if_ff_not_enabled, serve_instance
):
"""Test that only running apps are used for target groups"""
signal_actor = SignalActor.remote()
expected_http_ports = {30000, 30001, 30002, 30003}
expected_grpc_ports = {40000, 40001, 40002, 40003}
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
@serve.deployment(num_replicas=2)
def deployment_1():
return "deployment-1"
@serve.deployment(num_replicas=2)
class Deployment2:
async def __init__(self, signal_actor: SignalActor):
self.signal_actor = signal_actor
await self.signal_actor.wait.remote()
async def __call__(self):
return "deployment-2"
serve.run(
deployment_1.options(name="deployment-1").bind(),
name="app-1",
route_prefix="/app-1",
)
serve._run(
Deployment2.options(name="deployment-2").bind(signal_actor=signal_actor),
name="app-2",
route_prefix="/app-2",
_blocking=False,
)
wait_for_condition(
lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 2, timeout=10
)
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
assert (
serve_details.applications["app-2"].deployments["deployment-2"].status
== DeploymentStatus.UPDATING
)
assert serve_details.applications["app-2"].status == ApplicationStatus.DEPLOYING
assert serve_details.applications["app-1"].status == ApplicationStatus.RUNNING
http_ports = get_http_ports(first_only=False)
grpc_ports = get_grpc_ports(first_only=False)
# In HAProxy mode, we don't return itself or the Serve proxy as a target yet.
# This will change when we support scale to/from zero.
assert set(http_ports) == (
{30000, 30001} if RAY_SERVE_ENABLE_HA_PROXY else {30000, 30001, 8000}
)
assert set(grpc_ports) == (
{40000, 40001} if RAY_SERVE_ENABLE_HA_PROXY else {40000, 40001, 9000}
)
ray.get(signal_actor.send.remote())
def _func():
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
assert serve_details.applications["app-2"].status == ApplicationStatus.RUNNING
return True
wait_for_condition(_func, timeout=10)
http_ports = get_http_ports("/app-1", first_only=False)
grpc_ports = get_grpc_ports("/app-1", first_only=False)
assert set(http_ports) == {30000, 30001}
assert set(grpc_ports) == {40000, 40001}
http_urls = get_application_urls("HTTP", app_name="app-1")
# make requests to the application
for http_url in http_urls:
req = httpx.get(http_url)
assert req.status_code == 200
assert req.text == "deployment-1"
http_ports = get_http_ports("/app-2", first_only=False)
grpc_ports = get_grpc_ports("/app-2", first_only=False)
assert set(http_ports) == {30002, 30003}
assert set(grpc_ports) == {40002, 40003}
http_urls = get_application_urls("HTTP", app_name="app-2")
# make requests to the application
for http_url in http_urls:
req = httpx.get(http_url)
assert req.status_code == 200
assert req.text == "deployment-2"
def test_some_replicas_not_running(_skip_if_ff_not_enabled, serve_instance):
expected_http_ports = {30000, 30001}
expected_grpc_ports = {40000, 40001}
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=list(expected_http_ports) + list(expected_grpc_ports),
timeout=120,
)
signal_actor = Semaphore.remote(2)
@serve.deployment(num_replicas=4)
class Deployment1:
async def __init__(self):
await signal_actor.acquire.remote()
def __call__(self):
return "deployment-1"
serve._run(
Deployment1.options(name="deployment-1").bind(),
name="app-1",
route_prefix="/app-1",
_blocking=False,
)
def _func():
http_ports = get_http_ports("/app-1", first_only=False)
grpc_ports = get_grpc_ports("/app-1", first_only=False)
assert set(http_ports) == expected_http_ports
assert set(grpc_ports) == expected_grpc_ports
return True
wait_for_condition(_func, timeout=10)
# check status of the deployment
serve_details = ServeInstanceDetails(
**ServeSubmissionClient("http://localhost:8265").get_serve_details()
)
assert (
serve_details.applications["app-1"].deployments["deployment-1"].status
== DeploymentStatus.UPDATING
)
assert serve_details.applications["app-1"].status == ApplicationStatus.DEPLOYING
def test_port_recovery_on_controller_restart(_skip_if_ff_not_enabled, serve_instance):
"""Test that ports are recovered on controller restart."""
client = serve_instance
serve.run(Hybrid.options(num_replicas=4).bind(message="Hello world!"))
pre_controller_restart_http_ports = get_http_ports()
pre_controller_restart_grpc_ports = get_grpc_ports()
ray.kill(client._controller, no_restart=False)
def validate_port_recovery():
post_controller_restart_http_ports = get_http_ports()
post_controller_restart_grpc_ports = get_grpc_ports()
assert set(post_controller_restart_http_ports) == set(
pre_controller_restart_http_ports
)
assert set(post_controller_restart_grpc_ports) == set(
pre_controller_restart_grpc_ports
)
return True
wait_for_condition(validate_port_recovery)
class TestDirectIngressBackpressure:
def _do_http_request(self, url: str) -> bool:
r = httpx.get(url, timeout=10)
if r.status_code == 200:
return True
elif r.status_code == 503:
return False
else:
raise RuntimeError(f"Unexpected status code: {r.status_code}")
def _do_grpc_request(self, url: str) -> bool:
channel = grpc.insecure_channel(url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
try:
stub.Method1(serve_pb2.UserDefinedMessage(), timeout=20)
return True
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.RESOURCE_EXHAUSTED:
return False
raise RuntimeError(f"Unexpected status code: {e.code()}")
finally:
channel.close()
def test_max_ongoing_requests(self, _skip_if_ff_not_enabled, serve_instance):
wait_signal = SignalActor.remote()
serve.run(
Hybrid.options(max_ongoing_requests=5).bind(
message="done waiting!", wait_signal=wait_signal
)
)
http_url = get_application_url("HTTP")
grpc_url = get_application_url("gRPC", from_proxy_manager=True)
for _do_request in [self._do_grpc_request, self._do_http_request]:
url = grpc_url if _do_request == self._do_grpc_request else http_url
num_requests = 5
with ThreadPoolExecutor(num_requests + 5) as tpe:
# Submit `max_ongoing_requests` blocking requests.
futures = [tpe.submit(_do_request, url) for _ in range(num_requests)]
wait_for_condition(
lambda: ray.get(wait_signal.cur_num_waiters.remote())
== num_requests
)
assert all(not f.done() for f in futures)
# Send another request beyond `max_ongoing_requests`
queued_requests = [
tpe.submit(_do_request, url) for _ in range(num_requests + 5)
]
wait_for_condition(
lambda: ray.get(wait_signal.cur_num_waiters.remote())
== num_requests
)
assert all(not f.done() for f in queued_requests)
# Unblock the requests, check they finish successfully.
ray.get(wait_signal.send.remote())
assert all(f.result() is True for f in futures)
assert all(f.result() is True for f in queued_requests)
# Now a new request showld succeed.
assert _do_request(url) is True
ray.get(wait_signal.send.remote(clear=True))
def test_backpressure_queued_requests(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test that the backpressure logic works"""
signal = SignalActor.remote()
serve.run(
Hybrid.options(max_ongoing_requests=1).bind(
message="done waiting!", wait_signal=signal
)
)
http_url = get_application_url("HTTP")
grpc_url = get_application_url("gRPC", from_proxy_manager=True)
for _do_request in [self._do_grpc_request, self._do_http_request]:
url = grpc_url if _do_request == self._do_grpc_request else http_url
num_requests = 1000
with ThreadPoolExecutor(num_requests) as tpe:
futures = [tpe.submit(_do_request, url) for _ in range(1)]
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 1
)
futures.extend(
[tpe.submit(_do_request, url) for _ in range(num_requests - 1)]
)
ray.get(signal.send.remote())
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 0
)
assert sum(f.result() is True for f in futures) == num_requests
ray.get(signal.send.remote(clear=True))
def test_drop_after_max_queued_requests(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test that the backpressure logic works"""
signal = SignalActor.remote()
serve.run(
Hybrid.options(max_ongoing_requests=10, max_queued_requests=10).bind(
message="done waiting!", wait_signal=signal
)
)
http_url = get_application_url("HTTP")
grpc_url = get_application_url("gRPC", from_proxy_manager=True)
for _do_request in [self._do_grpc_request, self._do_http_request]:
url = grpc_url if _do_request == self._do_grpc_request else http_url
num_requests = 1000
with ThreadPoolExecutor(num_requests) as tpe:
futures = [tpe.submit(_do_request, url) for _ in range(10)]
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 10
)
futures.extend(
[tpe.submit(_do_request, url) for _ in range(num_requests - 10)]
)
def _func():
count = sum(
f.done() and f.result(timeout=0) is False for f in futures
)
assert count == num_requests - 20
return True
wait_for_condition(_func, timeout=10)
signal.send.remote()
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 0
)
# assert 2 requests succeeded
assert sum(f.result() is True for f in futures) == 20
signal.send.remote(clear=True)
def test_mixed_http_grpc_backpressure(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test backpressure with simultaneous HTTP and gRPC requests"""
signal = SignalActor.remote()
serve.run(
Hybrid.options(max_ongoing_requests=5, max_queued_requests=5).bind(
message="done waiting!", wait_signal=signal
)
)
http_url = get_application_url("HTTP")
grpc_url = get_application_url("gRPC", from_proxy_manager=True)
num_requests = 500
with ThreadPoolExecutor(num_requests) as tpe:
# Submit mixed HTTP and gRPC requests
http_futures = []
grpc_futures = []
http_futures.extend(
[tpe.submit(self._do_http_request, http_url) for _ in range(5)]
)
grpc_futures.extend(
[tpe.submit(self._do_grpc_request, grpc_url) for _ in range(5)]
)
# Wait for ongoing requests to block (should be 10 total across both protocols)
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 5)
http_futures.extend(
[
tpe.submit(self._do_http_request, http_url)
for _ in range((num_requests // 2) - 5)
]
)
grpc_futures.extend(
[
tpe.submit(self._do_grpc_request, grpc_url)
for _ in range((num_requests // 2) - 5)
]
)
def _func():
# Only check results for futures that are actually done
http_rejected = sum(
f.done() and f.result(timeout=0) is False for f in http_futures
)
grpc_rejected = sum(
f.done() and f.result(timeout=0) is False for f in grpc_futures
)
total_rejected = http_rejected + grpc_rejected
# Should have 10 ongoing + 10 queued = 20 allowed, so 10 rejected
assert total_rejected == num_requests - 10
return True
wait_for_condition(_func, timeout=20)
# Unblock and verify
ray.get(signal.send.remote())
http_successful = sum(1 for f in http_futures if f.result() is True)
grpc_successful = sum(1 for f in grpc_futures if f.result() is True)
total_successful = http_successful + grpc_successful
# Should have exactly 20 successful (10 ongoing + 10 queued)
assert total_successful == 10
def test_health_check_during_backpressure(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test that health checks work correctly during backpressure"""
signal = SignalActor.remote()
fail_hc_signal = SignalActor.remote()
serve.run(
Hybrid.options(
max_ongoing_requests=1, max_queued_requests=2, health_check_period_s=0.5
).bind(
message="done waiting!",
wait_signal=signal,
fail_hc_signal=fail_hc_signal,
)
)
# this is specifically checking the health check on the replica
http_url = get_application_url("HTTP", from_proxy_manager=True)
num_requests = 100
with ThreadPoolExecutor(num_requests) as tpe:
# Submit requests to create backpressure
futures = [tpe.submit(self._do_http_request, http_url) for _ in range(1)]
# Wait for backpressure
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
futures.extend(
[
tpe.submit(self._do_http_request, http_url)
for _ in range(num_requests - 1)
]
)
# Health check should still pass during backpressure
hc_response = httpx.get(f"{http_url}/-/healthz")
assert hc_response.status_code == 200
assert hc_response.text == HEALTHY_MESSAGE
# Fail health check
ray.get(fail_hc_signal.send.remote())
# Health check should fail even during backpressure
def _check_unhealthy():
hc_response = httpx.get(f"{http_url}/-/healthz")
assert hc_response.status_code == 503
assert hc_response.text == "UNHEALTHY"
return True
wait_for_condition(_check_unhealthy, timeout=2)
# Restore health check
ray.get(fail_hc_signal.send.remote(clear=True))
# Unblock requests
ray.get(signal.send.remote())
# Verify some requests succeeded
successful = sum(1 for f in futures if f.result() is True)
assert successful == 3
# check remaining requests are rejected
rejected = sum(1 for f in futures if f.done() and f.result() is False)
assert rejected == num_requests - 3
def test_multiple_deployment_backpressure_isolation(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test that backpressure is isolated between different deployments"""
signal1 = SignalActor.remote()
signal2 = SignalActor.remote()
@serve.deployment(name="deployment-1")
class Deployment1:
def __init__(self, signal):
self.signal = signal
async def __call__(self, request):
await self.signal.wait.remote()
return "deployment-1"
async def Method1(self, request):
await self.signal.wait.remote()
return serve_pb2.UserDefinedResponse(greeting="deployment-1")
@serve.deployment(name="deployment-2")
class Deployment2:
def __init__(self, signal):
self.signal = signal
async def __call__(self, request):
await self.signal.wait.remote()
return "deployment-2"
async def Method1(self, request):
await self.signal.wait.remote()
return serve_pb2.UserDefinedResponse(greeting="deployment-2")
# Deploy with different backpressure settings
serve.run(
Deployment1.options(max_ongoing_requests=1, max_queued_requests=1).bind(
signal1
),
name="app-1",
route_prefix="/app-1",
)
serve.run(
Deployment2.options(max_ongoing_requests=5, max_queued_requests=5).bind(
signal2
),
name="app-2",
route_prefix="/app-2",
)
http_url_1 = get_application_url("HTTP", app_name="app-1")
http_url_2 = get_application_url("HTTP", app_name="app-2")
grpc_url_1 = get_application_url(
"gRPC", app_name="app-1", from_proxy_manager=True
)
grpc_url_2 = get_application_url(
"gRPC", app_name="app-2", from_proxy_manager=True
)
for do_request in [self._do_http_request, self._do_grpc_request]:
url1 = http_url_1 if do_request == self._do_http_request else grpc_url_1
url2 = http_url_2 if do_request == self._do_http_request else grpc_url_2
num_requests = 20
with ThreadPoolExecutor(num_requests) as tpe:
# Saturate deployment-1 (should cause backpressure)
futures_1 = [tpe.submit(do_request, url1) for _ in range(1)]
# Wait for both to have ongoing requests
wait_for_condition(
lambda: ray.get(signal1.cur_num_waiters.remote()) == 1
)
futures_1.extend([tpe.submit(do_request, url1) for _ in range(9)])
# Submit to deployment-2 (should not be affected by deployment-1's backpressure)
futures_2 = [tpe.submit(do_request, url2) for _ in range(5)]
wait_for_condition(
lambda: ray.get(signal2.cur_num_waiters.remote()) == 5
)
futures_2.extend([tpe.submit(do_request, url2) for _ in range(5)])
def _func():
# deployment-1 should have rejected requests
rejected_1 = sum(
1
for f in futures_1
if f.done() and f.result(timeout=0) is False
)
assert rejected_1 == 8 # Most should be rejected (10 - 2 allowed)
# deployment-2 should not have rejected requests yet (higher limits)
rejected_2 = sum(
1
for f in futures_2
if f.done() and f.result(timeout=0) is False
)
assert rejected_2 == 0 # None should be rejected yet
return True
wait_for_condition(_func, timeout=20)
# Unblock both
ray.get(signal1.send.remote())
ray.get(signal2.send.remote())
# Verify deployment-2 succeeded more than deployment-1
successful_1 = sum(1 for f in futures_1 if f.result() is True)
successful_2 = sum(1 for f in futures_2 if f.result() is True)
assert successful_1 == 2 # At most 2 for deployment-1
assert successful_2 == 10
ray.get(signal1.send.remote(clear=True))
ray.get(signal2.send.remote(clear=True))
def test_backpressure_with_composite_deployments(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test backpressure with composite deployments"""
signal = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=2)
class ChildDeployment:
def __init__(self, signal):
self.signal = signal
async def __call__(self):
await self.signal.wait.remote()
return "child-deployment"
@serve.deployment(max_ongoing_requests=1000)
class CompositeDeployment:
def __init__(self, child_deployment: ChildDeployment):
self.child_deployment = child_deployment
async def __call__(self):
await self.child_deployment.remote()
return "composite-deployment"
async def Method1(self, request):
await self.child_deployment.remote()
return serve_pb2.UserDefinedResponse(greeting="composite-deployment")
serve.run(
CompositeDeployment.options(name="composite-deployment").bind(
ChildDeployment.options(name="child-deployment").bind(signal)
),
name="composite-app",
route_prefix="/composite-app",
)
http_url = get_application_url("HTTP", app_name="composite-app")
grpc_url = get_application_url(
"gRPC", app_name="composite-app", from_proxy_manager=True
)
num_requests = 10
for do_request in [self._do_http_request, self._do_grpc_request]:
url = http_url if do_request == self._do_http_request else grpc_url
with ThreadPoolExecutor(num_requests) as tpe:
futures = []
# there is a race condition in the router where if multiple requests
# are submitted at the same time, then we could reject more requests
# than strictly necessary. Hence we submit 1 request first and then
# submit the rest of the requests.
futures.append(tpe.submit(do_request, url))
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 1
)
futures.append(tpe.submit(do_request, url))
done, _ = wait(futures, timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
futures.append(tpe.submit(do_request, url))
done, _ = wait(futures, timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
for _ in range(num_requests - 3):
rejected_fut = tpe.submit(do_request, url)
assert rejected_fut.result() is False
ray.get(signal.send.remote())
for future in futures:
assert future.result() is True
ray.get(signal.send.remote(clear=True))
def test_client_disconnect_during_request(
self, _skip_if_ff_not_enabled, serve_instance
):
signal = SignalActor.remote()
collector = Collector.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=10)
class A:
async def __call__(self):
await signal.wait.remote()
await collector.add.remote(
ray.serve.context._get_serve_request_context().request_id
)
serve.run(A.options(name="A").bind(), name="app-1", route_prefix="/app-1")
http_url = get_application_url("HTTP", app_name="app-1")
num_requests = 100
with ThreadPoolExecutor(num_requests) as tpe:
futures = [tpe.submit(httpx.get, http_url, timeout=0.5) for _ in range(1)]
wait_for_condition(lambda: ray.get(signal.cur_num_waiters.remote()) == 1)
futures.extend(
[
tpe.submit(httpx.get, http_url, timeout=0.5)
for _ in range(num_requests - 1)
]
)
# wait for all futures to fail with a timeout
def _func():
for future in futures:
assert future.done()
try:
future.result()
except Exception as e:
assert isinstance(e, httpx.ReadTimeout)
return True
wait_for_condition(_func, timeout=10)
ray.get(signal.send.remote())
# check that the collector has the correct request ids
assert len(ray.get(collector.get.remote())) == 0
def test_graceful_shutdown_wait_loop(self, _skip_if_ff_not_enabled, serve_instance):
"""Test that the graceful shutdown wait loop works"""
signal = SignalActor.remote()
@serve.deployment(
graceful_shutdown_timeout_s=20,
graceful_shutdown_wait_loop_s=0.01,
max_ongoing_requests=10,
max_queued_requests=10,
)
class A:
async def __call__(self):
await signal.wait.remote()
return "ok"
serve.run(A.options(name="A").bind(), name="app-1", route_prefix="/app-1")
http_url = get_application_url("HTTP", app_name="app-1")
num_requests = 20
with ThreadPoolExecutor(num_requests) as tpe:
futures = [tpe.submit(httpx.get, http_url, timeout=10) for _ in range(10)]
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 10, timeout=10
)
# Submit the remaining requests
futures = [tpe.submit(httpx.get, http_url, timeout=10) for _ in range(10)]
serve.delete("app-1", _blocking=False)
# send the signal to unblock all requests
ray.get(signal.send.remote())
# wait for all requests to finish
for future in futures:
assert future.result().status_code == 200
def test_requests_are_not_running_serially(
self, _skip_if_ff_not_enabled, serve_instance
):
"""Test that requests are processed concurrently, not serially"""
@serve.deployment(
max_ongoing_requests=20,
)
class A:
async def __call__(self):
await asyncio.sleep(1)
return "ok"
serve.run(A.options(name="A").bind(), name="app-1", route_prefix="/app-1")
http_url = get_application_url("HTTP", app_name="app-1")
num_requests = 20
with ThreadPoolExecutor(num_requests) as tpe:
futures = [
tpe.submit(httpx.get, http_url, timeout=None)
for _ in range(num_requests)
]
def _func():
for future in futures:
assert future.result().status_code == 200
return True
wait_for_condition(_func, timeout=5)
class TestDirectIngressAutoscaling:
@pytest.mark.parametrize("min_replicas", [1, 2])
def test_autoscaling_scale_up_down_basic(
self, _skip_if_ff_not_enabled, serve_instance, min_replicas
):
"""Send 100 requests and check that we autoscale up, and then back down."""
signal = SignalActor.remote()
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": min_replicas,
"max_replicas": 3,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
"target_num_ongoing_requests": 100,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
async def __call__(self, request: Request):
await signal.wait.remote()
return "ok"
serve.run(A.options(name="A").bind(), name="app-1", route_prefix="/app-1")
wait_for_condition(
check_deployment_status,
name="A",
expected_status=DeploymentStatus.HEALTHY,
app_name="app-1",
)
http_url = get_application_url("HTTP", app_name="app-1")
# Send 100 concurrent HTTP requests
with ThreadPoolExecutor() as executor:
futures = [
executor.submit(httpx.get, http_url, timeout=None) for _ in range(100)
]
# scale up one more replica from min_replicas
wait_for_condition(
check_num_replicas_gte,
name="A",
target=min_replicas + 1,
app_name="app-1",
)
signal.send.remote()
# verify that all requests completed successfully
for future in futures:
assert future.result().status_code == 200
# As the queue is drained, we should scale back down.
wait_for_condition(
check_num_replicas_lte, name="A", target=min_replicas, app_name="app-1"
)
def test_autoscaling_scale_from_and_to_zero(
self, _skip_if_ff_not_enabled, _skip_if_haproxy_enabled, serve_instance
):
signal = SignalActor.remote()
@serve.deployment(
autoscaling_config={
"metrics_interval_s": 0.1,
"min_replicas": 0,
"max_replicas": 3,
"look_back_period_s": 0.2,
"downscale_delay_s": 0.5,
"upscale_delay_s": 0,
"target_num_ongoing_requests": 100,
},
# We will send over a lot of queries. This will make sure replicas are
# killed quickly during cleanup.
graceful_shutdown_timeout_s=1,
max_ongoing_requests=1000,
)
class A:
async def __call__(self, request: Request):
await signal.wait.remote()
return "ok"
serve.run(A.options(name="A").bind(), name="app-1", route_prefix="/app-1")
wait_for_condition(
check_deployment_status,
name="A",
expected_status=DeploymentStatus.HEALTHY,
app_name="app-1",
)
http_url = get_application_url("HTTP", app_name="app-1")
# Send 100 concurrent HTTP requests
with ThreadPoolExecutor() as executor:
futures = [
executor.submit(httpx.get, http_url, timeout=None) for _ in range(50)
]
# scale up one more replica from min_replicas
wait_for_condition(
check_num_replicas_gte, name="A", target=1, app_name="app-1"
)
# now that replicas are running, check that http ports are occupied
def _func():
_ = get_application_url("HTTP", app_name="app-1")
_ = get_application_url("gRPC", app_name="app-1")
return True
wait_for_condition(_func, timeout=10)
signal.send.remote()
# verify that all requests completed successfully
for future in futures:
assert future.result().status_code == 200
# As the queue is drained, we should scale back down.
wait_for_condition(check_num_replicas_lte, name="A", target=0, app_name="app-1")
# check that http ports are released
http_ports = get_http_ports(route_prefix="/app-1")
assert len(http_ports) == 1
assert http_ports[0] == 8000 # proxy port
# check that grpc ports are released
grpc_ports = get_grpc_ports(route_prefix="/app-1")
assert len(grpc_ports) == 1
assert grpc_ports[0] == 9000 # proxy port
def test_disconnect(_skip_if_ff_not_enabled, serve_instance):
"""Test gRPC client disconnect/cancellation behavior."""
running_signal = SignalActor.remote()
cancelled_signal = SignalActor.remote()
@serve.deployment(name="disconnect-deployment")
class DisconnectTest:
async def wait_for_signal(self):
async with send_signal_on_cancellation(cancelled_signal):
await running_signal.wait.remote()
async def __call__(self, request: Request):
await self.wait_for_signal()
return "completed"
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
await self.wait_for_signal()
return serve_pb2.UserDefinedResponse(greeting="completed")
serve.run(DisconnectTest.bind())
http_url = get_application_url("HTTP")
grpc_url = get_application_url("gRPC", from_proxy_manager=True)
# Test gRPC cancellation
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
# Send request and wait for it to start executing
request = serve_pb2.UserDefinedMessage()
future = stub.Method1.future(request=request)
# Wait for the request to start processing
wait_for_condition(
lambda: ray.get(running_signal.cur_num_waiters.remote()) == 1, timeout=10
)
# Cancel the request
future.cancel()
# Verify that cancellation was detected by the deployment
ray.get(cancelled_signal.wait.remote(), timeout=5)
# Verify the future was cancelled
with pytest.raises(grpc.FutureCancelledError):
future.result()
channel.close()
# Clean up signals
ray.get(running_signal.send.remote(clear=True))
ray.get(cancelled_signal.send.remote(clear=True))
# Test HTTP cancellation
http_url = get_application_url("HTTP")
try:
httpx.get(http_url, timeout=1)
except httpx.TimeoutException:
pass
else:
raise RuntimeError("Request should have been cancelled")
wait_for_condition(
lambda: ray.get(running_signal.cur_num_waiters.remote()) == 1, timeout=10
)
try:
ray.get(cancelled_signal.wait.remote(), timeout=5)
except ray.exceptions.GetTimeoutError:
assert False, "Cancelled signal should have been sent"
ray.get(running_signal.send.remote(clear=True))
ray.get(cancelled_signal.send.remote(clear=True))
def _get_replica_actor_handle(deployment_name: str, app_name: str) -> ActorHandle:
"""Return the actor handle for the (single) replica of a deployment."""
for actor in ray.util.list_named_actors(all_namespaces=True):
if actor["namespace"] != SERVE_NAMESPACE:
continue
if f"{app_name}#{deployment_name}#" in actor["name"]:
return ray.get_actor(actor["name"], namespace=SERVE_NAMESPACE)
raise AssertionError(
f"No replica actor found for deployment '{deployment_name}' in app "
f"'{app_name}'."
)
def test_tasks_cancelled_on_timeout(_skip_if_ff_not_enabled, serve_instance):
"""Test that the async tasks are cancelled and cleaned up on timeout.
Beyond the 408 status code, this asserts that the per-request asyncio tasks
created by ``_direct_ingress_asgi`` (the request task and the disconnect-watcher
receive task) are actually cancelled and removed from the replica's event loop
after the timeout, rather than leaking (e.g. a receive task left parked on the
async queue).
"""
name = "tasks-cancelled-on-timeout-deployment"
@serve.deployment(name=name)
class TasksCancelledOnTimeoutTest:
async def __call__(self):
await asyncio.sleep(10)
return "ok"
serve.run(TasksCancelledOnTimeoutTest.bind(), name=name)
http_url = get_application_url("HTTP", app_name=name)
headers = {SERVE_HTTP_REQUEST_TIMEOUT_S_HEADER: "1"}
replica = _get_replica_actor_handle(name, name)
def inflight_counts():
return ray.get(
replica._get_inflight_direct_ingress_task_counts_for_testing.remote()
)
# No direct-ingress request/receive tasks before we send anything.
assert inflight_counts() == {"request_tasks": 0, "receive_tasks": 0}
with ThreadPoolExecutor() as executor:
future = executor.submit(httpx.get, http_url, headers=headers)
# While the (slow) request is in flight, both the request task and the
# disconnect-watcher receive task should be present on the event loop.
wait_for_condition(
lambda: inflight_counts() == {"request_tasks": 1, "receive_tasks": 1},
timeout=10,
)
response = future.result()
assert response.status_code == 408
# After the timeout, both tasks must be cancelled and removed from the loop
# (not left pending / parked on the async queue).
wait_for_condition(
lambda: inflight_counts() == {"request_tasks": 0, "receive_tasks": 0},
timeout=10,
)
def test_context_propagation(_skip_if_ff_not_enabled, serve_instance):
"""Test that the context is propagated to the deployment"""
@serve.deployment(name="context-propagation-deployment")
class ContextPropagationTest:
async def __call__(self):
return ray.serve.context._get_serve_request_context().app_name
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
return serve_pb2.UserDefinedResponse(
greeting=ray.serve.context._get_serve_request_context().app_name
)
serve.run(
ContextPropagationTest.bind(),
name="context-propagation-deployment",
route_prefix="/context-propagation-deployment",
)
http_url = get_application_url("HTTP", app_name="context-propagation-deployment")
response = httpx.get(http_url)
assert response.status_code == 200
assert response.text == "context-propagation-deployment"
grpc_url = get_application_url(
"gRPC", app_name="context-propagation-deployment", from_proxy_manager=True
)
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage()
future = stub.Method1.future(request=request)
assert future.result().greeting == "context-propagation-deployment"
def test_context_propagation_with_child(_skip_if_ff_not_enabled, serve_instance):
"""Test that the context is propagated to the deployment"""
@serve.deployment(name="child-deployment")
class ChildDeployment:
async def __call__(self):
return ray.serve.context._get_serve_request_context().app_name
@serve.deployment(name="context-propagation-deployment")
class ContextPropagationTest:
def __init__(self, child_deployment: ChildDeployment):
self.child_deployment = child_deployment
async def __call__(self):
return await self.child_deployment.remote()
async def Method1(
self, request: serve_pb2.UserDefinedMessage
) -> serve_pb2.UserDefinedResponse:
return serve_pb2.UserDefinedResponse(
greeting=await self.child_deployment.remote()
)
serve.run(
ContextPropagationTest.bind(ChildDeployment.bind()),
name="context-propagation-deployment",
route_prefix="/context-propagation-deployment",
)
http_url = get_application_url("HTTP", app_name="context-propagation-deployment")
response = httpx.get(http_url)
assert response.status_code == 200
assert response.text == "context-propagation-deployment"
grpc_url = get_application_url(
"gRPC", app_name="context-propagation-deployment", from_proxy_manager=True
)
channel = grpc.insecure_channel(grpc_url)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage()
future = stub.Method1.future(request=request)
assert future.result().greeting == "context-propagation-deployment"
def test_shutdown_replica_only_after_draining_requests(
_skip_if_ff_not_enabled, serve_instance
):
"""Test that the replica is shutdown correctly when the deployment is shutdown."""
signal = SignalActor.remote()
# In direct ingress mode, graceful_shutdown_timeout_s is automatically bumped to
# max(graceful_shutdown_timeout_s, RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S)
# to give external load balancers time to deregister the replica.
@serve.deployment(name="replica-shutdown-deployment", graceful_shutdown_timeout_s=5)
class ReplicaShutdownTest:
async def __call__(self):
await signal.wait.remote()
return "ok"
serve.run(ReplicaShutdownTest.bind(), name="replica-shutdown-deployment")
http_url = get_application_url("HTTP", app_name="replica-shutdown-deployment")
with ThreadPoolExecutor() as executor:
futures = [executor.submit(httpx.get, http_url, timeout=10) for _ in range(4)]
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 4, timeout=10
)
serve.delete("replica-shutdown-deployment", _blocking=False)
# Wait less than graceful_shutdown_timeout_s to ensure requests can complete
time.sleep(0.5)
ray.get(signal.send.remote(clear=True))
for future in futures:
assert future.result().status_code == 200
wait_for_condition(
lambda: "replica-shutdown-deployment" not in serve.status().applications,
timeout=10,
)
def test_http_routes_endpoint(_skip_if_ff_not_enabled, serve_instance):
"""Test that the routes endpoint returns pair of routes_prefix and
app_name of which the replica is serving for.
"""
@serve.deployment
class D1:
def __call__(self, *args):
return "D1"
@serve.deployment
class D2:
def __call__(self, *args):
return "D2"
serve.run(D1.bind(), name="app1", route_prefix="/D1")
serve.run(D2.bind(), name="app2", route_prefix="/hello/world")
# Test routes endpoint on the replica running for app1 directly
url = get_application_url(
app_name="app1",
exclude_route_prefix=True,
from_proxy_manager=True,
)
routes = httpx.get(f"{url}/-/routes").json()
assert routes == {"/D1": "app1"}, routes
# Test routes endpoint on the replica running for app2 directly
url = get_application_url(
app_name="app2",
exclude_route_prefix=True,
from_proxy_manager=True,
)
routes = httpx.get(f"{url}/-/routes").json()
assert routes == {"/hello/world": "app2"}, routes
# Test routes endpoint on the proxy
url = "http://localhost:8000/-/routes"
routes = httpx.get(url).json()
assert routes == {"/D1": "app1", "/hello/world": "app2"}, routes
# TODO: haproxy doesn't support gRPC ListApplications yet so skipping this test
def test_grpc_list_applications_endpoint(
_skip_if_ff_not_enabled, _skip_if_haproxy_enabled, serve_instance
):
"""Each replica's gRPC `ListApplications` method should only report the
single application that replica is serving.
"""
@serve.deployment
class D1:
def __call__(self, *args):
return "D1"
@serve.deployment
class D2:
def __call__(self, *args):
return "D2"
serve.run(D1.bind(), name="app1", route_prefix="/D1")
serve.run(D2.bind(), name="app2", route_prefix="/hello/world")
# Test gRPC `ListApplications` on the replica running for app1 directly
url = get_application_url("gRPC", app_name="app1")
channel = grpc.insecure_channel(url)
try:
ping_grpc_list_applications(channel, ["app1"])
finally:
channel.close()
# Test gRPC `ListApplications` on the replica running for app2 directly
url = get_application_url("gRPC", app_name="app2")
channel = grpc.insecure_channel(url)
try:
ping_grpc_list_applications(channel, ["app2"])
finally:
channel.close()
# Test gRPC `ListApplications` on the proxy
channel = grpc.insecure_channel("localhost:9000")
try:
ping_grpc_list_applications(channel, ["app1", "app2"])
finally:
channel.close()
# Copied from test_controller.py
def test_redeploy_start_time(_skip_if_ff_not_enabled, serve_instance):
"""Check that redeploying a deployment doesn't reset its start time."""
controller = _get_global_client()._controller
@serve.deployment
def test(_):
return "1"
serve.run(test.bind())
deployment_route = DeploymentRoute.FromString(
ray.get(controller.get_deployment_info.remote("test", SERVE_DEFAULT_APP_NAME))
)
deployment_info_1 = DeploymentInfo.from_proto(deployment_route.deployment_info)
start_time_ms_1 = deployment_info_1.start_time_ms
time.sleep(0.1)
@serve.deployment
def test(_):
return "2"
serve.run(test.bind())
deployment_route = DeploymentRoute.FromString(
ray.get(controller.get_deployment_info.remote("test", SERVE_DEFAULT_APP_NAME))
)
deployment_info_2 = DeploymentInfo.from_proto(deployment_route.deployment_info)
start_time_ms_2 = deployment_info_2.start_time_ms
assert start_time_ms_1 == start_time_ms_2
# Copied from test_controller.py
def test_deploy_app_custom_exception(_skip_if_ff_not_enabled, serve_instance):
"""Check that controller doesn't deserialize an exception from deploy_app."""
controller = _get_global_client()._controller
config = {
"applications": [
{
"name": "broken_app",
"route_prefix": "/broken",
"import_path": "ray.serve.tests.test_config_files.broken_app:app",
}
]
}
ray.get(
controller.apply_config.remote(config=ServeDeploySchema.model_validate(config))
)
def check_custom_exception() -> bool:
status = serve.status().applications["broken_app"]
assert status.status == ApplicationStatus.DEPLOY_FAILED
assert "custom exception info" in status.message
return True
wait_for_condition(check_custom_exception, timeout=10)
# Copied from test_controller.py
@pytest.mark.parametrize(
"policy_name", [None, DEFAULT_AUTOSCALING_POLICY_NAME, default_autoscaling_policy]
)
def test_get_serve_instance_details_json_serializable(
_skip_if_ff_not_enabled, serve_instance, policy_name
):
"""Test the result from get_serve_instance_details is json serializable."""
controller = _get_global_client()._controller
autoscaling_config = {
"min_replicas": 1,
"max_replicas": 10,
"_policy": {"name": policy_name},
}
if policy_name is None:
autoscaling_config.pop("_policy")
# Pin graceful_shutdown_timeout_s above any direct-ingress shutdown floor
# (MIN_DRAINING_PERIOD_S + buffer) so the asserted value is deterministic
# regardless of the MIN_DRAINING setting in this lane.
@serve.deployment(
autoscaling_config=autoscaling_config, graceful_shutdown_timeout_s=60
)
def autoscaling_app():
return "1"
serve.run(autoscaling_app.bind())
details = ray.get(controller.get_serve_instance_details.remote())
details_json = json.dumps(details)
controller_details = ray.get(controller.get_actor_details.remote())
node_id = controller_details.node_id
node_ip = controller_details.node_ip
node_instance_id = controller_details.node_instance_id
proxy_details = ray.get(controller.get_proxy_details.remote(node_id=node_id))
deployment_timestamp = ray.get(
controller.get_deployment_timestamps.remote(app_name="default")
)
deployment_details = ray.get(
controller.get_deployment_details.remote("default", "autoscaling_app")
)
replica = deployment_details.replicas[0]
expected_json = json.dumps(
{
"controller_info": {
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": controller_details.actor_id,
"actor_name": controller_details.actor_name,
"worker_id": controller_details.worker_id,
"log_file_path": controller_details.log_file_path,
},
"proxy_location": "HeadOnly",
"http_options": {"host": "0.0.0.0"},
"grpc_options": {
"port": 9000,
"grpc_servicer_functions": TEST_GRPC_SERVICER_FUNCTIONS,
},
"proxies": {
node_id: {
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": proxy_details.actor_id,
"actor_name": proxy_details.actor_name,
"worker_id": proxy_details.worker_id,
"log_file_path": proxy_details.log_file_path,
"status": proxy_details.status,
}
},
"applications": {
"default": {
"name": "default",
"route_prefix": "/",
"docs_path": None,
"status": "RUNNING",
"message": "",
"last_deployed_time_s": deployment_timestamp,
"deployed_app_config": None,
"source": "imperative",
"deployments": {
"autoscaling_app": {
"name": "autoscaling_app",
"status": "HEALTHY",
"status_trigger": "CONFIG_UPDATE_COMPLETED",
"message": "",
"deployment_config": {
"name": "autoscaling_app",
"max_ongoing_requests": 5,
"max_queued_requests": -1,
"user_config": None,
"autoscaling_config": {
"min_replicas": 1,
"initial_replicas": None,
"max_replicas": 10,
"target_ongoing_requests": 2.0,
"metrics_interval_s": 10.0,
"look_back_period_s": 30.0,
"smoothing_factor": 1.0,
"upscale_smoothing_factor": None,
"downscale_smoothing_factor": None,
"upscaling_factor": None,
"downscaling_factor": None,
"downscale_delay_s": 600.0,
"downscale_to_zero_delay_s": None,
"upscale_delay_s": 30.0,
"aggregation_function": "mean",
"policy": {
"policy_function": "ray.serve.autoscaling_policy:default_autoscaling_policy",
"policy_kwargs": {},
},
},
"graceful_shutdown_wait_loop_s": 2.0,
# Set to 60 above (max(60, floor) == 60).
"graceful_shutdown_timeout_s": 60.0,
"health_check_period_s": 10.0,
"health_check_timeout_s": 30.0,
"ray_actor_options": {
"num_cpus": 1.0,
},
"request_router_config": {
"request_router_class": "ray.serve._private.request_router:PowerOfTwoChoicesRequestRouter",
"request_router_kwargs": {},
"request_routing_stats_period_s": 10.0,
"request_routing_stats_timeout_s": 30.0,
"initial_backoff_s": 0.025,
"backoff_multiplier": 2.0,
"max_backoff_s": 0.5,
},
"rolling_update_percentage": 0.2,
},
"target_num_replicas": 1,
"required_resources": {"CPU": 1},
"replicas": [
{
"node_id": node_id,
"node_ip": node_ip,
"node_instance_id": node_instance_id,
"actor_id": replica.actor_id,
"actor_name": replica.actor_name,
"worker_id": replica.worker_id,
"log_file_path": replica.log_file_path,
"replica_id": replica.replica_id,
"state": "RUNNING",
"pid": replica.pid,
"start_time_s": replica.start_time_s,
}
],
"recent_dead_replicas": [],
}
},
"external_scaler_enabled": False,
"deployment_topology": {
"app_name": "default",
"nodes": {
"autoscaling_app": {
"name": "autoscaling_app",
"app_name": "default",
"outbound_deployments": [],
"is_ingress": True,
},
},
"ingress_deployment": "autoscaling_app",
},
}
},
"target_capacity": None,
"target_groups": [
{
"targets": [
{
"ip": node_ip,
"port": 8000 if RAY_SERVE_ENABLE_HA_PROXY else 30000,
"instance_id": node_instance_id,
"name": proxy_details.actor_name
if RAY_SERVE_ENABLE_HA_PROXY
else replica.actor_name,
},
],
"route_prefix": "/",
"protocol": "HTTP",
"app_name": "" if RAY_SERVE_ENABLE_HA_PROXY else "default",
"ingress_request_router_targets": [],
"ingress_deployment_name": ""
if RAY_SERVE_ENABLE_HA_PROXY
else "autoscaling_app",
},
{
"targets": [
{
"ip": node_ip,
"port": 9000 if RAY_SERVE_ENABLE_HA_PROXY else 40000,
"instance_id": node_instance_id,
"name": proxy_details.actor_name
if RAY_SERVE_ENABLE_HA_PROXY
else replica.actor_name,
},
],
"route_prefix": "/",
"protocol": "gRPC",
"app_name": "" if RAY_SERVE_ENABLE_HA_PROXY else "default",
"ingress_request_router_targets": [],
"ingress_deployment_name": ""
if RAY_SERVE_ENABLE_HA_PROXY
else "autoscaling_app",
},
],
}
)
# Health metrics contain timestamps that change between calls, so verify
# the keys match what get_health_metrics returns rather than exact values.
details_dict = json.loads(details_json)
actual_health_metrics = details_dict.pop("controller_health_metrics")
expected_dict = json.loads(expected_json)
assert details_dict == expected_dict
controller_health_metrics = ray.get(controller.get_health_metrics.remote())
assert set(actual_health_metrics.keys()) == set(controller_health_metrics.keys())
# ensure internal field, serialized_policy_def, is not exposed
application = details["applications"]["default"]
deployment = application["deployments"]["autoscaling_app"]
autoscaling_config = deployment["deployment_config"]["autoscaling_config"]
assert "_serialized_policy_def" not in autoscaling_config
# Copied from test_controller.py
def test_get_deployment_config(_skip_if_ff_not_enabled, serve_instance):
"""Test getting deployment config."""
controller = _get_global_client()._controller
deployment_id = DeploymentID(name="App", app_name="default")
deployment_config = ray.get(
controller.get_deployment_config.remote(deployment_id=deployment_id)
)
# Before any deployment is created, the config should be None.
assert deployment_config is None
@serve.deployment
class App:
pass
serve.run(App.bind())
deployment_config = ray.get(
controller.get_deployment_config.remote(deployment_id=deployment_id)
)
# After the deployment is created, the config should be DeploymentConfig.
assert isinstance(deployment_config, DeploymentConfig)
def test_stuck_requests_are_force_killed(_skip_if_ff_not_enabled, serve_instance):
"""This test is really slow, because it waits for the ports to be released from TIME_WAIT state.
The ports are in TIME_WAIT state because the replicas are force-killed and the ports are not
released immediately."""
signal = SignalActor.remote()
@serve.deployment(
name="stuck-requests-deployment",
graceful_shutdown_timeout_s=1,
)
class StuckRequestsTest:
async def __call__(self):
# This request will never complete - it waits forever
await signal.wait.remote()
return "ok"
serve.run(
StuckRequestsTest.bind(),
name="stuck-requests-deployment",
route_prefix="/stuck-requests-deployment",
)
# Collect all ports used by the application before deleting it
http_ports = get_http_ports(route_prefix="/stuck-requests-deployment")
grpc_ports = get_grpc_ports(route_prefix="/stuck-requests-deployment")
http_url = get_application_url("HTTP", app_name="stuck-requests-deployment")
with ThreadPoolExecutor() as executor:
# Send requests that will hang forever (signal is never sent)
futures = [executor.submit(httpx.get, http_url, timeout=60) for _ in range(2)]
# Wait for requests to be received by the replica
wait_for_condition(
lambda: ray.get(signal.cur_num_waiters.remote()) == 2, timeout=10
)
# Delete the deployment - requests are still stuck
serve.delete("stuck-requests-deployment", _blocking=False)
# Verify the application is eventually deleted (replica was force-killed).
# For an ingress deployment in direct ingress mode, graceful_shutdown_timeout_s
# is floored to RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S +
# RAY_SERVE_DIRECT_INGRESS_SHUTDOWN_BUFFER_S (about 35s by default), so the
# controller force-kills after that floor rather than the configured 1s. Wait
# well past it.
wait_for_condition(
lambda: "stuck-requests-deployment" not in serve.status().applications,
timeout=60,
)
# The stuck requests should fail (connection closed or similar)
for future in futures:
try:
result = future.result(timeout=5)
# If we get a response, it should be an error (not 200)
assert result.status_code != 200
except Exception:
# Expected - request failed due to force-kill
pass
# TIME_WAIT can last up to 60s on Linux, so use a generous timeout
wait_for_condition(
all_ports_can_be_bound,
ports=http_ports + grpc_ports,
timeout=120,
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))