Files
ray-project--ray/python/ray/serve/_private/test_utils.py
T
2026-07-13 13:17:40 +08:00

1724 lines
54 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import datetime
import glob
import os
import random
import socket
import threading
import time
from contextlib import asynccontextmanager
from copy import copy, deepcopy
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
from unittest.mock import Mock
import grpc
import httpx
import requests
from starlette.requests import Request
import ray
from ray import serve
from ray._common.network_utils import build_address
from ray._common.test_utils import (
PrometheusTimeseries,
fetch_prometheus_metric_timeseries,
wait_for_condition,
)
from ray._common.utils import TimerBase
from ray.actor import ActorHandle
from ray.serve._private.client import ServeControllerClient
from ray.serve._private.common import (
CreatePlacementGroupRequest,
DeploymentID,
DeploymentStatus,
ReplicaID,
RequestProtocol,
RunningReplicaInfo,
)
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
RAY_SERVE_HAPROXY_SOCKET_PATH,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.deployment_info import DeploymentInfo
from ray.serve._private.deployment_scheduler import ReplicaSchedulingRequest
from ray.serve._private.deployment_state import (
ALL_REPLICA_STATES,
DeploymentVersion,
ReplicaStartupStatus,
ReplicaState,
)
from ray.serve._private.haproxy import HAProxyApi
from ray.serve._private.proxy import DRAINING_MESSAGE
from ray.serve._private.replica_result import ReplicaResult
from ray.serve._private.request_router import (
PendingRequest,
RunningReplica,
)
from ray.serve._private.usage import ServeUsageTag
from ray.serve.context import _get_global_client
from ray.serve.gang import GangContext
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.schema import ApplicationStatus, ReplicaRank, TargetGroup
from ray.util.state import list_actors
TELEMETRY_ROUTE_PREFIX = "/telemetry"
STORAGE_ACTOR_NAME = "storage"
PROMETHEUS_METRICS_TIMEOUT_S = 5
def skip_if_haproxy(reason: str):
"""Skip a test when the HAProxy ingress is enabled.
The HAProxy ingress runs as a separate premerge step with
RAY_SERVE_ENABLE_HA_PROXY=1. Some tests exercise behavior HAProxy does not
support yet (e.g. gRPC ingress) or that probes the native Serve proxy
directly. Mark those with this decorator instead of maintaining a separate
test allowlist. The test still runs in the non-HAProxy steps.
"""
import pytest
return pytest.mark.skipif(
RAY_SERVE_ENABLE_HA_PROXY, reason=f"HAProxy ingress: {reason}"
)
# Global variable that is fetched during controller recovery that
# marks (simulates) which replicas have died since controller first
# recovered a list of live replica names.
# NOTE(zcin): This is necessary because the replica's `recover()` method
# is called in the controller's init function, instead of in the control
# loop, so we can't "mark" a replica dead through a method. This global
# state is cleared after each test that uses the fixtures in this file.
dead_replicas_context = set()
# Replicas registered in this set will report `was_initialized() == False` to
# the controller during recovery, simulating the case where a previous
# controller crashed before the actor finished its initial setup.
uninitialized_replicas_context = set()
replica_rank_context: Dict[str, ReplicaRank] = {}
class MockTimer(TimerBase):
def __init__(self, start_time: Optional[float] = None):
self._lock = threading.Lock()
self.reset(start_time=start_time)
def reset(self, start_time: Optional[float] = None):
if start_time is None:
start_time = time.time()
self._curr = start_time
def time(self) -> float:
return self._curr
def advance(self, by: float):
with self._lock:
self._curr += by
def realistic_sleep(self, amt: float):
with self._lock:
self._curr += amt + 0.001
class MockAsyncTimer:
def __init__(self, start_time: Optional[float] = 0):
self.reset(start_time=start_time)
self._num_sleepers = 0
def reset(self, start_time: 0):
self._curr = start_time
def time(self) -> float:
return self._curr
async def sleep(self, amt: float):
self._num_sleepers += 1
end = self._curr + amt
# Give up the event loop
while self._curr < end:
await asyncio.sleep(0)
self._num_sleepers -= 1
def advance(self, amt: float):
self._curr += amt
def num_sleepers(self):
return self._num_sleepers
class MockKVStore:
def __init__(self):
self.store = dict()
def put(self, key: str, val: Any) -> bool:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
self.store[key] = val
return True
def get(self, key: str) -> Any:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
return self.store.get(key, None)
def delete(self, key: str) -> bool:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
if key in self.store:
del self.store[key]
return True
return False
class MockClusterNodeInfoCache:
def __init__(self):
self.alive_node_ids = set()
self.total_resources_per_node = dict()
self.available_resources_per_node = dict()
self.draining_nodes = dict()
self.node_labels = dict()
def get_alive_node_ids(self):
return self.alive_node_ids
def get_draining_nodes(self):
return self.draining_nodes
def get_active_node_ids(self):
return self.alive_node_ids - set(self.draining_nodes)
def get_node_az(self, node_id):
return None
def get_available_resources_per_node(self):
return self.available_resources_per_node
def get_total_resources_per_node(self):
return self.total_resources_per_node
def add_node(self, node_id: str, resources: Dict = None, labels: Dict = None):
self.alive_node_ids.add(node_id)
self.total_resources_per_node[node_id] = deepcopy(resources) or {}
self.available_resources_per_node[node_id] = deepcopy(resources) or {}
self.node_labels[node_id] = labels or {}
def set_available_resources_per_node(self, node_id: str, resources: Dict):
self.available_resources_per_node[node_id] = deepcopy(resources)
def get_node_labels(self, node_id: str):
return self.node_labels.get(node_id, {})
# Default value used by FakeRunningReplica when the test doesn't override.
FAKE_REPLICA_DEFAULT_MAX_ONGOING_REQUESTS = 10
# Every FakeRunningReplica is stamped with this deployment id. Router
# tests never care about app/name specifically; cross-test consistency
# matters more than differentiation.
FAKE_REPLICA_DEPLOYMENT_ID = DeploymentID(name="TEST_DEPLOYMENT")
class FakeRunningReplica(RunningReplica):
"""In-memory ``RunningReplica`` stand-in for request-router unit tests.
Shared between ``test_pow_2_request_router.py`` and
``test_consistent_hash_router.py``. Supports enough knobs for pow-2's
locality / cancellation / re-probe tests; consistent-hash only uses
the queue-length response hooks.
"""
def __init__(
self,
replica_unique_id: str,
*,
node_id: str = "",
availability_zone: Optional[str] = None,
reset_after_response: bool = False,
model_ids: Optional[Set[str]] = None,
sleep_time_s: float = 0.0,
max_ongoing_requests: int = FAKE_REPLICA_DEFAULT_MAX_ONGOING_REQUESTS,
):
self._replica_id = ReplicaID(
unique_id=replica_unique_id,
deployment_id=FAKE_REPLICA_DEPLOYMENT_ID,
)
self._node_id = node_id
self._availability_zone = availability_zone
self._queue_len = 0
self._max_ongoing_requests = max_ongoing_requests
self._has_queue_len_response = asyncio.Event()
self._reset_after_response = reset_after_response
self._model_ids = model_ids or set()
self._sleep_time_s = sleep_time_s
self._exception: Optional[Exception] = None
self.get_queue_len_was_cancelled = False
self.queue_len_deadline_history = list()
self.num_get_queue_len_calls = 0
@property
def replica_id(self) -> ReplicaID:
return self._replica_id
@property
def node_id(self) -> str:
return self._node_id
@property
def availability_zone(self) -> Optional[str]:
return self._availability_zone
@property
def multiplexed_model_ids(self) -> Set[str]:
return self._model_ids
def update_replica_info(self, replica_info: RunningReplicaInfo) -> None:
self._model_ids = set(replica_info.multiplexed_model_ids)
@property
def max_ongoing_requests(self) -> int:
return self._max_ongoing_requests
def set_queue_len_response(
self,
queue_len: int,
exception: Optional[Exception] = None,
):
self._queue_len = queue_len
self._exception = exception
self._has_queue_len_response.set()
def push_proxy_handle(self, handle: ActorHandle):
pass
async def get_queue_len(self, *, deadline_s: float) -> int:
self.num_get_queue_len_calls += 1
self.queue_len_deadline_history.append(deadline_s)
try:
while not self._has_queue_len_response.is_set():
await self._has_queue_len_response.wait()
if self._sleep_time_s > 0:
await asyncio.sleep(self._sleep_time_s)
if self._reset_after_response:
self._has_queue_len_response.clear()
if self._exception is not None:
raise self._exception
return self._queue_len
except asyncio.CancelledError:
self.get_queue_len_was_cancelled = True
raise
def try_send_request(
self, pr: PendingRequest, with_rejection: bool
) -> ReplicaResult:
raise NotImplementedError()
def send_request_with_rejection(self, pr: PendingRequest) -> ReplicaResult:
raise NotImplementedError()
class FakeRemoteFunction:
def remote(self):
pass
class MockActorHandle:
def __init__(self, **kwargs):
self._options = kwargs
self._actor_id = "fake_id"
self.initialize_and_get_metadata_called = False
self.is_allocated_called = False
@property
def initialize_and_get_metadata(self):
self.initialize_and_get_metadata_called = True
# return a mock object so that we can call `remote()` on it.
return FakeRemoteFunction()
@property
def is_allocated(self):
self.is_allocated_called = True
return FakeRemoteFunction()
class MockActorClass:
def __init__(self):
self._init_args = ()
self._options = dict()
def options(self, **kwargs):
res = copy(self)
for k, v in kwargs.items():
res._options[k] = v
return res
def remote(self, *args) -> MockActorHandle:
return MockActorHandle(init_args=args, **self._options)
class MockPlacementGroup:
def __init__(self, request: CreatePlacementGroupRequest):
self._bundles = request.bundles
self._strategy = request.strategy
self._soft_target_node_id = request.target_node_id
self._name = request.name
self._lifetime = "detached"
class MockDeploymentHandle:
def __init__(self, deployment_name: str, app_name: str = SERVE_DEFAULT_APP_NAME):
self._deployment_name = deployment_name
self._app_name = app_name
self._protocol = RequestProtocol.UNDEFINED
self._running_replicas_populated = False
self._initialized = False
def is_initialized(self):
return self._initialized
def _init(self):
if self._initialized:
raise RuntimeError("already initialized")
self._initialized = True
def options(self, *args, **kwargs):
return self
def __eq__(self, dep: Tuple[str]):
other_deployment_name, other_app_name = dep
return (
self._deployment_name == other_deployment_name
and self._app_name == other_app_name
)
def _set_request_protocol(self, protocol: RequestProtocol):
self._protocol = protocol
def _get_or_create_router(self):
pass
def running_replicas_populated(self) -> bool:
return self._running_replicas_populated
def set_running_replicas_populated(self, val: bool):
self._running_replicas_populated = val
class MockDeploymentActorWrapper:
"""Mock for DeploymentActorWrapper with per-instance setters."""
def __init__(
self,
deployment_id: DeploymentID,
config,
code_version: str,
recovered_handle=None,
):
self._deployment_id = deployment_id
self._config = config
self._code_version = code_version
self._handle = recovered_handle
self._ready = False # Recovered starts pending until set_ready()
self._start_error_msg: Optional[str] = None
self._check_ready_error_msg: Optional[str] = None
self.killed = False
self.pending_killed = False
self._start_fail_count = 0
self._start_fail_msg = None
self._health_ok = True
self.reset_health_state_after_running_count = 0
@property
def actor_logical_name(self) -> str:
return self._config.name
@property
def code_version(self) -> str:
return self._code_version
def set_ready(self):
self._ready = True
def set_start_error(self, error_msg: str):
"""Make start() fail with this error (persists until cleared)."""
self._start_error_msg = error_msg
def set_check_ready_error(self, error_msg: str):
self._check_ready_error_msg = error_msg
def set_failed_to_start(self, error_msg: str = "constructor failed"):
"""Simulate start failure (like replica's set_failed_to_start). check_ready will return this error."""
self._check_ready_error_msg = error_msg
def start(self, deployment_runtime_env=None):
if self._start_error_msg is not None:
return False, self._start_error_msg
# Match real behavior: created actor starts as pending until ready.
return True, None
def check_ready(self):
if self._check_ready_error_msg is not None:
return False, self._check_ready_error_msg
if self._ready:
return True, None
return False, None
def set_health_ok(self, ok: bool) -> None:
"""If False, ``check_health`` returns False (failed health reconciliation)."""
self._health_ok = ok
def reset_health_state_after_running(self) -> None:
"""Match DeploymentActorWrapper; increments counter for unit tests."""
self.reset_health_state_after_running_count += 1
def check_health(self) -> bool:
"""Match DeploymentActorWrapper; controlled via ``set_health_ok``."""
return self._health_ok
def kill(self) -> None:
self.killed = True
class MockReplicaActorWrapper:
def __init__(
self,
replica_id: ReplicaID,
version: DeploymentVersion,
):
self._replica_id = replica_id
self._actor_name = replica_id.to_full_id_str()
# Will be set when `start()` is called.
self.started = False
# Will be set when `recover()` is called.
self.recovering = False
# Will be set when `start()` is called.
self.version = version
# Initial state for a replica is PENDING_ALLOCATION.
self.status = ReplicaStartupStatus.PENDING_ALLOCATION
# Will be set when `graceful_stop()` is called.
self.stopped = False
# Expected to be set in the test.
self.done_stopping = False
# Will be set when `force_stop()` is called.
self.force_stopped_counter = 0
# Will be set when `check_health()` is called.
self.health_check_called = False
# Returned by the health check.
self.healthy = True
self._is_cross_language = False
self._actor_handle = MockActorHandle()
self._node_id = None
self._node_ip = None
self._node_instance_id = None
self._node_id_is_set = False
self._log_file_path = None
self._actor_id = None
self._internal_grpc_port = None
self._http_port = None
self._pg_bundles = None
self._initialization_latency_s = -1
self._docs_path = None
self._rank = replica_rank_context.get(replica_id.unique_id, None)
self._assign_rank_callback = None
self._ingress = False
self._gang_context = None
self._gang_pg_index = None
self._unrecoverable = False
@property
def is_cross_language(self) -> bool:
return self._is_cross_language
@property
def replica_id(self) -> ReplicaID:
return self._replica_id
@property
def deployment_name(self) -> str:
return self._replica_id.deployment_id.name
@property
def actor_handle(self) -> MockActorHandle:
return self._actor_handle
@property
def max_ongoing_requests(self) -> int:
return self.version.deployment_config.max_ongoing_requests
@property
def graceful_shutdown_timeout_s(self) -> float:
return self.version.deployment_config.graceful_shutdown_timeout_s
@property
def health_check_period_s(self) -> float:
return self.version.deployment_config.health_check_period_s
@property
def health_check_timeout_s(self) -> float:
return self.version.deployment_config.health_check_timeout_s
@property
def pid(self) -> Optional[int]:
return None
@property
def actor_id(self) -> Optional[str]:
return self._actor_id
@property
def worker_id(self) -> Optional[str]:
return None
@property
def node_id(self) -> Optional[str]:
if self._node_id_is_set:
return self._node_id
if self.status == ReplicaStartupStatus.SUCCEEDED or self.started:
return "node-id"
return None
@property
def availability_zone(self) -> Optional[str]:
return None
@property
def node_ip(self) -> Optional[str]:
return None
@property
def node_instance_id(self) -> Optional[str]:
return None
@property
def log_file_path(self) -> Optional[str]:
return self._log_file_path
@property
def grpc_port(self) -> Optional[int]:
return None
@property
def placement_group_bundles(self) -> Optional[List[Dict[str, float]]]:
return None
@property
def initialization_latency_s(self) -> float:
return self._initialization_latency_s
@property
def reconfigure_start_time(self) -> Optional[float]:
return None
@property
def last_health_check_latency_ms(self) -> Optional[float]:
return None
@property
def last_health_check_failed(self) -> bool:
return False
def set_docs_path(self, docs_path: str):
self._docs_path = docs_path
@property
def docs_path(self) -> Optional[str]:
return self._docs_path
def set_status(self, status: ReplicaStartupStatus):
self.status = status
def set_ready(self, version: DeploymentVersion = None):
self.status = ReplicaStartupStatus.SUCCEEDED
# Mirror the real actor: a started replica has allocated a log file.
self._log_file_path = "serve/replica.log"
if version:
self.version_to_be_fetched_from_actor = version
else:
self.version_to_be_fetched_from_actor = self.version
def set_failed_to_start(self):
self.status = ReplicaStartupStatus.FAILED
def set_done_stopping(self):
self.done_stopping = True
def set_unhealthy(self):
self.healthy = False
def set_starting_version(self, version: DeploymentVersion):
"""Mocked deployment_worker return version from reconfigure()"""
self.starting_version = version
def set_node_id(self, node_id: str):
self._node_id = node_id
self._node_id_is_set = True
def set_actor_id(self, actor_id: str):
self._actor_id = actor_id
def start(
self,
deployment_info: DeploymentInfo,
assign_rank_callback: Callable[[ReplicaID], ReplicaRank],
gang_placement_group=None,
gang_pg_index=None,
gang_context=None,
):
self.started = True
self._gang_context = gang_context
self._gang_pg_index = gang_pg_index
self._assign_rank_callback = assign_rank_callback
self._rank = assign_rank_callback(self._replica_id.unique_id, node_id=-1)
replica_rank_context[self._replica_id.unique_id] = self._rank
def _on_scheduled_stub(*args, **kwargs):
pass
return ReplicaSchedulingRequest(
replica_id=self._replica_id,
actor_def=Mock(),
actor_resources={},
actor_options={"name": "placeholder"},
actor_init_args=(),
placement_group_bundles=(
deployment_info.replica_config.placement_group_bundles
),
on_scheduled=_on_scheduled_stub,
gang_placement_group=gang_placement_group,
gang_pg_index=gang_pg_index,
)
@property
def rank(self) -> Optional[ReplicaRank]:
return self._rank
def reconfigure(
self,
version: DeploymentVersion,
rank: ReplicaRank = None,
):
self.started = True
updating = self.version.requires_actor_reconfigure(version)
self.version = version
self._rank = rank
replica_rank_context[self._replica_id.unique_id] = rank
return updating
def recover(self, ingress: bool = False):
if self.replica_id in dead_replicas_context:
return False
self._ingress = ingress
self.recovering = True
self.started = False
self._rank = replica_rank_context.get(self._replica_id.unique_id, None)
# Mirror production: the `was_initialized` probe is fired here and
# observed asynchronously in `check_ready()`. Tests register
# uninitialized replicas via `uninitialized_replicas_context`.
self._unrecoverable = self.replica_id in uninitialized_replicas_context
return True
def check_ready(self) -> ReplicaStartupStatus:
# If the controller's async `was_initialized` probe came back False,
# report a failed-but-unrecoverable startup so the reconciler drops
# and replaces the replica without recording a deploy failure.
if self.recovering and self._unrecoverable:
return (
ReplicaStartupStatus.FAILED,
f"{self._replica_id} was found alive but never finished "
"its initial setup.",
)
ready = self.status
self.status = ReplicaStartupStatus.PENDING_INITIALIZATION
if ready == ReplicaStartupStatus.SUCCEEDED and self.recovering:
self.recovering = False
self.started = True
self.version = self.version_to_be_fetched_from_actor
return ready, None
def resource_requirements(self) -> Tuple[str, str]:
assert self.started
return str({"REQUIRED_RESOURCE": 1.0}), str({"AVAILABLE_RESOURCE": 1.0})
@property
def actor_resources(self) -> Dict[str, float]:
return {"CPU": 0.1}
@property
def available_resources(self) -> Dict[str, float]:
# Only used to print a warning.
return {}
def graceful_stop(self) -> None:
# `started` is only set after a successful `check_ready` transition
# to RUNNING. A replica force-stopped while still RECOVERING (e.g.,
# because the `was_initialized` probe failed) is a legitimate stop.
assert self.started or self.recovering
self.stopped = True
return self.graceful_shutdown_timeout_s
def check_stopped(self) -> bool:
return self.done_stopping
def force_stop(self, log_shutdown_message: bool = False):
self.force_stopped_counter += 1
def check_health(self):
self.health_check_called = True
return self.healthy
def get_routing_stats(self) -> Dict[str, Any]:
return {}
def get_outbound_deployments(self) -> Optional[List[DeploymentID]]:
return getattr(self, "_outbound_deployments", None)
@property
def route_patterns(self) -> Optional[List[str]]:
return None
@property
def gang_context(self) -> Optional[GangContext]:
return self._gang_context
@property
def unrecoverable(self) -> bool:
return self._unrecoverable
@serve.deployment
class GetPID:
def __call__(self):
return os.getpid()
get_pid_entrypoint = GetPID.bind()
def check_ray_stopped():
try:
requests.get("http://localhost:8265/api/ray/version")
return False
except Exception:
return True
def check_ray_started():
return requests.get("http://localhost:8265/api/ray/version").status_code == 200
def check_deployment_status(
name: str, expected_status: DeploymentStatus, app_name=SERVE_DEFAULT_APP_NAME
) -> bool:
app_status = serve.status().applications[app_name]
assert app_status.deployments[name].status == expected_status
return True
def get_num_alive_replicas(
deployment_name: str, app_name: str = SERVE_DEFAULT_APP_NAME
) -> int:
"""Get the replicas currently running for the given deployment."""
dep_id = DeploymentID(name=deployment_name, app_name=app_name)
actors = list_actors(
filters=[
("class_name", "=", dep_id.to_replica_actor_class_name()),
("state", "=", "ALIVE"),
]
)
return len(actors)
def expected_proxy_actors(num_proxy_nodes: int = 1) -> Dict[str, int]:
"""Proxy actors expected ALIVE by class name for the given number of proxy nodes.
Natively each proxy node runs one ProxyActor. Under HAProxy each proxy node runs an
HAProxyManager and the head node also runs a single fallback ProxyActor. Set-based
callers can take the keys, count-based callers use the counts directly.
"""
if RAY_SERVE_ENABLE_HA_PROXY:
return {"HAProxyManager": num_proxy_nodes, "ProxyActor": 1}
return {"ProxyActor": num_proxy_nodes}
def wait_for_haproxy_routing_to_replica(timeout: int = 30):
"""Block until HAProxy marks a replica's primary backend server UP.
serve.run returns once replicas are RUNNING, but until a replica's
direct-ingress server passes HAProxy's health check, HAProxy serves requests
from the head-node backup fallback proxy. That path adds proxy and router
spans, so trace-topology assertions must wait for direct routing first. No-op
without HAProxy, which has no fallback to race.
"""
if not RAY_SERVE_ENABLE_HA_PROXY:
return
socket_glob = os.path.join(
os.path.dirname(RAY_SERVE_HAPROXY_SOCKET_PATH), "*", "admin.sock"
)
def replica_backend_up():
for sock_path in glob.glob(socket_glob):
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(5.0)
client.connect(sock_path)
client.sendall(b"show stat\n")
data = b""
while chunk := client.recv(65536):
data += chunk
except OSError:
continue # stale socket from a prior run
stats = HAProxyApi._parse_haproxy_csv_stats(data.decode(errors="replace"))
if any(
name.startswith("SERVE_REPLICA") and server.is_up
for servers in stats.values()
for name, server in servers.items()
):
return True
return False
wait_for_condition(replica_backend_up, timeout=timeout)
def alive_actor_counts() -> Dict[str, int]:
"""Count of ALIVE actors by class name in the current Ray session."""
counts: Dict[str, int] = {}
for actor in list_actors(filters=[("STATE", "=", "ALIVE")]):
counts[actor["class_name"]] = counts.get(actor["class_name"], 0) + 1
return counts
def check_num_replicas_gte(
name: str, target: int, app_name: str = SERVE_DEFAULT_APP_NAME
) -> int:
"""Check if num replicas is >= target."""
assert get_num_alive_replicas(name, app_name) >= target
return True
def check_num_replicas_eq(
name: str,
target: int,
app_name: str = SERVE_DEFAULT_APP_NAME,
use_controller: bool = False,
) -> bool:
"""Check if num replicas is == target."""
if use_controller:
dep = serve.status().applications[app_name].deployments[name]
num_running_replicas = dep.replica_states.get(ReplicaState.RUNNING, 0)
assert num_running_replicas == target
else:
assert get_num_alive_replicas(name, app_name) == target
return True
def check_num_replicas_lte(
name: str, target: int, app_name: str = SERVE_DEFAULT_APP_NAME
) -> int:
"""Check if num replicas is <= target."""
assert get_num_alive_replicas(name, app_name) <= target
return True
def check_apps_running(apps: List):
status = serve.status()
for app_name in apps:
assert status.applications[app_name].status == ApplicationStatus.RUNNING
return True
def check_replica_counts(
controller: ActorHandle,
deployment_id: DeploymentID,
total: Optional[int] = None,
by_state: Optional[List[Tuple[ReplicaState, int, Callable]]] = None,
):
"""Uses _dump_replica_states_for_testing to check replica counts.
Args:
controller: A handle to the Serve controller.
deployment_id: The deployment to check replica counts for.
total: The total number of expected replicas for the deployment.
by_state: A list of tuples of the form
(replica state, number of replicas, filter function).
Used for more fine grained checks.
Returns:
True when all assertions pass (raises ``AssertionError`` otherwise).
"""
replicas = ray.get(
controller._dump_replica_states_for_testing.remote(deployment_id)
)
if total is not None:
replica_counts = {
state: len(replicas.get([state]))
for state in ALL_REPLICA_STATES
if replicas.get([state])
}
assert replicas.count() == total, replica_counts
if by_state is not None:
for state, count, check in by_state:
assert isinstance(state, ReplicaState)
assert isinstance(count, int) and count >= 0
if check:
filtered = {r for r in replicas.get(states=[state]) if check(r)}
curr_count = len(filtered)
else:
curr_count = replicas.count(states=[state])
msg = f"Expected {count} for state {state} but got {curr_count}."
assert curr_count == count, msg
return True
@ray.remote(name=STORAGE_ACTOR_NAME, namespace=SERVE_NAMESPACE, num_cpus=0)
class TelemetryStorage:
def __init__(self):
self.reports_received = 0
self.current_report = dict()
def store_report(self, report: Dict) -> None:
self.reports_received += 1
self.current_report = report
def get_report(self) -> Dict:
return self.current_report
def get_reports_received(self) -> int:
return self.reports_received
@serve.deployment(ray_actor_options={"num_cpus": 0})
class TelemetryReceiver:
def __init__(self):
self.storage = ray.get_actor(name=STORAGE_ACTOR_NAME, namespace=SERVE_NAMESPACE)
async def __call__(self, request: Request) -> bool:
report = await request.json()
ray.get(self.storage.store_report.remote(report))
return True
receiver_app = TelemetryReceiver.bind()
def start_telemetry_app():
"""Start a telemetry Serve app.
Ray should be initialized before calling this method.
NOTE: If you're running the TelemetryReceiver Serve app to check telemetry,
remember that the receiver itself is counted in the telemetry. E.g. if you
deploy a Serve app other than the receiver, the number of apps in the
cluster is 2- not 1 since the receiver is also running.
Returns a handle to a TelemetryStorage actor. You can use this actor
to access the latest telemetry reports.
"""
storage = TelemetryStorage.remote()
serve.run(receiver_app, name="telemetry", route_prefix=TELEMETRY_ROUTE_PREFIX)
return storage
def check_telemetry(
tag: ServeUsageTag, expected: Any, storage_actor_name: str = STORAGE_ACTOR_NAME
):
storage_handle = ray.get_actor(storage_actor_name, namespace=SERVE_NAMESPACE)
report = ray.get(storage_handle.get_report.remote())
print(report["extra_usage_tags"])
assert tag.get_value_from_report(report) == expected
return True
def ping_grpc_list_applications(channel, app_names, test_draining=False):
import pytest
stub = serve_pb2_grpc.RayServeAPIServiceStub(channel)
request = serve_pb2.ListApplicationsRequest()
if test_draining:
with pytest.raises(grpc.RpcError) as exception_info:
_, _ = stub.ListApplications.with_call(request=request)
rpc_error = exception_info.value
assert rpc_error.code() == grpc.StatusCode.UNAVAILABLE
assert rpc_error.details() == DRAINING_MESSAGE
else:
response, call = stub.ListApplications.with_call(request=request)
assert call.code() == grpc.StatusCode.OK
assert response.application_names == app_names
return True
def ping_grpc_healthz(channel, test_draining=False):
import pytest
stub = serve_pb2_grpc.RayServeAPIServiceStub(channel)
request = serve_pb2.HealthzRequest()
if test_draining:
with pytest.raises(grpc.RpcError) as exception_info:
_, _ = stub.Healthz.with_call(request=request)
rpc_error = exception_info.value
assert rpc_error.code() == grpc.StatusCode.UNAVAILABLE
assert rpc_error.details() == DRAINING_MESSAGE
else:
response, call = stub.Healthz.with_call(request=request)
assert call.code() == grpc.StatusCode.OK
if not RAY_SERVE_ENABLE_HA_PROXY:
assert response.message == "success"
return True
def ping_grpc_call_method(channel, app_name, test_not_found=False):
import pytest
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
metadata = (("application", app_name),)
if test_not_found:
with pytest.raises(grpc.RpcError) as exception_info:
_, _ = stub.__call__.with_call(request=request, metadata=metadata)
rpc_error = exception_info.value
assert rpc_error.code() == grpc.StatusCode.NOT_FOUND, rpc_error.code()
assert f"Application '{app_name}' not found." in rpc_error.details()
else:
response, call = stub.__call__.with_call(request=request, metadata=metadata)
assert call.code() == grpc.StatusCode.OK, call.code()
assert response.greeting == "Hello foo from bar", response.greeting
def ping_grpc_another_method(channel, app_name):
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
metadata = (("application", app_name),)
response = stub.Method1(request=request, metadata=metadata)
assert response.greeting == "Hello foo from method1"
def ping_grpc_model_multiplexing(channel, app_name):
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
multiplexed_model_id = "999"
metadata = (
("application", app_name),
("multiplexed_model_id", multiplexed_model_id),
)
response = stub.Method2(request=request, metadata=metadata)
assert (
response.greeting
== f"Method2 called model, loading model: {multiplexed_model_id}"
)
def ping_grpc_streaming(channel, app_name):
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
metadata = (("application", app_name),)
responses = stub.Streaming(request=request, metadata=metadata)
for idx, response in enumerate(responses):
assert response.greeting == f"{idx}: Hello foo from bar"
def ping_fruit_stand(channel, app_name):
stub = serve_pb2_grpc.FruitServiceStub(channel)
request = serve_pb2.FruitAmounts(orange=4, apple=8)
metadata = (("application", app_name),)
response = stub.FruitStand(request=request, metadata=metadata)
assert response.costs == 32
@asynccontextmanager
async def send_signal_on_cancellation(signal_actor: ActorHandle):
cancelled = False
try:
yield
await asyncio.sleep(100)
except asyncio.CancelledError:
cancelled = True
# Clear the context var to avoid Ray recursively cancelling this method call.
ray._raylet.async_task_id.set(None)
await signal_actor.send.remote()
if not cancelled:
raise RuntimeError(
"CancelledError wasn't raised during `send_signal_on_cancellation` block"
)
class FakeGrpcContext:
def __init__(self):
self._auth_context = {"key": "value"}
self._invocation_metadata = [("key", "value")]
self._peer = "peer"
self._peer_identities = b"peer_identities"
self._peer_identity_key = "peer_identity_key"
self._code = None
self._details = None
self._trailing_metadata = []
self._invocation_metadata = []
def auth_context(self):
return self._auth_context
def code(self):
return self._code
def details(self):
return self._details
def peer(self):
return self._peer
def peer_identities(self):
return self._peer_identities
def peer_identity_key(self):
return self._peer_identity_key
def trailing_metadata(self):
return self._trailing_metadata
def set_code(self, code):
self._code = code
def set_details(self, details):
self._details = details
def set_trailing_metadata(self, trailing_metadata):
self._trailing_metadata = trailing_metadata
def invocation_metadata(self):
return self._invocation_metadata
class FakeGauge:
def __init__(self, name: str = None, tag_keys: Tuple[str] = None):
self.name = name
self.values = dict()
self.tags = tag_keys or ()
self.default_tags = dict()
def set_default_tags(self, tags: Dict[str, str]):
for key, tag in tags.items():
assert key in self.tags
self.default_tags[key] = tag
def set(self, value: Union[int, float], tags: Dict[str, str] = None):
merged_tags = self.default_tags.copy()
merged_tags.update(tags or {})
assert set(merged_tags.keys()) == set(self.tags)
d = self.values
for tag in self.tags[:-1]:
tag_value = merged_tags[tag]
if tag_value not in d:
d[tag_value] = dict()
d = d[tag_value]
d[merged_tags[self.tags[-1]]] = value
def get_value(self, tags: Dict[str, str]):
value = self.values
for tag in self.tags:
tag_value = tags[tag]
value = value.get(tag_value)
if value is None:
return
return value
class FakeCounter:
def __init__(self, name: str = None, tag_keys: Tuple[str] = None):
self.name = name
self.counts = dict()
self.tags = tag_keys or ()
self.default_tags = dict()
def set_default_tags(self, tags: Dict[str, str]):
for key, tag in tags.items():
assert key in self.tags
self.default_tags[key] = tag
def inc(self, value: Union[int, float] = 1.0, tags: Dict[str, str] = None):
merged_tags = self.default_tags.copy()
merged_tags.update(tags or {})
assert set(merged_tags.keys()) == set(self.tags)
d = self.counts
for tag in self.tags[:-1]:
tag_value = merged_tags[tag]
if tag_value not in d:
d[tag_value] = dict()
d = d[tag_value]
key = merged_tags[self.tags[-1]]
d[key] = d.get(key, 0) + value
def get_count(self, tags: Dict[str, str]) -> int:
value = self.counts
for tag in self.tags:
tag_value = tags[tag]
value = value.get(tag_value)
if value is None:
return
return value
def get_tags(self):
return self.tags
@ray.remote
def get_node_id():
return ray.get_runtime_context().get_node_id()
def check_num_alive_nodes(target: int):
alive_nodes = [node for node in ray.nodes() if node["Alive"]]
assert len(alive_nodes) == target
return True
def get_deployment_details(
deployment_name: str,
app_name: str = SERVE_DEFAULT_APP_NAME,
_client: ServeControllerClient = None,
):
client = _client or _get_global_client()
details = client.get_serve_details()
return details["applications"][app_name]["deployments"][deployment_name]
@ray.remote(num_cpus=0)
class Barrier:
"""Block until n participants have called wait()."""
def __init__(self, n: int):
self.n = n
self.count = 0
self.event = asyncio.Event()
async def wait(self):
self.count += 1
if self.count >= self.n:
self.event.set()
else:
await self.event.wait()
def get_count(self) -> int:
return self.count
@ray.remote(num_cpus=0)
class Accumulator:
"""Collect items from multiple actors/replicas."""
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def get(self) -> list:
return list(self.items)
def count(self) -> int:
return len(self.items)
@ray.remote(num_cpus=0)
class FailedReplicaStore:
"""Controls replica constructor failure behavior for constructor failure tests.
Behavior is determined by ``fail_first``:
- ``fail_first=True`` (transient): first caller fails, rest succeed.
- ``fail_first=False`` (partial): first caller succeeds, rest fail.
All decisions are made in a single atomic actor call to avoid races
between concurrent replicas.
"""
def __init__(self, fail_first: bool = False):
self._fail_first = fail_first
self._first_caller = False
self._fail_count = 0
def get_fail_count(self) -> int:
"""Returns the number replica startup failures"""
return self._fail_count
def should_fail(self) -> bool:
"""Returns whether this replica should raise in its constructor."""
if not self._first_caller:
self._first_caller = True
result = self._fail_first
else:
result = not self._fail_first
if result:
self._fail_count += 1
return result
@ray.remote(num_cpus=0)
class FailedGangReplicaStore:
"""
Controls replica constructor failure behavior for gang scheduling tests.
- The first gang seen is allowed to run.
- The next gang has first replica flagged to fail.
- Retry gangs after the first failed gang have first replica flagged.
"""
def __init__(self):
self._good_gang_chosen = False
self._successful_gang_id = None
self._failed_gang_ids = set()
def mark_first_failing_gang(self, gang_id: str) -> bool:
"""Picks the good gang on the first call and flags the first replica of the next gang to fail."""
if not self._good_gang_chosen:
self._good_gang_chosen = True
self._successful_gang_id = gang_id
return False
if gang_id == self._successful_gang_id:
return False
if len(self._failed_gang_ids) == 0:
self._failed_gang_ids.add(gang_id)
return True
return False
def mark_retry_failing_gang(self, gang_id: str) -> bool:
"""Flags the first replica of each new retry gang.
This is called after ``mark_first_failing_gang``."""
if gang_id == self._successful_gang_id:
return False
if gang_id not in self._failed_gang_ids:
self._failed_gang_ids.add(gang_id)
return True
return False
def get_failed_gang_count(self) -> int:
"""Returns the number of distinct gangs that failed."""
return len(self._failed_gang_ids)
@ray.remote(num_cpus=0)
class SharedFlag:
"""Non-blocking boolean flag shared across actors."""
def __init__(self):
self.value = False
def set(self):
self.value = True
def clear(self):
self.value = False
def is_set(self) -> bool:
return self.value
@ray.remote(num_cpus=0)
class SharedCounter:
"""Simple shared counter for cross-actor coordination."""
def __init__(self):
self.count = 0
def inc(self) -> int:
self.count += 1
return self.count
def get(self) -> int:
return self.count
@ray.remote(num_cpus=0)
class Counter:
def __init__(self, target: int):
self.count = 0
self.target = target
self.ready_event = asyncio.Event()
def inc(self):
self.count += 1
if self.count == self.target:
self.ready_event.set()
async def wait(self):
await self.ready_event.wait()
def tlog(s: str, level: str = "INFO"):
"""Convenient logging method for testing."""
now = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
print(f"[{level}] {now} {s}")
def check_target_groups_ready(
client: ServeControllerClient,
app_name: str,
protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP,
):
"""Wait for target groups to be ready for the given app and protocol.
Target groups are ready when there are at least one target for the given protocol. And it's
possible that target groups are not ready immediately. An example is when the controller
is recovering from a crash.
"""
target_groups = ray.get(client._controller.get_target_groups.remote(app_name))
target_groups = [
target_group
for target_group in target_groups
if target_group.protocol == protocol
]
all_targets = [
target for target_group in target_groups for target in target_group.targets
]
return len(all_targets) > 0
def get_application_urls(
protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP,
app_name: str = SERVE_DEFAULT_APP_NAME,
use_localhost: bool = True,
is_websocket: bool = False,
exclude_route_prefix: bool = False,
from_proxy_manager: bool = False,
) -> List[str]:
"""Get the URL of the application.
Args:
protocol: The protocol to use for the application.
app_name: The name of the application.
use_localhost: Whether to use localhost instead of the IP address.
Set to True if Serve deployments are not exposed publicly or
for low latency benchmarking.
is_websocket: Whether the url should be served as a websocket.
exclude_route_prefix: The route prefix to exclude from the application.
from_proxy_manager: Whether the caller is a proxy manager.
Returns:
The URLs of the application.
"""
client = _get_global_client()
serve_details = client.get_serve_details()
assert (
app_name in serve_details["applications"]
), f"App {app_name} not found in serve details. Use this method only when the app is known to be running."
route_prefix = serve_details["applications"][app_name]["route_prefix"]
# route_prefix is set to None when route_prefix value is specifically set to None
# in the config used to deploy the app.
if exclude_route_prefix or route_prefix is None:
route_prefix = ""
if isinstance(protocol, str):
protocol = RequestProtocol(protocol)
target_groups: List[TargetGroup] = ray.get(
client._controller.get_target_groups.remote(app_name, from_proxy_manager)
)
target_groups = [
target_group
for target_group in target_groups
if target_group.protocol == protocol
]
if len(target_groups) == 0:
raise ValueError(
f"No target group found for app {app_name} with protocol {protocol} and route prefix {route_prefix}"
)
urls = []
for target_group in target_groups:
for target in target_group.targets:
ip = "localhost" if use_localhost else target.ip
if protocol == RequestProtocol.HTTP:
scheme = "ws" if is_websocket else "http"
url = f"{scheme}://{build_address(ip, target.port)}{route_prefix}"
elif protocol == RequestProtocol.GRPC:
if is_websocket:
raise ValueError(
"is_websocket=True is not supported with gRPC protocol."
)
url = build_address(ip, target.port)
else:
raise ValueError(f"Unsupported protocol: {protocol}")
url = url.rstrip("/")
urls.append(url)
return urls
def get_application_url(
protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP,
app_name: str = SERVE_DEFAULT_APP_NAME,
use_localhost: bool = True,
is_websocket: bool = False,
exclude_route_prefix: bool = False,
from_proxy_manager: bool = False,
) -> str:
"""Get the URL of the application.
Args:
protocol: The protocol to use for the application.
app_name: The name of the application.
use_localhost: Whether to use localhost instead of the IP address.
Set to True if Serve deployments are not exposed publicly or
for low latency benchmarking.
is_websocket: Whether the url should be served as a websocket.
exclude_route_prefix: The route prefix to exclude from the application.
from_proxy_manager: Whether the caller is a proxy manager.
Returns:
The URL of the application. If there are multiple URLs, a random one is returned.
"""
return random.choice(
get_application_urls(
protocol,
app_name,
use_localhost,
is_websocket,
exclude_route_prefix,
from_proxy_manager,
)
)
def check_running(app_name: str = SERVE_DEFAULT_APP_NAME):
assert serve.status().applications[app_name].status == ApplicationStatus.RUNNING
return True
def request_with_retries(timeout=30, app_name=SERVE_DEFAULT_APP_NAME):
result_holder = {"resp": None}
def _attempt() -> bool:
try:
url = get_application_url("HTTP", app_name=app_name)
result_holder["resp"] = httpx.get(url, timeout=timeout)
return True
except (httpx.RequestError, IndexError):
return False
try:
wait_for_condition(_attempt, timeout=timeout)
return result_holder["resp"]
except RuntimeError as e:
# Preserve previous API by raising TimeoutError on expiry
raise TimeoutError from e
# Metrics test utilities
TEST_METRICS_EXPORT_PORT = 9999
def get_metric_float(
metric: str,
expected_tags: Optional[Dict[str, str]],
timeseries: Optional[PrometheusTimeseries] = None,
timeout: float = 20,
) -> float:
"""Gets the float value of metric.
If tags is specified, searched for metric with matching tags.
Returns -1 if the metric isn't available.
"""
if timeseries is None:
timeseries = PrometheusTimeseries()
samples = fetch_prometheus_metric_timeseries(
[f"localhost:{TEST_METRICS_EXPORT_PORT}"],
timeseries,
timeout=timeout,
).get(metric, [])
for sample in samples:
if expected_tags.items() <= sample.labels.items():
return sample.value
return -1
def check_metric_float_eq(
metric: str,
expected: float,
expected_tags: Optional[Dict[str, str]],
timeseries: Optional[PrometheusTimeseries] = None,
timeout: float = 20,
) -> bool:
"""Check if a metric's float value equals the expected value."""
metric_value = get_metric_float(metric, expected_tags, timeseries, timeout=timeout)
assert float(metric_value) == expected
return True
def get_metric_dictionaries(
name: str,
timeout: float = 20,
timeseries: Optional[PrometheusTimeseries] = None,
wait: bool = True,
) -> List[Dict]:
"""Get metric samples as list of label dicts.
Args:
name: Metric name to fetch.
timeout: Timeout for each fetch attempt.
timeseries: Optional shared timeseries to populate.
wait: If True (default), wait for metric to appear before returning.
If False, fetch once and return immediately (possibly empty).
Returns:
List of metric samples as label dicts.
"""
if timeseries is None:
timeseries = PrometheusTimeseries()
def metric_available() -> bool:
prom_timeseries = fetch_prometheus_metric_timeseries(
[f"localhost:{TEST_METRICS_EXPORT_PORT}"],
timeseries,
# pass timeout to fetch_prometheus_metric_timeseries
# so the test doesn't hang on requests.get
timeout=timeout,
)
assert (
name in prom_timeseries
), f"Metric {name} not found. Available metrics: {list(prom_timeseries.keys())}"
return True
if wait:
wait_for_condition(
metric_available, retry_interval_ms=1000, timeout=timeout * 4
)
else:
# Fetch once without asserting - allows outer wait_for_condition to retry
fetch_prometheus_metric_timeseries(
[f"localhost:{TEST_METRICS_EXPORT_PORT}"],
timeseries,
timeout=PROMETHEUS_METRICS_TIMEOUT_S,
)
metric_dicts = []
for sample in timeseries.metric_samples.values():
if sample.name == name:
metric_dicts.append(sample.labels)
return metric_dicts