1724 lines
54 KiB
Python
1724 lines
54 KiB
Python
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
|