3305 lines
123 KiB
Python
3305 lines
123 KiB
Python
import asyncio
|
|
import concurrent.futures
|
|
import random
|
|
import sys
|
|
import threading
|
|
from collections import defaultdict
|
|
from contextlib import asynccontextmanager
|
|
from dataclasses import replace
|
|
from typing import Callable, Dict, List, Optional, Set, Tuple
|
|
from unittest.mock import Mock, patch
|
|
|
|
import grpc
|
|
import pytest
|
|
|
|
import ray
|
|
from ray._common.test_utils import async_wait_for_condition, wait_for_condition
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray.exceptions import (
|
|
ActorDiedError,
|
|
ActorUnavailableError,
|
|
RayTaskError,
|
|
TaskCancelledError,
|
|
)
|
|
from ray.serve._private.common import (
|
|
DeploymentHandleSource,
|
|
DeploymentID,
|
|
ReplicaID,
|
|
ReplicaQueueLengthInfo,
|
|
RequestMetadata,
|
|
RunningReplicaInfo,
|
|
)
|
|
from ray.serve._private.config import DeploymentConfig
|
|
from ray.serve._private.constants import (
|
|
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
|
|
RAY_SERVE_METRICS_EXPORT_INTERVAL_MS,
|
|
)
|
|
from ray.serve._private.replica import Replica as ServeReplica
|
|
from ray.serve._private.replica_result import ReplicaResult, gRPCReplicaResult
|
|
from ray.serve._private.request_router import (
|
|
PendingRequest,
|
|
RequestRouter,
|
|
RunningReplica,
|
|
)
|
|
from ray.serve._private.request_router.common import ReplicaQueueLengthCache
|
|
from ray.serve._private.request_router.replica_wrapper import ReplicaSelection
|
|
from ray.serve._private.router import (
|
|
QUEUED_REQUESTS_KEY,
|
|
AsyncioRouter,
|
|
CurrentLoopRouter,
|
|
RouterMetricsManager,
|
|
SingletonThreadRouter,
|
|
)
|
|
from ray.serve._private.test_utils import FakeCounter, FakeGauge, MockTimer
|
|
from ray.serve._private.utils import (
|
|
Semaphore,
|
|
decompress_metric_report,
|
|
get_random_string,
|
|
)
|
|
from ray.serve.config import AutoscalingConfig, RequestRouterConfig
|
|
from ray.serve.exceptions import (
|
|
BackPressureError,
|
|
DeploymentUnavailableError,
|
|
ReplicaUnavailableError,
|
|
)
|
|
|
|
|
|
class FakeReplicaResult(ReplicaResult):
|
|
def __init__(
|
|
self,
|
|
replica_id,
|
|
is_generator_object: bool,
|
|
queue_len_info: Optional[ReplicaQueueLengthInfo] = None,
|
|
):
|
|
self._replica_id = replica_id
|
|
self._is_generator_object = is_generator_object
|
|
self._queue_len_info = queue_len_info
|
|
self._done_callbacks: List[Callable] = []
|
|
self.cancelled = False
|
|
|
|
async def get_rejection_response(self):
|
|
return self._queue_len_info
|
|
|
|
def get(self, timeout_s: Optional[float]):
|
|
raise NotImplementedError
|
|
|
|
async def get_async(self):
|
|
raise NotImplementedError
|
|
|
|
def __next__(self):
|
|
raise NotImplementedError
|
|
|
|
async def __anext__(self):
|
|
raise NotImplementedError
|
|
|
|
def add_done_callback(self, callback: Callable):
|
|
self._done_callbacks.append(callback)
|
|
|
|
def fire_done_callbacks(self, result=None):
|
|
"""Simulate request completion by invoking all registered callbacks."""
|
|
for cb in self._done_callbacks:
|
|
cb(result)
|
|
self._done_callbacks.clear()
|
|
|
|
def cancel(self):
|
|
self.cancelled = True
|
|
|
|
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
|
|
raise NotImplementedError
|
|
|
|
async def to_object_ref_async(self) -> ray.ObjectRef:
|
|
raise NotImplementedError
|
|
|
|
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
|
|
raise NotImplementedError
|
|
|
|
|
|
class FakeReplica(RunningReplica):
|
|
def __init__(
|
|
self,
|
|
replica_id: ReplicaID,
|
|
*,
|
|
queue_len_info: Optional[ReplicaQueueLengthInfo] = None,
|
|
is_cross_language: bool = False,
|
|
error: Optional[Exception] = None,
|
|
node_id: str = "fake-node-id",
|
|
availability_zone: Optional[str] = None,
|
|
node_ip: str = "127.0.0.1",
|
|
port: Optional[int] = 8000,
|
|
actor_id: Optional[ray.ActorID] = None,
|
|
):
|
|
self._replica_id = replica_id
|
|
self._is_cross_language = is_cross_language
|
|
self._queue_len_info = queue_len_info
|
|
self._error = error
|
|
self._reserved_slots: Set[str] = set()
|
|
self._slot_counter = 0
|
|
self._requests_sent = [] # Track all requests sent to this replica
|
|
self._reject_reservation = False
|
|
self._reservation_queue_len = 1
|
|
self._release_slot_error: Optional[Exception] = None
|
|
|
|
# Create a minimal _replica_info object to satisfy router.py requirements
|
|
self._replica_info = Mock()
|
|
self._replica_info.node_id = node_id
|
|
self._replica_info.availability_zone = availability_zone
|
|
self._replica_info.node_ip = node_ip
|
|
self._replica_info.port = port
|
|
self._replica_info.replica_id = replica_id
|
|
self._actor_id = actor_id
|
|
|
|
@property
|
|
def replica_id(self) -> ReplicaID:
|
|
return self._replica_id
|
|
|
|
@property
|
|
def node_id(self) -> str:
|
|
return self._replica_info.node_id
|
|
|
|
@property
|
|
def availability_zone(self) -> Optional[str]:
|
|
return self._replica_info.availability_zone
|
|
|
|
@property
|
|
def actor_id(self) -> Optional[ray.ActorID]:
|
|
return self._actor_id
|
|
|
|
@property
|
|
def is_cross_language(self) -> bool:
|
|
return self._is_cross_language
|
|
|
|
def get_queue_len(self, *, deadline_s: float) -> int:
|
|
raise NotImplementedError
|
|
|
|
async def reserve_slot(
|
|
self, request_metadata: RequestMetadata
|
|
) -> Tuple[str, ReplicaQueueLengthInfo]:
|
|
"""Reserve a slot and return a token."""
|
|
if self._reject_reservation:
|
|
return "", ReplicaQueueLengthInfo(
|
|
accepted=False,
|
|
num_ongoing_requests=self._reservation_queue_len,
|
|
)
|
|
|
|
self._slot_counter += 1
|
|
token = f"slot-token-{self._slot_counter}"
|
|
self._reserved_slots.add(token)
|
|
return token, ReplicaQueueLengthInfo(
|
|
accepted=True,
|
|
num_ongoing_requests=len(self._reserved_slots),
|
|
)
|
|
|
|
async def release_slot(self, slot_token: str) -> int:
|
|
"""Release a reserved slot."""
|
|
if self._release_slot_error is not None:
|
|
raise self._release_slot_error
|
|
self._reserved_slots.discard(slot_token)
|
|
return len(self._reserved_slots)
|
|
|
|
def set_release_slot_error(self, error: Optional[Exception]) -> None:
|
|
"""Configure release_slot to raise the given error (or clear with None)."""
|
|
self._release_slot_error = error
|
|
|
|
def send_request_with_slot(
|
|
self, pr: PendingRequest, slot_token: str
|
|
) -> FakeReplicaResult:
|
|
"""Send request using a reserved slot."""
|
|
assert slot_token in self._reserved_slots, f"Invalid slot token: {slot_token}"
|
|
|
|
# Create result same way as try_send_request
|
|
if pr.metadata.is_streaming:
|
|
return FakeReplicaResult(self._replica_id, is_generator_object=True)
|
|
else:
|
|
return FakeReplicaResult(self._replica_id, is_generator_object=False)
|
|
|
|
def try_send_request(
|
|
self, pr: PendingRequest, with_rejection: bool
|
|
) -> FakeReplicaResult:
|
|
# Raise immediately if we're simulating send-time failure (e.g. ActorDiedError).
|
|
# Real replicas can raise here when with_rejection=False (e.g. broadcast path).
|
|
if self._error:
|
|
raise self._error
|
|
|
|
# Track the request
|
|
if pr.metadata._reserved_slot_token:
|
|
self._reserved_slots.discard(pr.metadata._reserved_slot_token)
|
|
|
|
self._requests_sent.append(
|
|
{
|
|
"request_id": pr.metadata.request_id,
|
|
"with_rejection": with_rejection,
|
|
}
|
|
)
|
|
|
|
if with_rejection:
|
|
assert (
|
|
not self.is_cross_language
|
|
), "Rejection not supported for cross language."
|
|
assert (
|
|
self._queue_len_info is not None
|
|
), "Must set queue_len_info to use `send_request_with_rejection`."
|
|
|
|
return FakeReplicaResult(
|
|
self._replica_id,
|
|
is_generator_object=True,
|
|
queue_len_info=self._queue_len_info,
|
|
)
|
|
else:
|
|
if pr.metadata.is_streaming:
|
|
return FakeReplicaResult(self._replica_id, is_generator_object=True)
|
|
else:
|
|
return FakeReplicaResult(self._replica_id, is_generator_object=False)
|
|
|
|
|
|
class FakeRequestRouter(RequestRouter):
|
|
def __init__(self, use_queue_len_cache: bool):
|
|
self._block_requests = False
|
|
self._blocked_requests: List[asyncio.Event] = []
|
|
self._replica_to_return: Optional[FakeReplica] = None
|
|
self._replica_to_return_on_retry: Optional[FakeReplica] = None
|
|
self._replica_queue_len_cache = ReplicaQueueLengthCache()
|
|
self._dropped_replicas: Set[ReplicaID] = set()
|
|
self._use_queue_len_cache = use_queue_len_cache
|
|
self._use_replica_queue_len_cache = use_queue_len_cache
|
|
self.on_request_routed_called = False
|
|
self.completed_requests: List[Tuple[ReplicaID, str]] = []
|
|
|
|
def create_replica_wrapper(self, replica_info: RunningReplicaInfo):
|
|
return FakeReplica(replica_info)
|
|
|
|
@property
|
|
def replica_queue_len_cache(self) -> ReplicaQueueLengthCache:
|
|
return self._replica_queue_len_cache
|
|
|
|
@property
|
|
def dropped_replicas(self) -> Set[ReplicaID]:
|
|
return self._dropped_replicas
|
|
|
|
@property
|
|
def curr_replicas(self) -> Dict[ReplicaID, RunningReplica]:
|
|
replicas = {}
|
|
if self._replica_to_return is not None:
|
|
replicas[self._replica_to_return.replica_id] = self._replica_to_return
|
|
if self._replica_to_return_on_retry is not None:
|
|
replicas[
|
|
self._replica_to_return_on_retry.replica_id
|
|
] = self._replica_to_return_on_retry
|
|
|
|
return replicas
|
|
|
|
def update_replicas(self, replicas: List[RunningReplica]):
|
|
pass
|
|
|
|
def on_replica_actor_died(self, replica_id: ReplicaID):
|
|
self._dropped_replicas.add(replica_id)
|
|
|
|
def on_new_queue_len_info(self, replica_id: ReplicaID, num_ongoing_requests: int):
|
|
if self._use_queue_len_cache:
|
|
self._replica_queue_len_cache.update(replica_id, num_ongoing_requests)
|
|
|
|
def on_send_request(self, replica_id: ReplicaID):
|
|
if self._use_queue_len_cache:
|
|
num_ongoing_requests = self._replica_queue_len_cache.get(replica_id) or 0
|
|
self._replica_queue_len_cache.update(replica_id, num_ongoing_requests + 1)
|
|
|
|
def on_replica_actor_unavailable(self, replica_id: ReplicaID):
|
|
self._replica_queue_len_cache.invalidate_key(replica_id)
|
|
|
|
def set_should_block_requests(self, block_requests: bool):
|
|
self._block_requests = block_requests
|
|
|
|
def set_replica_to_return(self, replica: FakeReplica):
|
|
self._replica_to_return = replica
|
|
|
|
def set_replica_to_return_on_retry(self, replica: FakeReplica):
|
|
self._replica_to_return_on_retry = replica
|
|
|
|
def unblock_requests(self, num: int):
|
|
assert self._block_requests and len(self._blocked_requests) >= num
|
|
for _ in range(num):
|
|
self._blocked_requests.pop(0).set()
|
|
|
|
async def _choose_replica_for_request(
|
|
self, pr: PendingRequest, *, is_retry: bool = False
|
|
) -> FakeReplica:
|
|
if self._block_requests:
|
|
event = asyncio.Event()
|
|
self._blocked_requests.append(event)
|
|
await event.wait()
|
|
|
|
if is_retry:
|
|
assert (
|
|
self._replica_to_return_on_retry is not None
|
|
), "Set a replica to return on retry."
|
|
replica = self._replica_to_return_on_retry
|
|
else:
|
|
assert self._replica_to_return is not None, "Set a replica to return."
|
|
replica = self._replica_to_return
|
|
|
|
return replica
|
|
|
|
async def choose_replicas(
|
|
self,
|
|
candidate_replicas: List[RunningReplica],
|
|
pending_request: Optional[PendingRequest] = None,
|
|
) -> List[List[RunningReplica]]:
|
|
pass
|
|
|
|
def on_request_routed(
|
|
self,
|
|
pending_request: PendingRequest,
|
|
replica_id: ReplicaID,
|
|
result: ReplicaResult,
|
|
) -> None:
|
|
self.on_request_routed_called = True
|
|
|
|
def on_request_completed(
|
|
self,
|
|
replica_id: ReplicaID,
|
|
internal_request_id: str,
|
|
) -> None:
|
|
self.completed_requests.append((replica_id, internal_request_id))
|
|
|
|
|
|
@pytest.fixture
|
|
async def setup_router(request) -> Tuple[AsyncioRouter, FakeRequestRouter]:
|
|
if not hasattr(request, "param"):
|
|
request.param = {}
|
|
|
|
fake_request_router = FakeRequestRouter(
|
|
request.param.get("enable_queue_len_cache", False)
|
|
)
|
|
router = AsyncioRouter(
|
|
# TODO(edoakes): refactor to make a better fake controller or not depend on it.
|
|
controller_handle=Mock(),
|
|
deployment_id=DeploymentID(name="test-deployment"),
|
|
handle_id="test-handle-id",
|
|
self_actor_id="test-node-id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
event_loop=get_or_create_event_loop(),
|
|
enable_strict_max_ongoing_requests=request.param.get(
|
|
"enable_strict_max_ongoing_requests", False
|
|
),
|
|
request_router=fake_request_router,
|
|
node_id="test-node-id",
|
|
availability_zone="test-az",
|
|
prefer_local_node_routing=False,
|
|
_request_router_initialized_event=asyncio.Event(),
|
|
)
|
|
yield router, fake_request_router
|
|
await router.shutdown()
|
|
|
|
|
|
def dummy_request_metadata(is_streaming: bool = False) -> RequestMetadata:
|
|
return RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
is_streaming=is_streaming,
|
|
)
|
|
|
|
|
|
class FakeReplicaMetricsManager:
|
|
def __init__(self):
|
|
self.num_ongoing_requests = 0
|
|
|
|
def get_num_ongoing_requests(self) -> int:
|
|
return self.num_ongoing_requests
|
|
|
|
def inc_num_ongoing_requests(self, request_metadata: RequestMetadata):
|
|
self.num_ongoing_requests += 1
|
|
|
|
def dec_num_ongoing_requests(self, request_metadata: RequestMetadata):
|
|
self.num_ongoing_requests -= 1
|
|
|
|
|
|
class FakeServeReplicaForSlotReservation(ServeReplica):
|
|
def __init__(
|
|
self,
|
|
*,
|
|
max_ongoing_requests: int = 1,
|
|
graceful_shutdown_wait_loop_s: float = 0.01,
|
|
):
|
|
self._deployment_config = Mock(
|
|
max_ongoing_requests=max_ongoing_requests,
|
|
graceful_shutdown_wait_loop_s=graceful_shutdown_wait_loop_s,
|
|
)
|
|
self._metrics_manager = FakeReplicaMetricsManager()
|
|
self._reserved_slots = set()
|
|
self._semaphore = Semaphore(lambda: self.max_ongoing_requests)
|
|
# The real __init__ is bypassed; set the quiesce flag read by
|
|
# _can_accept_request (reservations are rejected once quiescing).
|
|
self._quiescing = False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestReplicaSlotReservation:
|
|
async def test_reserved_slot_counts_against_replica_capacity(self):
|
|
# A reserved slot consumes capacity, so another reservation should be
|
|
# rejected once the replica reaches max_ongoing_requests.
|
|
replica = FakeServeReplicaForSlotReservation()
|
|
|
|
request_metadata = dummy_request_metadata()
|
|
|
|
slot_token = "slot-token-1"
|
|
accepted, queue_len = await replica.reserve_slot(request_metadata, slot_token)
|
|
assert accepted
|
|
assert slot_token in replica._reserved_slots
|
|
assert queue_len == 1
|
|
assert replica.get_num_ongoing_requests() == 1
|
|
|
|
accepted, queue_len = await replica.reserve_slot(
|
|
request_metadata, "slot-token-2"
|
|
)
|
|
assert not accepted
|
|
assert queue_len == 1
|
|
|
|
dispatch_metadata = replace(request_metadata, _reserved_slot_token=slot_token)
|
|
async with replica._start_request(dispatch_metadata):
|
|
assert slot_token not in replica._reserved_slots
|
|
assert replica.get_num_ongoing_requests() == 1
|
|
|
|
assert replica.get_num_ongoing_requests() == 0
|
|
|
|
async def test_release_slot_returns_status_and_recovers_capacity(self):
|
|
# Releasing a known token reports True and frees a slot; releasing an
|
|
# unknown or already-released token reports False and is a no-op.
|
|
replica = FakeServeReplicaForSlotReservation()
|
|
|
|
request_metadata = dummy_request_metadata()
|
|
accepted, _ = await replica.reserve_slot(request_metadata, "tok-a")
|
|
assert accepted
|
|
assert replica.get_num_ongoing_requests() == 1
|
|
|
|
accepted, count = replica.release_slot("tok-a")
|
|
assert accepted
|
|
assert count == 0
|
|
assert replica.get_num_ongoing_requests() == 0
|
|
|
|
# Releasing the same token a second time is a no-op.
|
|
accepted, count = replica.release_slot("tok-a")
|
|
assert not accepted
|
|
assert count == 0
|
|
|
|
# Releasing a token that was never reserved is also a no-op.
|
|
accepted, count = replica.release_slot("never-reserved")
|
|
assert not accepted
|
|
assert count == 0
|
|
|
|
# Capacity is fully recovered: a fresh reservation succeeds.
|
|
accepted, _ = await replica.reserve_slot(request_metadata, "tok-b")
|
|
assert accepted
|
|
|
|
async def test_dispatch_with_unknown_slot_token_is_rejected(self):
|
|
# A request that arrives with a slot token nobody reserved (or that
|
|
# was already released) must be rejected loudly and must not
|
|
# consume capacity.
|
|
replica = FakeServeReplicaForSlotReservation()
|
|
|
|
bogus = replace(dummy_request_metadata(), _reserved_slot_token="never-reserved")
|
|
with pytest.raises(RuntimeError, match="unknown reserved slot"):
|
|
async with replica._start_request(bogus):
|
|
pass
|
|
|
|
assert replica.get_num_ongoing_requests() == 0
|
|
|
|
# The replica is still healthy: a normal request can still run.
|
|
async with replica._start_request(dummy_request_metadata()):
|
|
assert replica.get_num_ongoing_requests() == 1
|
|
assert replica.get_num_ongoing_requests() == 0
|
|
|
|
async def test_drain_waits_for_reserved_slots(self):
|
|
# A reserved-but-not-yet-dispatched slot is holding capacity, so a
|
|
# graceful shutdown must wait for it just like an in-flight request.
|
|
# Otherwise the replica can finish draining between reserve_slot and
|
|
# the dispatch, leaving the router to send the request to a dead replica.
|
|
replica = FakeServeReplicaForSlotReservation(graceful_shutdown_wait_loop_s=0.01)
|
|
|
|
request_metadata = dummy_request_metadata()
|
|
accepted, _ = await replica.reserve_slot(request_metadata, "slot-token-1")
|
|
assert accepted
|
|
|
|
drain_task = asyncio.create_task(replica._drain_ongoing_requests())
|
|
try:
|
|
await asyncio.sleep(0.1)
|
|
assert (
|
|
not drain_task.done()
|
|
), "drain should still be running while a slot is reserved"
|
|
|
|
replica.release_slot("slot-token-1")
|
|
await asyncio.wait_for(drain_task, timeout=1.0)
|
|
finally:
|
|
if not drain_task.done():
|
|
drain_task.cancel()
|
|
|
|
async def test_drain_honors_min_period_then_waits_for_ongoing(self):
|
|
# The minimum draining period keeps the replica alive (still serving) so
|
|
# external load balancers deregister it. The drain must not exit until the
|
|
# period has elapsed AND there are no ongoing requests -- so a request
|
|
# admitted during the period and still running keeps it alive, instead of
|
|
# being reset when the replica tears down.
|
|
replica = FakeServeReplicaForSlotReservation(graceful_shutdown_wait_loop_s=0.02)
|
|
min_period_s = 0.2
|
|
|
|
drain_task = asyncio.create_task(
|
|
replica._drain_ongoing_requests(min_draining_period_s=min_period_s)
|
|
)
|
|
try:
|
|
# Nothing in flight, but the minimum period has not elapsed yet.
|
|
await asyncio.sleep(0.1)
|
|
assert not drain_task.done(), "drain must honor the minimum period"
|
|
|
|
# A request becomes ongoing partway through the period.
|
|
replica._metrics_manager.num_ongoing_requests = 1
|
|
|
|
# The period elapses, but the request is still ongoing -> keep waiting.
|
|
await asyncio.sleep(0.2)
|
|
assert (
|
|
not drain_task.done()
|
|
), "drain must not exit while a request is still ongoing"
|
|
|
|
# Request finishes; only now may the drain complete.
|
|
replica._metrics_manager.num_ongoing_requests = 0
|
|
await asyncio.wait_for(drain_task, timeout=1.0)
|
|
finally:
|
|
if not drain_task.done():
|
|
drain_task.cancel()
|
|
|
|
async def test_direct_ingress_request_is_rejected(self):
|
|
replica = FakeServeReplicaForSlotReservation()
|
|
|
|
request_metadata = replace(dummy_request_metadata(), is_direct_ingress=True)
|
|
|
|
with pytest.raises(RuntimeError, match="direct-ingress"):
|
|
await replica.reserve_slot(request_metadata, "slot-token-1")
|
|
|
|
assert replica.get_num_ongoing_requests() == 0
|
|
|
|
|
|
class FakeRunningReplicaForSlotReservation(RunningReplica):
|
|
def __init__(self, actor_handle: Mock):
|
|
self._replica_info = Mock(is_cross_language=False)
|
|
self._actor_handle = actor_handle
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestRunningReplicaSlotReservation:
|
|
async def test_cancellation_releases_reserved_slot(self):
|
|
# Cancellation can arrive after the actor has already reserved the slot,
|
|
# so ray.cancel(obj_ref) is a no-op and release_slot.remote() is the
|
|
# only one that frees the leaked slot.
|
|
pending = asyncio.get_running_loop().create_future()
|
|
actor_handle = Mock()
|
|
actor_handle.reserve_slot.remote = Mock(return_value=pending)
|
|
actor_handle.release_slot.remote = Mock()
|
|
replica = FakeRunningReplicaForSlotReservation(actor_handle)
|
|
|
|
with patch("ray.cancel") as mock_ray_cancel:
|
|
task = asyncio.create_task(replica.reserve_slot(dummy_request_metadata()))
|
|
# Yield so the task reaches `await obj_ref` before we cancel.
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
mock_ray_cancel.assert_called_once_with(pending)
|
|
|
|
actor_handle.release_slot.remote.assert_called_once()
|
|
released_token = actor_handle.release_slot.remote.call_args[0][0]
|
|
reserved_token = actor_handle.reserve_slot.remote.call_args[0][1]
|
|
assert released_token == reserved_token
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
ActorUnavailableError(error_message="unavailable", actor_id=None),
|
|
ActorDiedError(),
|
|
RuntimeError("boom"),
|
|
],
|
|
)
|
|
async def test_exception_releases_reserved_slot(self, exc):
|
|
# If reserve_slot's reply fails (e.g. ActorUnavailableError),
|
|
# the actor may have already reserved the slot. Best-effort
|
|
# release_slot.remote() prevents the leak.
|
|
pending = asyncio.get_running_loop().create_future()
|
|
pending.set_exception(exc)
|
|
actor_handle = Mock()
|
|
actor_handle.reserve_slot.remote = Mock(return_value=pending)
|
|
actor_handle.release_slot.remote = Mock()
|
|
replica = FakeRunningReplicaForSlotReservation(actor_handle)
|
|
|
|
with pytest.raises(type(exc)):
|
|
await replica.reserve_slot(dummy_request_metadata())
|
|
|
|
actor_handle.release_slot.remote.assert_called_once()
|
|
released_token = actor_handle.release_slot.remote.call_args[0][0]
|
|
reserved_token = actor_handle.reserve_slot.remote.call_args[0][1]
|
|
assert released_token == reserved_token
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestBroadcast:
|
|
async def test_unavailable_fails_without_waiting_for_router_init(self):
|
|
fake_request_router = FakeRequestRouter(use_queue_len_cache=False)
|
|
router = AsyncioRouter(
|
|
controller_handle=Mock(),
|
|
deployment_id=DeploymentID(name="test-deployment"),
|
|
handle_id="test-handle-id",
|
|
self_actor_id="test-node-id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
event_loop=get_or_create_event_loop(),
|
|
enable_strict_max_ongoing_requests=False,
|
|
request_router=fake_request_router,
|
|
node_id="test-node-id",
|
|
availability_zone="test-az",
|
|
prefer_local_node_routing=False,
|
|
_request_router_initialized_event=asyncio.Event(),
|
|
)
|
|
# Simulate a router that has not finished initialization yet.
|
|
router._request_router_initialized.clear()
|
|
router._deployment_available = False
|
|
|
|
with pytest.raises(DeploymentUnavailableError):
|
|
await asyncio.wait_for(
|
|
router.broadcast(dummy_request_metadata()),
|
|
timeout=0.1,
|
|
)
|
|
|
|
async def test_skips_dead_replica_and_continues(self, setup_router):
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, error=ActorDiedError())
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(FakeReplica(r2_id))
|
|
|
|
results = await router.broadcast(dummy_request_metadata())
|
|
assert len(results) == 1
|
|
assert results[0]._replica_id == r2_id
|
|
assert r1_id in fake_request_router.dropped_replicas
|
|
|
|
async def test_all_replicas_dead_raises_unavailable(self, setup_router):
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, error=ActorDiedError())
|
|
)
|
|
|
|
with pytest.raises(DeploymentUnavailableError):
|
|
await router.broadcast(dummy_request_metadata())
|
|
|
|
|
|
class TestDeploymentBroadcastResponseLocalTesting:
|
|
"""Tests for DeploymentBroadcastResponse with loop=None (local testing mode)."""
|
|
|
|
def test_results_twice_after_error_does_not_raise_coroutine_reuse(self):
|
|
"""Calling results() twice when the coroutine raises should replay the
|
|
cached exception, not crash with 'cannot reuse already awaited coroutine'.
|
|
"""
|
|
from ray.serve.handle import DeploymentBroadcastResponse
|
|
|
|
async def failing_coro():
|
|
raise DeploymentUnavailableError("no replicas")
|
|
|
|
response = DeploymentBroadcastResponse(failing_coro(), loop=None)
|
|
|
|
with pytest.raises(DeploymentUnavailableError):
|
|
response.results()
|
|
|
|
# Second call must raise the same error, not RuntimeError about reuse.
|
|
with pytest.raises(DeploymentUnavailableError):
|
|
response.results()
|
|
|
|
def test_results_twice_after_success_returns_same_results(self):
|
|
"""Calling results() twice on a successful broadcast should return the
|
|
same cached results both times.
|
|
"""
|
|
from ray.serve.handle import DeploymentBroadcastResponse
|
|
|
|
fake_result = FakeReplicaResult(
|
|
replica_id=ReplicaID(
|
|
unique_id="r1", deployment_id=DeploymentID(name="test")
|
|
),
|
|
is_generator_object=False,
|
|
)
|
|
|
|
async def success_coro():
|
|
return [fake_result]
|
|
|
|
response = DeploymentBroadcastResponse(success_coro(), loop=None)
|
|
|
|
# First call succeeds and caches.
|
|
replica_results = response._fetch_replica_results_sync()
|
|
assert len(replica_results) == 1
|
|
|
|
# Second call returns the same cached list.
|
|
replica_results_2 = response._fetch_replica_results_sync()
|
|
assert replica_results_2 is replica_results
|
|
|
|
|
|
class FakeGrpcCallForRejection:
|
|
"""Minimal stand-in for a grpc.aio.Call whose connection attempt fails."""
|
|
|
|
def __init__(self, error: Exception):
|
|
self._error = error
|
|
|
|
async def wait_for_connection(self):
|
|
raise self._error
|
|
|
|
|
|
class FakeGrpcReplicaResultForRejection(gRPCReplicaResult):
|
|
"""Bypasses the real __init__ (context bookkeeping, background tasks);
|
|
sets only the attributes get_rejection_response() reads."""
|
|
|
|
def __init__(self, call, *, is_streaming: bool = False):
|
|
self._call = call
|
|
self._with_rejection = True
|
|
self._rejection_response = None
|
|
self._is_streaming = is_streaming
|
|
# ActorUnavailableError -> ActorID(actor_id) requires a valid 16-byte
|
|
# binary, so use a real random ActorID rather than an arbitrary value.
|
|
self._actor_id = ray.ActorID.from_random()
|
|
|
|
|
|
def _aio_rpc_error(
|
|
code: grpc.StatusCode, initial_metadata: Optional[grpc.aio.Metadata] = None
|
|
) -> grpc.aio.AioRpcError:
|
|
return grpc.aio.AioRpcError(
|
|
code=code,
|
|
initial_metadata=initial_metadata or grpc.aio.Metadata(),
|
|
trailing_metadata=grpc.aio.Metadata(),
|
|
details=code.name,
|
|
debug_error_string=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestGrpcRejectionResponseErrorMapping:
|
|
"""get_rejection_response() error mapping for dispatches that provably
|
|
never started executing (no `accepted` initial metadata received).
|
|
|
|
CANCELLED is what a replica's gRPC server returns for streams landing in
|
|
its `server.stop()` window during graceful shutdown (quiesce). Like
|
|
UNAVAILABLE, it must surface as ActorUnavailableError so the router
|
|
retries the request on another replica instead of failing it.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"code", [grpc.StatusCode.CANCELLED, grpc.StatusCode.UNAVAILABLE]
|
|
)
|
|
async def test_pre_accept_errors_map_to_actor_unavailable(self, code):
|
|
result = FakeGrpcReplicaResultForRejection(
|
|
FakeGrpcCallForRejection(_aio_rpc_error(code))
|
|
)
|
|
|
|
with pytest.raises(ActorUnavailableError):
|
|
await result.get_rejection_response()
|
|
|
|
async def test_other_codes_propagate_raw(self):
|
|
result = FakeGrpcReplicaResultForRejection(
|
|
FakeGrpcCallForRejection(_aio_rpc_error(grpc.StatusCode.INTERNAL))
|
|
)
|
|
|
|
with pytest.raises(grpc.aio.AioRpcError):
|
|
await result.get_rejection_response()
|
|
|
|
async def test_accepted_metadata_on_error_is_not_retried(self):
|
|
# A unary call that fails mid-execution carries `accepted=1` initial
|
|
# metadata in the error: the request DID start executing, so it must
|
|
# NOT be converted into a retryable ActorUnavailableError.
|
|
error = _aio_rpc_error(
|
|
grpc.StatusCode.CANCELLED,
|
|
initial_metadata=grpc.aio.Metadata(
|
|
("accepted", "1"), ("num_ongoing_requests", "3")
|
|
),
|
|
)
|
|
result = FakeGrpcReplicaResultForRejection(FakeGrpcCallForRejection(error))
|
|
|
|
info = await result.get_rejection_response()
|
|
assert info.accepted is True
|
|
assert info.num_ongoing_requests == 3
|
|
|
|
async def test_streaming_pre_accept_cancelled_maps_to_actor_unavailable(self):
|
|
# For streaming calls initial metadata is sent before execution, so
|
|
# an error during connection establishment always means the request
|
|
# never started; the unary metadata-recovery branch is skipped.
|
|
error = _aio_rpc_error(
|
|
grpc.StatusCode.CANCELLED,
|
|
initial_metadata=grpc.aio.Metadata(
|
|
("accepted", "1"), ("num_ongoing_requests", "3")
|
|
),
|
|
)
|
|
result = FakeGrpcReplicaResultForRejection(
|
|
FakeGrpcCallForRejection(error), is_streaming=True
|
|
)
|
|
|
|
with pytest.raises(ActorUnavailableError):
|
|
await result.get_rejection_response()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestAssignRequest:
|
|
@pytest.mark.parametrize("is_streaming", [False, True])
|
|
async def test_basic(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
is_streaming: bool,
|
|
):
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
is_streaming=is_streaming,
|
|
)
|
|
replica_result = await router.assign_request(request_metadata)
|
|
if is_streaming:
|
|
assert replica_result._is_generator_object
|
|
assert replica_result._replica_id == r1_id
|
|
else:
|
|
assert not replica_result._is_generator_object
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": False,
|
|
},
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.parametrize("is_streaming", [False, True])
|
|
async def test_basic_with_rejection(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
is_streaming: bool,
|
|
):
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(
|
|
r1_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=10
|
|
),
|
|
)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
is_streaming=is_streaming,
|
|
)
|
|
replica_result = await router.assign_request(request_metadata)
|
|
assert replica_result._is_generator_object
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
if router._request_router._use_queue_len_cache:
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 10
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": False,
|
|
},
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.parametrize("is_streaming", [False, True])
|
|
async def test_retry_with_rejection(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
is_streaming: bool,
|
|
):
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica1 = FakeReplica(
|
|
r1_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=False, num_ongoing_requests=10
|
|
),
|
|
)
|
|
fake_request_router.set_replica_to_return(replica1)
|
|
|
|
r2_id = ReplicaID(
|
|
unique_id="test-replica-2", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica2 = FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=20
|
|
),
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(replica2)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
is_streaming=is_streaming,
|
|
)
|
|
replica_result = await router.assign_request(request_metadata)
|
|
assert replica_result._is_generator_object
|
|
assert replica_result._replica_id == r2_id
|
|
|
|
if router._request_router._use_queue_len_cache:
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 10
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) == 20
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_strict_max_ongoing_requests": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_cross_lang_no_rejection(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id, is_cross_language=True)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
replica_result = await router.assign_request(request_metadata)
|
|
assert not replica_result._is_generator_object
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
async def test_max_queued_requests_no_limit(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
router, fake_request_router = setup_router
|
|
fake_request_router.set_should_block_requests(True)
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=-1))
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
# Queued a bunch of tasks. None should error because there's no limit.
|
|
assign_request_tasks = [
|
|
asyncio.ensure_future(router.assign_request(request_metadata))
|
|
for _ in range(100)
|
|
]
|
|
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
# Unblock the requests, now they should all get routed.
|
|
fake_request_router.unblock_requests(100)
|
|
assert all(
|
|
[
|
|
not replica_result._is_generator_object
|
|
and replica_result._replica_id == r1_id
|
|
for replica_result in await asyncio.gather(*assign_request_tasks)
|
|
]
|
|
)
|
|
|
|
async def test_max_queued_requests_limited(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
router, fake_request_router = setup_router
|
|
fake_request_router.set_should_block_requests(True)
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=5))
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
# Queued `max_queued_requests` tasks. None should fail.
|
|
assign_request_tasks = [
|
|
asyncio.ensure_future(router.assign_request(request_metadata))
|
|
for _ in range(5)
|
|
]
|
|
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
# Try to queue more tasks, they should fail immediately.
|
|
for _ in range(10):
|
|
with pytest.raises(BackPressureError):
|
|
await router.assign_request(request_metadata)
|
|
|
|
# Unblock a request.
|
|
fake_request_router.unblock_requests(1)
|
|
done, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(done) == 1
|
|
replica_result = await done.pop()
|
|
assert not replica_result._is_generator_object
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
# One more task should be allowed to be queued.
|
|
assign_request_tasks = list(pending) + [
|
|
asyncio.ensure_future(router.assign_request(request_metadata))
|
|
]
|
|
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
# Unblock the requests, now they should all get routed.
|
|
fake_request_router.unblock_requests(5)
|
|
assert all(
|
|
[
|
|
not replica_result._is_generator_object
|
|
and replica_result._replica_id == r1_id
|
|
for replica_result in await asyncio.gather(*assign_request_tasks)
|
|
]
|
|
)
|
|
|
|
async def test_max_queued_requests_updated(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
router, fake_request_router = setup_router
|
|
fake_request_router.set_should_block_requests(True)
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=5))
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
# Queued `max_queued_requests` tasks. None should fail.
|
|
assign_request_tasks = [
|
|
asyncio.ensure_future(router.assign_request(request_metadata))
|
|
for _ in range(5)
|
|
]
|
|
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
# Try to queue more tasks, they should fail immediately.
|
|
for _ in range(10):
|
|
with pytest.raises(BackPressureError):
|
|
await router.assign_request(request_metadata)
|
|
|
|
# Dynamically increase `max_queued_requests`, more tasks should be allowed to
|
|
# be queued.
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=10))
|
|
assign_request_tasks.extend(
|
|
[
|
|
asyncio.ensure_future(router.assign_request(request_metadata))
|
|
for _ in range(5)
|
|
]
|
|
)
|
|
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
# Try to queue more tasks, they should fail immediately.
|
|
for _ in range(10):
|
|
with pytest.raises(BackPressureError):
|
|
await router.assign_request(request_metadata)
|
|
|
|
# Dynamically decrease `max_queued_requests`, the existing tasks should remain
|
|
# queued but the limit should apply to new tasks.
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=5))
|
|
_, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == len(assign_request_tasks)
|
|
|
|
for _ in range(10):
|
|
with pytest.raises(BackPressureError):
|
|
await router.assign_request(request_metadata)
|
|
|
|
fake_request_router.unblock_requests(5)
|
|
done, pending = await asyncio.wait(assign_request_tasks, timeout=0.01)
|
|
assert len(pending) == 5
|
|
assert all(
|
|
[
|
|
not replica_result._is_generator_object
|
|
and replica_result._replica_id == r1_id
|
|
for replica_result in await asyncio.gather(*done)
|
|
]
|
|
)
|
|
assign_request_tasks = list(pending)
|
|
|
|
# Try to queue more tasks, they should fail immediately if the new limit is
|
|
# respected.
|
|
for _ in range(10):
|
|
with pytest.raises(BackPressureError):
|
|
await router.assign_request(request_metadata)
|
|
|
|
# Unblock the requests, now they should all get routed.
|
|
fake_request_router.unblock_requests(5)
|
|
assert all(
|
|
[
|
|
not replica_result._is_generator_object
|
|
and replica_result._replica_id == r1_id
|
|
for replica_result in await asyncio.gather(*assign_request_tasks)
|
|
]
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
async def test_replica_actor_died(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""ActorDiedError whose actor_id matches the replica → replica is dropped."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
error = ActorDiedError()
|
|
error.actor_id = r1_actor_id.hex()
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, error=error, actor_id=r1_actor_id)
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=5
|
|
),
|
|
)
|
|
)
|
|
await router.assign_request(dummy_request_metadata())
|
|
assert r1_id in fake_request_router.dropped_replicas
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
async def test_replica_actor_died_mismatched_actor_id(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""ActorDiedError whose actor_id does NOT match the replica → replica is NOT
|
|
dropped (the error came from an upstream dependency, not this replica)."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
# r1 has one actor_id but the error reports a different actor_id (upstream).
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
upstream_actor_id = ray.ActorID.from_random()
|
|
error = ActorDiedError()
|
|
error.actor_id = upstream_actor_id.hex()
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, error=error, actor_id=r1_actor_id)
|
|
)
|
|
# r2 handles the retry after r1 fails.
|
|
fake_request_router.set_replica_to_return_on_retry(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=5
|
|
),
|
|
)
|
|
)
|
|
await router.assign_request(dummy_request_metadata())
|
|
# r1 should NOT be dropped because the error came from a different actor.
|
|
assert r1_id not in fake_request_router.dropped_replicas
|
|
|
|
async def test_actor_died_matching_id_via_callback(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Done-callback with ActorDiedError whose actor_id matches the replica →
|
|
replica is dropped via _process_finished_request."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, actor_id=r1_actor_id)
|
|
)
|
|
|
|
result = await router.assign_request(dummy_request_metadata())
|
|
|
|
# Simulate the response finishing with an ActorDiedError from this replica.
|
|
error = ActorDiedError()
|
|
error.actor_id = r1_actor_id.hex()
|
|
result.fire_done_callbacks(result=error)
|
|
await asyncio.sleep(0)
|
|
|
|
assert r1_id in fake_request_router.dropped_replicas
|
|
|
|
async def test_actor_died_mismatched_id_via_callback(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Done-callback with ActorDiedError whose actor_id does NOT match the
|
|
replica → replica is NOT dropped (upstream dependency failure)."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
upstream_actor_id = ray.ActorID.from_random()
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, actor_id=r1_actor_id)
|
|
)
|
|
|
|
result = await router.assign_request(dummy_request_metadata())
|
|
|
|
# Simulate the response finishing with an ActorDiedError from a different actor.
|
|
error = ActorDiedError()
|
|
error.actor_id = upstream_actor_id.hex()
|
|
result.fire_done_callbacks(result=error)
|
|
await asyncio.sleep(0)
|
|
|
|
assert r1_id not in fake_request_router.dropped_replicas
|
|
|
|
async def test_actor_died_none_actor_id_via_callback(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Done-callback with ActorDiedError(actor_id=None) and replica has valid
|
|
actor_id → replica IS dropped (conservative fallback when ID unknown)."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, actor_id=r1_actor_id)
|
|
)
|
|
|
|
result = await router.assign_request(dummy_request_metadata())
|
|
|
|
# ActorDiedError without actor_id (e.g., constructed without
|
|
# ActorDiedErrorContext). Conservative fallback: mark replica dead.
|
|
error = ActorDiedError()
|
|
error.actor_id = None # Explicitly None
|
|
result.fire_done_callbacks(result=error)
|
|
await asyncio.sleep(0)
|
|
|
|
assert r1_id in fake_request_router.dropped_replicas
|
|
|
|
async def test_ray_task_error_wrapping_actor_died_matching_id_via_callback(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Done-callback with RayTaskError(cause=ActorDiedError) where actor_id
|
|
matches the replica → replica is dropped."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, actor_id=r1_actor_id)
|
|
)
|
|
|
|
result = await router.assign_request(dummy_request_metadata())
|
|
|
|
actor_died = ActorDiedError()
|
|
actor_died.actor_id = r1_actor_id.hex()
|
|
wrapped = RayTaskError(
|
|
function_name="test_func",
|
|
traceback_str="",
|
|
cause=actor_died,
|
|
proctitle="test",
|
|
pid=12345,
|
|
ip="127.0.0.1",
|
|
)
|
|
result.fire_done_callbacks(result=wrapped)
|
|
await asyncio.sleep(0)
|
|
|
|
assert r1_id in fake_request_router.dropped_replicas
|
|
|
|
async def test_ray_task_error_wrapping_actor_died_mismatched_id_via_callback(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Done-callback with RayTaskError(cause=ActorDiedError) where actor_id does
|
|
NOT match the replica → replica is NOT dropped (upstream failure)."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
|
|
r1_actor_id = ray.ActorID.from_random()
|
|
upstream_actor_id = ray.ActorID.from_random()
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, actor_id=r1_actor_id)
|
|
)
|
|
|
|
result = await router.assign_request(dummy_request_metadata())
|
|
|
|
actor_died = ActorDiedError()
|
|
actor_died.actor_id = upstream_actor_id.hex()
|
|
wrapped = RayTaskError(
|
|
function_name="test_func",
|
|
traceback_str="",
|
|
cause=actor_died,
|
|
proctitle="test",
|
|
pid=12345,
|
|
ip="127.0.0.1",
|
|
)
|
|
result.fire_done_callbacks(result=wrapped)
|
|
await asyncio.sleep(0)
|
|
|
|
assert r1_id not in fake_request_router.dropped_replicas
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
"enable_queue_len_cache": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
async def test_replica_actor_unavailable(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
router, fake_request_router = setup_router
|
|
# Two replicas
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
# First request is sent to r1, cache should be populated with r1:5
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(
|
|
r1_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=5
|
|
),
|
|
)
|
|
)
|
|
replica_result = await router.assign_request(dummy_request_metadata())
|
|
assert replica_result._replica_id == r1_id
|
|
# Cache should have R1:5
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 5
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) is None
|
|
|
|
# Second request is sent to r2, cache should be populated with r2:10
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=10
|
|
),
|
|
)
|
|
)
|
|
replica_result = await router.assign_request(dummy_request_metadata())
|
|
assert replica_result._replica_id == r2_id
|
|
# Cache should have R1:5, R2:10
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 5
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) == 10
|
|
|
|
# Third request is sent to r1 again, but system message yields
|
|
# an ActorUnavailableError
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(
|
|
r1_id,
|
|
error=ActorUnavailableError(error_message="unavailable", actor_id=None),
|
|
)
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=15
|
|
),
|
|
)
|
|
)
|
|
await router.assign_request(dummy_request_metadata())
|
|
# R1 should be REMOVED from cache, cache should now be R2:15
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) is None
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) == 15
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[
|
|
{
|
|
"enable_strict_max_ongoing_requests": True,
|
|
},
|
|
],
|
|
indirect=True,
|
|
)
|
|
@pytest.mark.parametrize("is_streaming", [False, True])
|
|
async def test_on_request_routed(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
is_streaming: bool,
|
|
):
|
|
"""Test that on_request_routed is called when a request is routed."""
|
|
router, fake_request_router = setup_router
|
|
|
|
# Before the request is routed, on_request_routed_called should be False.
|
|
assert fake_request_router.on_request_routed_called is False
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(
|
|
r1_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=1
|
|
),
|
|
)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
replica_result = await router.assign_request(dummy_request_metadata())
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
# After the request is routed, on_request_routed_called should be False.
|
|
assert fake_request_router.on_request_routed_called is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestChooseReplica:
|
|
"""Tests for choose_replica() and dispatch() flow."""
|
|
|
|
async def test_choose_replica_raises_deployment_unavailable(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""choose_replica fails when deployment is marked unavailable."""
|
|
router, _ = setup_router
|
|
router._deployment_available = False
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
with pytest.raises(DeploymentUnavailableError):
|
|
async with router.choose_replica(request_metadata):
|
|
pass
|
|
|
|
async def test_choose_replica_raises_backpressure(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""choose_replica raises BackPressureError when queue limit is reached."""
|
|
router, _ = setup_router
|
|
router.update_deployment_config(DeploymentConfig(max_queued_requests=1))
|
|
# Bump num_queued_requests to 1 to simulate a full queue
|
|
router._metrics_manager.inc_num_queued_requests()
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
with pytest.raises(BackPressureError):
|
|
async with router.choose_replica(request_metadata):
|
|
pass
|
|
|
|
async def test_choose_without_dispatch_releases_slot(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test that exiting context without dispatch releases the slot."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
slot_token = None
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
slot_token = selection._slot_token
|
|
assert slot_token in replica._reserved_slots
|
|
# Exit without calling dispatch
|
|
|
|
# After context exit, slot should be released
|
|
assert slot_token not in replica._reserved_slots
|
|
|
|
async def test_choose_without_dispatch_does_not_fire_on_request_completed(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""on_request_completed shouldn't fire if the caller exits without dispatching."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
fake_request_router.set_replica_to_return(FakeReplica(r1_id))
|
|
|
|
async with router.choose_replica(dummy_request_metadata()):
|
|
pass # Exit without dispatch.
|
|
|
|
assert fake_request_router.on_request_routed_called is False
|
|
assert fake_request_router.completed_requests == []
|
|
|
|
async def test_choose_with_exception_releases_slot(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test that exception in context releases the slot."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
slot_token = None
|
|
with pytest.raises(RuntimeError):
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
slot_token = selection._slot_token
|
|
assert slot_token in replica._reserved_slots
|
|
raise RuntimeError("Test exception")
|
|
|
|
# After exception, slot should be released
|
|
assert slot_token not in replica._reserved_slots
|
|
|
|
@pytest.mark.parametrize("is_streaming", [False, True])
|
|
async def test_choose_and_dispatch(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
is_streaming: bool,
|
|
):
|
|
"""Happy path: selection fields are populated, dispatch sends with
|
|
with_rejection=False (slot reservation replaces the rejection
|
|
round-trip), and the slot is released on exit."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
call_method="test_method",
|
|
is_streaming=is_streaming,
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
assert selection.replica_id == r1_id.unique_id
|
|
assert selection._replica == replica
|
|
assert selection._method_name == "test_method"
|
|
assert selection._slot_token in replica._reserved_slots
|
|
|
|
replica_result = await router.dispatch(selection, request_metadata)
|
|
assert replica_result._replica_id == r1_id
|
|
assert replica_result._is_generator_object == is_streaming
|
|
|
|
assert selection._slot_token not in replica._reserved_slots
|
|
assert len(replica._requests_sent) == 1
|
|
assert replica._requests_sent[0]["with_rejection"] is False
|
|
|
|
async def test_multiple_sequential_selections(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test multiple sequential choose_replica() and dispatch() calls."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
for i in range(3):
|
|
request_metadata = RequestMetadata(
|
|
request_id=f"test-request-{i}",
|
|
internal_request_id=f"test-internal-request-{i}",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
replica_result = await router.dispatch(selection, request_metadata)
|
|
assert replica_result._replica_id == r1_id
|
|
|
|
# All slots should have been created and used
|
|
assert replica._slot_counter == 3
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_cache_updated_on_choose_replica(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test that queue length cache is updated when choosing a replica."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
# Initially cache should be empty
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) is None
|
|
|
|
# Choose replica should update cache to 1
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
# Dispatch should NOT increment again (already counted in choose)
|
|
await router.dispatch(selection, request_metadata)
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
# After dispatch, cache remains incremented while the request is in flight.
|
|
# It is decremented when request completion is observed.
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_current_loop_dispatch_marks_selection_before_task_runs(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Wrapper dispatch should consume the selection before its task runs."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
current_loop_router = CurrentLoopRouter.__new__(CurrentLoopRouter)
|
|
current_loop_router._asyncio_loop = asyncio.get_running_loop()
|
|
current_loop_router._asyncio_router = router
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
dispatch_task = current_loop_router.dispatch(selection, request_metadata)
|
|
assert selection._dispatched
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
assert len(replica._requests_sent) == 0
|
|
|
|
replica_result = await dispatch_task
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
replica_result.fire_done_callbacks()
|
|
await asyncio.sleep(0)
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_current_loop_dispatch_failure_releases_cache(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""If wrapper dispatch fails after consuming a selection, it owns cleanup."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
current_loop_router = CurrentLoopRouter.__new__(CurrentLoopRouter)
|
|
current_loop_router._asyncio_loop = asyncio.get_running_loop()
|
|
current_loop_router._asyncio_router = router
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
fake_request_router._replica_to_return = None
|
|
dispatch_task = current_loop_router.dispatch(selection, request_metadata)
|
|
assert selection._dispatched
|
|
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
|
|
with pytest.raises(ReplicaUnavailableError):
|
|
await dispatch_task
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_cache_decremented_on_choose_without_dispatch(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test that cache is decremented when choose exits without dispatch."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
# Choose replica without dispatch
|
|
async with router.choose_replica(request_metadata):
|
|
# Cache should be 1 (reservation)
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
# Exit without calling dispatch
|
|
|
|
# After context exit without dispatch, cache should be decremented to 0
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_choose_replica_retries_when_reservation_rejected(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""choose_replica should only yield after replica-side capacity is reserved."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
r2_id = ReplicaID(
|
|
unique_id="test-replica-2", deployment_id=DeploymentID(name="test")
|
|
)
|
|
r1 = FakeReplica(r1_id)
|
|
r2 = FakeReplica(r2_id)
|
|
r1._reject_reservation = True
|
|
fake_request_router.set_replica_to_return(r1)
|
|
fake_request_router.set_replica_to_return_on_retry(r2)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
assert selection._replica == r2
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) == 1
|
|
|
|
assert fake_request_router.replica_queue_len_cache.get(r2_id) == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_concurrent_choose_replica_updates_cache(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test that concurrent choose_replica calls correctly update cache."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
# Start 3 concurrent choose_replica operations
|
|
async def choose_and_hold(request_id: str):
|
|
metadata = RequestMetadata(
|
|
request_id=request_id,
|
|
internal_request_id=request_id,
|
|
)
|
|
async with router.choose_replica(metadata) as selection:
|
|
# Hold the selection
|
|
await asyncio.sleep(0.01)
|
|
return selection
|
|
|
|
# Create tasks
|
|
tasks = [asyncio.create_task(choose_and_hold(f"request-{i}")) for i in range(3)]
|
|
|
|
# Wait a bit for all to enter context
|
|
await asyncio.sleep(0.005)
|
|
|
|
# Cache should reflect all 3 reservations
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 3
|
|
|
|
# Let them all exit
|
|
await asyncio.gather(*tasks)
|
|
|
|
# After all exit without dispatch, cache should be 0
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 0
|
|
|
|
async def test_reserved_slots_gauge_increments_and_decrements(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Gauge goes 0 → 1 → 0 across choose+dispatch and choose-without-dispatch.
|
|
|
|
Asserts the underlying `.set()` calls too, so bumping the counter
|
|
without pushing to the gauge is caught.
|
|
"""
|
|
router, fake_request_router = setup_router
|
|
|
|
gauge_mock = Mock()
|
|
router._metrics_manager._reserved_slots_gauge = gauge_mock
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
# Choose + dispatch path.
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
assert router._metrics_manager._num_reserved_slots == 0
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
assert router._metrics_manager._num_reserved_slots == 1
|
|
gauge_mock.set.assert_called_with(1)
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
assert router._metrics_manager._num_reserved_slots == 0
|
|
gauge_mock.set.assert_called_with(0)
|
|
|
|
# Choose without dispatch: same gauge transition must hold.
|
|
gauge_mock.reset_mock()
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-2",
|
|
internal_request_id="test-internal-request-2",
|
|
)
|
|
async with router.choose_replica(request_metadata):
|
|
assert router._metrics_manager._num_reserved_slots == 1
|
|
gauge_mock.set.assert_called_with(1)
|
|
|
|
assert router._metrics_manager._num_reserved_slots == 0
|
|
gauge_mock.set.assert_called_with(0)
|
|
|
|
async def test_release_failure_does_not_leak_reserved_slots_metric(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""If the release call raises during choose_replica cleanup, the
|
|
reserved-slots gauge must still decrement — otherwise it leaks for the
|
|
process lifetime — and the exception must not propagate to the caller.
|
|
"""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
replica.set_release_slot_error(RuntimeError("simulated release failure"))
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
before = router._metrics_manager._num_reserved_slots
|
|
async with router.choose_replica(request_metadata):
|
|
assert router._metrics_manager._num_reserved_slots == before + 1
|
|
# Exit ran cleanly even though release raised.
|
|
assert router._metrics_manager._num_reserved_slots == before
|
|
|
|
async def test_inc_reserved_slots_failure_releases_slot(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""If gauge.set() throws during inc, cleanup must still release the
|
|
slot and rebalance the counter."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
# Raise only on inc's set(1); the cleanup's set(0) must still run.
|
|
gauge_mock = Mock()
|
|
gauge_mock.set = Mock(
|
|
side_effect=lambda v: (_ for _ in ()).throw(RuntimeError("gauge boom"))
|
|
if v > 0
|
|
else None
|
|
)
|
|
router._metrics_manager._reserved_slots_gauge = gauge_mock
|
|
|
|
with pytest.raises(RuntimeError, match="gauge boom"):
|
|
async with router.choose_replica(dummy_request_metadata()):
|
|
pass
|
|
|
|
assert replica._reserved_slots == set()
|
|
assert router._metrics_manager._num_reserved_slots == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"callback_result,expect_release",
|
|
[
|
|
pytest.param(None, False, id="actor_success"),
|
|
pytest.param(TaskCancelledError(), True, id="actor_cancellation"),
|
|
pytest.param(
|
|
Mock(cancelled=Mock(return_value=False)),
|
|
False,
|
|
id="grpc_success",
|
|
),
|
|
pytest.param(
|
|
Mock(cancelled=Mock(return_value=True)),
|
|
True,
|
|
id="grpc_cancellation",
|
|
),
|
|
],
|
|
)
|
|
async def test_dispatch_release_slot_fires_only_on_cancellation(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
callback_result,
|
|
expect_release,
|
|
):
|
|
"""Done-callback fires release_slot only when the dispatched
|
|
request was cancelled. On success the replica already consumed the
|
|
slot when try_send_request arrived, so a follow-up RPC would be a
|
|
no-op per dispatched request. Covers both transports."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
release_slot_calls: List[str] = []
|
|
original_release_slot = replica.release_slot
|
|
|
|
async def spy_release_slot(slot_token):
|
|
release_slot_calls.append(slot_token)
|
|
return await original_release_slot(slot_token)
|
|
|
|
replica.release_slot = spy_release_slot
|
|
|
|
request_metadata = dummy_request_metadata()
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
slot_token = selection._slot_token
|
|
result = await router.dispatch(selection, request_metadata)
|
|
|
|
# try_send_request consumed the slot synchronously; the done-callback
|
|
# hasn't fired yet, so no release_slot call should have happened.
|
|
assert release_slot_calls == []
|
|
|
|
result.fire_done_callbacks(result=callback_result)
|
|
# Drain the call_soon_threadsafe → create_task chain.
|
|
for _ in range(2):
|
|
await asyncio.sleep(0)
|
|
|
|
assert release_slot_calls == ([slot_token] if expect_release else [])
|
|
|
|
async def test_dispatch_failure_is_not_masked_by_release_failure(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""If both `try_send_request` and the cleanup `release_slot` raise, the
|
|
caller must see the original dispatch error, not the release error.
|
|
"""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
dispatch_error = RuntimeError("dispatch boom")
|
|
replica = FakeReplica(r1_id, error=dispatch_error)
|
|
replica.set_release_slot_error(RuntimeError("release boom"))
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
with pytest.raises(RuntimeError, match="dispatch boom"):
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
async def test_dispatch_replica_unavailable(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""Test dispatch() raises error when replica becomes unavailable."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
# Simulate replica becoming unavailable
|
|
fake_request_router._replica_to_return = None
|
|
|
|
# dispatch should raise ReplicaUnavailableError
|
|
with pytest.raises(ReplicaUnavailableError):
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
async def test_multiple_dispatch_calls_fail(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""A ReplicaSelection can only be dispatched once."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with router.choose_replica(request_metadata) as selection:
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
with pytest.raises(RuntimeError, match="already been dispatched"):
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
assert len(replica._requests_sent) == 1
|
|
|
|
async def test_choose_replica_no_reserve_skips_slot_reservation(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
):
|
|
"""``_reserve=False`` yields a selection with no slot token, bypasses
|
|
``reserve_slot`` on the replica, and ``dispatch`` on that selection
|
|
raises since there's no reservation to consume."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router._replicas_list = [replica]
|
|
|
|
async def fake_choose_replicas(candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
fake_request_router.choose_replicas = fake_choose_replicas
|
|
|
|
request_metadata = dummy_request_metadata()
|
|
|
|
async with router.choose_replica(request_metadata, _reserve=False) as selection:
|
|
assert selection._replica is replica
|
|
assert selection._slot_token is None
|
|
assert replica._reserved_slots == set()
|
|
|
|
with pytest.raises(RuntimeError, match="_reserve=False"):
|
|
await router.dispatch(selection, request_metadata)
|
|
|
|
assert replica._reserved_slots == set()
|
|
assert replica._slot_counter == 0
|
|
|
|
|
|
def running_replica_info(replica_id: ReplicaID) -> RunningReplicaInfo:
|
|
return RunningReplicaInfo(
|
|
replica_id=replica_id,
|
|
node_id="node_id",
|
|
node_ip="node_ip",
|
|
availability_zone="some-az",
|
|
actor_name=replica_id.to_full_id_str(),
|
|
max_ongoing_requests=1,
|
|
)
|
|
|
|
|
|
class TestRouterMetricsManager:
|
|
@pytest.mark.asyncio
|
|
async def test_num_router_requests(self):
|
|
tags = {
|
|
"deployment": "a",
|
|
"application": "b",
|
|
"route": "/alice",
|
|
"handle": "random_handle",
|
|
"actor_id": "random_actor",
|
|
}
|
|
metrics_manager = RouterMetricsManager(
|
|
DeploymentID(name="a", app_name="b"),
|
|
"random_handle",
|
|
"random_actor",
|
|
DeploymentHandleSource.UNKNOWN,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=("deployment", "route", "application", "handle", "actor_id")
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
assert metrics_manager.num_router_requests.get_count(tags) is None
|
|
|
|
n = random.randint(1, 10)
|
|
for _ in range(n):
|
|
metrics_manager.inc_num_total_requests(route="/alice")
|
|
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
assert metrics_manager.num_router_requests.get_count(tags) == n
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_num_queued_requests_gauge(self):
|
|
tags = {
|
|
"deployment": "a",
|
|
"application": "b",
|
|
"handle": "random_handle",
|
|
"actor_id": "random_actor",
|
|
}
|
|
metrics_manager = RouterMetricsManager(
|
|
DeploymentID(name="a", app_name="b"),
|
|
"random_handle",
|
|
"random_actor",
|
|
DeploymentHandleSource.UNKNOWN,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=("deployment", "route", "application", "handle", "actor_id")
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
assert metrics_manager.num_queued_requests_gauge.get_value(tags) == 0
|
|
|
|
n, m = random.randint(0, 10), random.randint(0, 5)
|
|
for _ in range(n):
|
|
metrics_manager.inc_num_queued_requests()
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
assert metrics_manager.num_queued_requests_gauge.get_value(tags) == n
|
|
for _ in range(m):
|
|
metrics_manager.dec_num_queued_requests()
|
|
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
assert metrics_manager.num_queued_requests_gauge.get_value(tags) == n - m
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_track_requests_sent_to_replicas(self):
|
|
d_id = DeploymentID(name="a", app_name="b")
|
|
metrics_manager = RouterMetricsManager(
|
|
d_id,
|
|
"random",
|
|
"random_actor",
|
|
DeploymentHandleSource.UNKNOWN,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=("deployment", "route", "application", "handle", "actor_id")
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
|
|
# r1: number requests -> 0, removed from list of running replicas -> prune
|
|
# r2: number requests -> 0, remains on list of running replicas -> don't prune
|
|
# r3: number requests > 0, removed from list of running replicas -> don't prune
|
|
# r4: number requests > 0, remains on list of running replicas -> don't prune
|
|
replica_ids = [
|
|
ReplicaID(unique_id=f"test-replica-{i}", deployment_id=d_id)
|
|
for i in range(1, 5)
|
|
]
|
|
r1, r2, r3, r4 = replica_ids
|
|
|
|
# ri has i requests
|
|
for i in range(4):
|
|
for _ in range(i + 1):
|
|
metrics_manager.inc_num_running_requests_for_replica(replica_ids[i])
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
|
|
# All 4 replicas should have a positive number of requests
|
|
for i, r in enumerate(replica_ids):
|
|
assert metrics_manager.num_requests_sent_to_replicas[r] == i + 1
|
|
assert (
|
|
metrics_manager.num_running_requests_gauge.get_value(
|
|
{
|
|
"deployment": "a",
|
|
"application": "b",
|
|
"handle": "random",
|
|
"actor_id": "random_actor",
|
|
}
|
|
)
|
|
== 10
|
|
)
|
|
|
|
# Requests at r1 and r2 drop to 0
|
|
for _ in range(1):
|
|
metrics_manager.dec_num_running_requests_for_replica(r1)
|
|
for _ in range(2):
|
|
metrics_manager.dec_num_running_requests_for_replica(r2)
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
assert metrics_manager.num_requests_sent_to_replicas[r1] == 0
|
|
assert metrics_manager.num_requests_sent_to_replicas[r2] == 0
|
|
|
|
# 3 requests finished processing
|
|
assert (
|
|
metrics_manager.num_running_requests_gauge.get_value(
|
|
{
|
|
"deployment": "a",
|
|
"application": "b",
|
|
"handle": "random",
|
|
"actor_id": "random_actor",
|
|
}
|
|
)
|
|
== 7
|
|
)
|
|
|
|
# Running replicas reduces to [r2, r4]
|
|
metrics_manager._update_running_replicas(
|
|
[
|
|
running_replica_info(r2),
|
|
running_replica_info(r4),
|
|
]
|
|
)
|
|
await asyncio.sleep(RAY_SERVE_METRICS_EXPORT_INTERVAL_MS * 2 / 1000)
|
|
|
|
# Only r1 should be pruned, the rest should still be tracked.
|
|
assert r1 not in metrics_manager.num_requests_sent_to_replicas
|
|
assert r2 in metrics_manager.num_requests_sent_to_replicas
|
|
assert r3 in metrics_manager.num_requests_sent_to_replicas
|
|
assert r4 in metrics_manager.num_requests_sent_to_replicas
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_should_send_scaled_to_zero_optimized_push(self):
|
|
metrics_manager = RouterMetricsManager(
|
|
DeploymentID(name="a", app_name="b"),
|
|
"random",
|
|
"random_actor",
|
|
DeploymentHandleSource.UNKNOWN,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=("deployment", "route", "application", "handle", "actor_id")
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
|
|
# Not an autoscaling deployment, should not push metrics
|
|
assert not metrics_manager.should_send_scaled_to_zero_optimized_push(0)
|
|
|
|
# No queued requests at the handle, should not push metrics
|
|
metrics_manager._deployment_config = DeploymentConfig(
|
|
autoscaling_config=AutoscalingConfig()
|
|
)
|
|
assert not metrics_manager.should_send_scaled_to_zero_optimized_push(0)
|
|
|
|
# Current number of replicas is non-zero, should not push metrics
|
|
metrics_manager.inc_num_queued_requests()
|
|
assert not metrics_manager.should_send_scaled_to_zero_optimized_push(1)
|
|
|
|
# All 3 conditions satisfied, should push metrics
|
|
assert metrics_manager.should_send_scaled_to_zero_optimized_push(0)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch(
|
|
"ray.serve._private.router.RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE", "1"
|
|
)
|
|
async def test_push_autoscaling_metrics_to_controller(self):
|
|
timer = MockTimer()
|
|
start = random.randint(50, 100)
|
|
timer.reset(start)
|
|
deployment_id = DeploymentID(name="a", app_name="b")
|
|
handle_id = "random"
|
|
self_actor_id = "abc"
|
|
mock_controller_handle = Mock()
|
|
|
|
with patch("time.time", new=timer.time):
|
|
metrics_manager = RouterMetricsManager(
|
|
deployment_id,
|
|
handle_id,
|
|
self_actor_id,
|
|
DeploymentHandleSource.PROXY,
|
|
mock_controller_handle,
|
|
FakeCounter(
|
|
tag_keys=(
|
|
"deployment",
|
|
"route",
|
|
"application",
|
|
"handle",
|
|
"actor_id",
|
|
)
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
metrics_manager._deployment_config = DeploymentConfig(
|
|
autoscaling_config=AutoscalingConfig()
|
|
)
|
|
|
|
# Set up some requests
|
|
n = random.randint(0, 5)
|
|
replica_ids = [
|
|
ReplicaID(get_random_string(), DeploymentID("d", "a")) for _ in range(3)
|
|
]
|
|
running_requests = defaultdict(int)
|
|
for _ in range(n):
|
|
metrics_manager.inc_num_queued_requests()
|
|
for _ in range(20):
|
|
r = random.choice(replica_ids)
|
|
running_requests[r] += 1
|
|
metrics_manager.inc_num_running_requests_for_replica(r)
|
|
|
|
# Check metrics are pushed correctly (compressed)
|
|
metrics_manager.push_autoscaling_metrics_to_controller()
|
|
mock_controller_handle.record_autoscaling_metrics_from_handle.remote.assert_called_once()
|
|
(
|
|
compressed,
|
|
) = mock_controller_handle.record_autoscaling_metrics_from_handle.remote.call_args[
|
|
0
|
|
]
|
|
assert isinstance(compressed, bytes)
|
|
handle_metric_report = decompress_metric_report(compressed)
|
|
assert handle_metric_report.deployment_id == deployment_id
|
|
assert handle_metric_report.handle_id == handle_id
|
|
|
|
@pytest.mark.skipif(
|
|
not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
|
|
reason="Tests handle metrics behavior.",
|
|
)
|
|
@pytest.mark.asyncio
|
|
@patch(
|
|
"ray.serve._private.router.RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR",
|
|
0.001,
|
|
)
|
|
async def test_memory_cleared(self):
|
|
deployment_id = DeploymentID(name="a", app_name="b")
|
|
metrics_manager = RouterMetricsManager(
|
|
deployment_id,
|
|
"some_handle",
|
|
"some_actor",
|
|
DeploymentHandleSource.PROXY,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=(
|
|
"deployment",
|
|
"route",
|
|
"application",
|
|
"handle",
|
|
"actor_id",
|
|
)
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
metrics_manager.update_deployment_config(
|
|
deployment_config=DeploymentConfig(
|
|
autoscaling_config=AutoscalingConfig(
|
|
metrics_interval_s=0.005, look_back_period_s=0.01
|
|
)
|
|
),
|
|
curr_num_replicas=0,
|
|
)
|
|
|
|
r1 = ReplicaID("r1", deployment_id)
|
|
r2 = ReplicaID("r2", deployment_id)
|
|
r3 = ReplicaID("r3", deployment_id)
|
|
|
|
def check_database(expected: Set[ReplicaID]):
|
|
assert set(metrics_manager.metrics_store.data) == expected
|
|
return True
|
|
|
|
# r1: 1
|
|
metrics_manager.inc_num_running_requests_for_replica(r1)
|
|
await async_wait_for_condition(
|
|
check_database, expected={r1, QUEUED_REQUESTS_KEY}
|
|
)
|
|
|
|
# r1: 1, r2: 0
|
|
metrics_manager.inc_num_running_requests_for_replica(r2)
|
|
await async_wait_for_condition(
|
|
check_database, expected={r1, r2, QUEUED_REQUESTS_KEY}
|
|
)
|
|
metrics_manager.dec_num_running_requests_for_replica(r2)
|
|
|
|
# r1: 1, r2: 0, r3: 0
|
|
metrics_manager.inc_num_running_requests_for_replica(r3)
|
|
await async_wait_for_condition(
|
|
check_database, expected={r1, r2, r3, QUEUED_REQUESTS_KEY}
|
|
)
|
|
metrics_manager.dec_num_running_requests_for_replica(r3)
|
|
|
|
# update running replicas {r2}
|
|
metrics_manager._update_running_replicas([running_replica_info(r2)])
|
|
await async_wait_for_condition(
|
|
check_database, expected={r1, r2, QUEUED_REQUESTS_KEY}
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch(
|
|
"ray.serve._private.router.RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE", "1"
|
|
)
|
|
@patch("ray.serve._private.router.MetricsPusher")
|
|
async def test_update_deployment_config(self, metrics_pusher_mock):
|
|
metrics_manager = RouterMetricsManager(
|
|
DeploymentID(name="a", app_name="b"),
|
|
"random",
|
|
"random_actor",
|
|
DeploymentHandleSource.UNKNOWN,
|
|
Mock(),
|
|
FakeCounter(
|
|
tag_keys=("deployment", "route", "application", "handle", "actor_id")
|
|
),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
FakeGauge(tag_keys=("deployment", "application", "handle", "actor_id")),
|
|
event_loop=asyncio.get_event_loop(),
|
|
)
|
|
|
|
# Without autoscaling config, do nothing
|
|
metrics_manager.update_deployment_config(DeploymentConfig(), 0)
|
|
metrics_manager.metrics_pusher.register_or_update_task.assert_not_called()
|
|
|
|
# With autoscaling config, register or update task should be called
|
|
metrics_manager.update_deployment_config(
|
|
DeploymentConfig(autoscaling_config=AutoscalingConfig()), 0
|
|
)
|
|
metrics_manager.metrics_pusher.register_or_update_task.assert_called()
|
|
|
|
|
|
class TestSingletonThreadRouter:
|
|
@pytest.fixture
|
|
def setup_singleton_thread_router(
|
|
self, setup_router: Tuple[AsyncioRouter, FakeRequestRouter]
|
|
) -> SingletonThreadRouter:
|
|
asyncio_router, fake_request_router = setup_router
|
|
|
|
# Mock ray.get_runtime_context() to avoid triggering Ray initialization
|
|
with patch("ray.get_runtime_context") as mock_context:
|
|
mock_context.return_value.get_actor_id.return_value = "test-actor-id"
|
|
|
|
router = SingletonThreadRouter(
|
|
controller_handle=Mock(),
|
|
deployment_id=DeploymentID(name="test", app_name="test"),
|
|
handle_id="test",
|
|
self_actor_id="test",
|
|
handle_source="test",
|
|
request_router=fake_request_router,
|
|
enable_strict_max_ongoing_requests=False,
|
|
resolve_request_arg_func=Mock(),
|
|
node_id="test-node-id",
|
|
availability_zone="test-az",
|
|
prefer_local_node_routing=False,
|
|
)
|
|
router._asyncio_router = asyncio_router
|
|
return router
|
|
|
|
def test_request_assignment(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
):
|
|
_, fake_request_router = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
future = thread_router.assign_request(request_metadata)
|
|
assert isinstance(future, concurrent.futures.Future)
|
|
assert future.result()._replica_id == r1_id
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_queue_len_cache": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_dispatch_marks_selection_before_scheduled_coroutine_runs(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
monkeypatch,
|
|
):
|
|
_, fake_request_router = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
replica = FakeReplica(r1_id)
|
|
fake_request_router.set_replica_to_return(replica)
|
|
|
|
pending_coros = []
|
|
|
|
def delay_asyncio_call(coro):
|
|
pending_coros.append(coro)
|
|
return concurrent.futures.Future()
|
|
|
|
monkeypatch.setattr(
|
|
thread_router, "_wrap_asyncio_call_in_future", delay_asyncio_call
|
|
)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async with thread_router.choose_replica(request_metadata) as selection:
|
|
dispatch_future = thread_router.dispatch(selection, request_metadata)
|
|
assert selection._dispatched
|
|
|
|
assert fake_request_router.replica_queue_len_cache.get(r1_id) == 1
|
|
assert len(pending_coros) == 1
|
|
|
|
dispatch_future.cancel()
|
|
pending_coros[0].close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_choose_replica_cancel_during_entry_releases_slot(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
monkeypatch,
|
|
):
|
|
"""Cancelling the caller after __aenter__ reserved a slot on the router
|
|
loop, but before the bridge takes ownership of the selection, must
|
|
still release the slot via __aexit__.
|
|
"""
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
outer_loop = asyncio.get_running_loop()
|
|
|
|
fake_selection = ReplicaSelection(
|
|
replica_id="fake-replica",
|
|
node_ip="127.0.0.1",
|
|
port=None,
|
|
node_id="fake-node",
|
|
availability_zone=None,
|
|
replica_metadata={},
|
|
_replica=None,
|
|
_deployment_id=None,
|
|
_request_metadata=None,
|
|
_method_name="",
|
|
_slot_token="",
|
|
)
|
|
|
|
reserved_slots = 0
|
|
reserved_lock = threading.Lock()
|
|
release_path: List[str] = []
|
|
released = threading.Event()
|
|
outer_task_holder: List[asyncio.Task] = []
|
|
# Pin every inner CM so GC can't finalize the parked generator and
|
|
# release the slot for us -- only an explicit __aexit__ should.
|
|
pinned_cms: List = []
|
|
|
|
@asynccontextmanager
|
|
async def fake_choose_replica(*args, **kwargs):
|
|
nonlocal reserved_slots
|
|
# __aenter__: reserve a slot.
|
|
with reserved_lock:
|
|
reserved_slots += 1
|
|
|
|
# Open the race window deterministically: cancel on the outer loop,
|
|
# then block the router thread until the cancel has propagated -- the
|
|
# caller observes CancelledError before __aexit__ runs.
|
|
cancel_propagated = threading.Event()
|
|
|
|
def cancel_and_chain_marker():
|
|
outer_task_holder[0].cancel()
|
|
outer_loop.call_soon(cancel_propagated.set)
|
|
|
|
outer_loop.call_soon_threadsafe(cancel_and_chain_marker)
|
|
cancel_propagated.wait()
|
|
try:
|
|
yield fake_selection
|
|
finally:
|
|
# GeneratorExit => released by GC/aclose; else by explicit __aexit__.
|
|
release_path.append(
|
|
"gc" if isinstance(sys.exc_info()[1], GeneratorExit) else "aexit"
|
|
)
|
|
with reserved_lock:
|
|
reserved_slots -= 1
|
|
released.set()
|
|
|
|
def pinning_factory(*args, **kwargs):
|
|
cm = fake_choose_replica(*args, **kwargs)
|
|
pinned_cms.append(cm)
|
|
return cm
|
|
|
|
monkeypatch.setattr(fake_router, "choose_replica", pinning_factory)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async def runner():
|
|
async with thread_router.choose_replica(request_metadata):
|
|
pass
|
|
|
|
task = asyncio.create_task(runner())
|
|
outer_task_holder.append(task)
|
|
|
|
# Cancellation is honored.
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
# Slot released via __aexit__, not GC.
|
|
assert released.wait(timeout=2.0)
|
|
assert release_path == ["aexit"]
|
|
with reserved_lock:
|
|
assert reserved_slots == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_finally_shields_cleanup_from_cancellation(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
monkeypatch,
|
|
):
|
|
"""Cancellation during the bridge's finally must not abort cleanup on
|
|
the router loop. The cleanup await is shielded so __aexit__ runs to
|
|
completion even if a cancel arrives while we're awaiting it.
|
|
"""
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
router_loop = thread_router._get_singleton_asyncio_loop(component="unknown")
|
|
|
|
fake_selection = ReplicaSelection(
|
|
replica_id="fake-replica",
|
|
node_ip="127.0.0.1",
|
|
port=None,
|
|
node_id="fake-node",
|
|
availability_zone=None,
|
|
replica_metadata={},
|
|
_replica=None,
|
|
_deployment_id=None,
|
|
_request_metadata=None,
|
|
_method_name="",
|
|
_slot_token="",
|
|
)
|
|
|
|
aexit_started = threading.Event()
|
|
exit_called = threading.Event()
|
|
|
|
async def make_event():
|
|
return asyncio.Event()
|
|
|
|
release_event = await asyncio.wrap_future(
|
|
asyncio.run_coroutine_threadsafe(make_event(), router_loop)
|
|
)
|
|
|
|
@asynccontextmanager
|
|
async def fake_choose_replica(*args, **kwargs):
|
|
try:
|
|
yield fake_selection
|
|
finally:
|
|
# Signal that the bridge's finally is now awaiting cleanup.
|
|
aexit_started.set()
|
|
# Block until released. If a cancel propagates through to
|
|
# this loop the wait raises and exit_called is never set.
|
|
await release_event.wait()
|
|
exit_called.set()
|
|
|
|
monkeypatch.setattr(fake_router, "choose_replica", fake_choose_replica)
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
async def runner():
|
|
async with thread_router.choose_replica(request_metadata):
|
|
pass
|
|
|
|
task = asyncio.create_task(runner())
|
|
|
|
# Wait until the bridge's finally is awaiting cleanup.
|
|
await asyncio.to_thread(aexit_started.wait)
|
|
|
|
# Cancel mid-cleanup. Without shielding, the cancel propagates
|
|
# through wrap_future to the router-loop task and interrupts
|
|
# fake's __aexit__ before it can set exit_called.
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
|
|
# Release the gate. With shielding, fake's __aexit__ will now
|
|
# complete and set exit_called.
|
|
router_loop.call_soon_threadsafe(release_event.set)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert exit_called.wait(timeout=2.0), (
|
|
"Cleanup was aborted by cancellation propagating to the router "
|
|
"loop; bridge's finally await must be shielded."
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_propagation(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
):
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
loop = thread_router._get_singleton_asyncio_loop(component="unknown")
|
|
|
|
async def init_events():
|
|
return asyncio.Event()
|
|
|
|
f = asyncio.run_coroutine_threadsafe(init_events(), loop)
|
|
lock_acquire_event = f.result()
|
|
cancel_block_event = threading.Event()
|
|
|
|
fake_replica_result = FakeReplicaResult(
|
|
replica_id=ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
),
|
|
is_generator_object=False,
|
|
)
|
|
|
|
async def mock_assign_request(*args, **kwargs):
|
|
try:
|
|
await lock_acquire_event.wait()
|
|
return fake_replica_result
|
|
except asyncio.CancelledError:
|
|
cancel_block_event.wait()
|
|
fake_replica_result.cancel()
|
|
raise
|
|
|
|
fake_router.assign_request = mock_assign_request
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
assign_request_future = thread_router.assign_request(request_metadata)
|
|
assign_request_future.cancel()
|
|
|
|
assert fake_replica_result.cancelled is False
|
|
|
|
cancel_block_event.set()
|
|
|
|
def release_lock():
|
|
lock_acquire_event.set()
|
|
|
|
loop.call_soon_threadsafe(release_lock)
|
|
|
|
with pytest.raises(concurrent.futures.CancelledError):
|
|
assign_request_future.result()
|
|
assert assign_request_future.cancelled() is True
|
|
wait_for_condition(lambda: fake_replica_result.cancelled is True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replica_result_cancellation(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
):
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
fake_replica_result = FakeReplicaResult(
|
|
replica_id=ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
),
|
|
is_generator_object=False,
|
|
)
|
|
|
|
event = threading.Event()
|
|
|
|
async def mock_assign_request(*args, **kwargs):
|
|
# this is a blocking call that will block the asyncio loop
|
|
event.wait()
|
|
return fake_replica_result
|
|
|
|
fake_router.assign_request = mock_assign_request
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
assign_request_future = thread_router.assign_request(request_metadata)
|
|
assign_request_future.cancel()
|
|
|
|
with pytest.raises(concurrent.futures.CancelledError):
|
|
assign_request_future.result()
|
|
|
|
assert assign_request_future.cancelled() is True
|
|
assert fake_replica_result.cancelled is False
|
|
|
|
event.set()
|
|
wait_for_condition(lambda: fake_replica_result.cancelled is True)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_request_with_exception(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
):
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
async def mock_assign_request(*args, **kwargs):
|
|
raise Exception("test exception")
|
|
|
|
fake_router.assign_request = mock_assign_request
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
assign_request_future = thread_router.assign_request(request_metadata)
|
|
|
|
assert assign_request_future.exception() is not None
|
|
assert str(assign_request_future.exception()) == "test exception"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_assign_request_with_exception_during_cancellation(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
setup_singleton_thread_router: SingletonThreadRouter,
|
|
):
|
|
fake_router, _ = setup_router
|
|
thread_router = setup_singleton_thread_router
|
|
|
|
mock_replica_result = FakeReplicaResult(
|
|
replica_id=ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
),
|
|
is_generator_object=False,
|
|
)
|
|
loop = thread_router._get_singleton_asyncio_loop(component="unknown")
|
|
|
|
async def init_events():
|
|
return asyncio.Event(), asyncio.Event()
|
|
|
|
f = asyncio.run_coroutine_threadsafe(init_events(), loop)
|
|
lock_acquire_event, cancel_block_event = f.result()
|
|
|
|
async def mock_assign_request(*args, **kwargs):
|
|
try:
|
|
await lock_acquire_event.wait()
|
|
return mock_replica_result
|
|
except asyncio.CancelledError:
|
|
cancel_block_event.set()
|
|
raise Exception("test exception")
|
|
|
|
fake_router.assign_request = mock_assign_request
|
|
|
|
request_metadata = RequestMetadata(
|
|
request_id="test-request-1",
|
|
internal_request_id="test-internal-request-1",
|
|
)
|
|
|
|
assign_request_future = thread_router.assign_request(request_metadata)
|
|
assign_request_future.cancel()
|
|
|
|
async def coro():
|
|
await cancel_block_event.wait()
|
|
|
|
asyncio.run_coroutine_threadsafe(coro(), loop).result()
|
|
|
|
def release_lock():
|
|
lock_acquire_event.set()
|
|
|
|
loop.call_soon_threadsafe(release_lock)
|
|
|
|
assert assign_request_future.cancelled() is True
|
|
with pytest.raises(concurrent.futures.CancelledError):
|
|
assign_request_future.exception()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestAsyncioRouterBackoffConfig:
|
|
"""Test that backoff config flows from DeploymentConfig to RequestRouter."""
|
|
|
|
async def test_update_deployment_config_sets_backoff_params(self):
|
|
"""Test that update_deployment_config extracts backoff params from config."""
|
|
fake_request_router = FakeRequestRouter(use_queue_len_cache=False)
|
|
router = AsyncioRouter(
|
|
controller_handle=Mock(),
|
|
deployment_id=DeploymentID(name="test-deployment"),
|
|
handle_id="test-handle-id",
|
|
self_actor_id="test-node-id",
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
event_loop=get_or_create_event_loop(),
|
|
enable_strict_max_ongoing_requests=False,
|
|
request_router=fake_request_router,
|
|
node_id="test-node-id",
|
|
availability_zone="test-az",
|
|
prefer_local_node_routing=False,
|
|
_request_router_initialized_event=asyncio.Event(),
|
|
)
|
|
|
|
# Create a DeploymentConfig with custom backoff params
|
|
custom_initial_backoff = 0.15
|
|
custom_multiplier = 4
|
|
custom_max_backoff = 2.5
|
|
|
|
deployment_config = DeploymentConfig.from_default(
|
|
request_router_config=RequestRouterConfig(
|
|
initial_backoff_s=custom_initial_backoff,
|
|
backoff_multiplier=custom_multiplier,
|
|
max_backoff_s=custom_max_backoff,
|
|
)
|
|
)
|
|
|
|
# Update the router with the config
|
|
router.update_deployment_config(deployment_config)
|
|
|
|
# Verify the backoff params were stored on the router
|
|
assert router._initial_backoff_s == custom_initial_backoff
|
|
assert router._backoff_multiplier == custom_multiplier
|
|
assert router._max_backoff_s == custom_max_backoff
|
|
|
|
# Verify the backoff params were propagated to the request router
|
|
assert fake_request_router.initial_backoff_s == custom_initial_backoff
|
|
assert fake_request_router.backoff_multiplier == custom_multiplier
|
|
assert fake_request_router.max_backoff_s == custom_max_backoff
|
|
|
|
|
|
class TestOnRequestCompleted:
|
|
"""Tests for the on_request_completed hook introduced in this branch.
|
|
|
|
Verifies that on_request_completed is called on the request router:
|
|
- Via the done-callback when a request completes normally.
|
|
- Via the finally block when a request fails before the callback is registered.
|
|
"""
|
|
|
|
async def test_on_request_completed_called_via_done_callback(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
):
|
|
"""After a normal (no-rejection) request, firing the ReplicaResult's
|
|
done callback should invoke on_request_completed on the router."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
fake_request_router.set_replica_to_return(FakeReplica(r1_id))
|
|
|
|
metadata = RequestMetadata(
|
|
request_id="req-1",
|
|
internal_request_id="internal-req-1",
|
|
)
|
|
result = await router.assign_request(metadata)
|
|
|
|
assert len(fake_request_router.completed_requests) == 0
|
|
|
|
# Simulate the request finishing.
|
|
result.fire_done_callbacks(result=None)
|
|
await asyncio.sleep(0)
|
|
|
|
assert len(fake_request_router.completed_requests) == 1
|
|
completed_replica_id, completed_req_id = fake_request_router.completed_requests[
|
|
0
|
|
]
|
|
assert completed_replica_id == r1_id
|
|
assert completed_req_id == "internal-req-1"
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_strict_max_ongoing_requests": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_on_request_completed_called_via_finally_on_actor_died(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
):
|
|
"""When try_send_request raises ActorDiedError (before the callback is
|
|
registered), the finally block should call on_request_completed."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(r1_id, error=ActorDiedError())
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=5
|
|
),
|
|
)
|
|
)
|
|
|
|
await router.assign_request(dummy_request_metadata())
|
|
|
|
# The finally block should have called on_request_completed for r1.
|
|
r1_completions = [
|
|
(rid, req_id)
|
|
for rid, req_id in fake_request_router.completed_requests
|
|
if rid == r1_id
|
|
]
|
|
assert len(r1_completions) == 1
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_router",
|
|
[{"enable_strict_max_ongoing_requests": True}],
|
|
indirect=True,
|
|
)
|
|
async def test_on_request_completed_called_via_finally_on_actor_unavailable(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
):
|
|
"""When try_send_request raises ActorUnavailableError, the finally
|
|
block should call on_request_completed."""
|
|
router, fake_request_router = setup_router
|
|
d_id = DeploymentID(name="test")
|
|
r1_id = ReplicaID(unique_id="r1", deployment_id=d_id)
|
|
r2_id = ReplicaID(unique_id="r2", deployment_id=d_id)
|
|
|
|
fake_request_router.set_replica_to_return(
|
|
FakeReplica(
|
|
r1_id,
|
|
error=ActorUnavailableError(error_message="unavailable", actor_id=None),
|
|
)
|
|
)
|
|
fake_request_router.set_replica_to_return_on_retry(
|
|
FakeReplica(
|
|
r2_id,
|
|
queue_len_info=ReplicaQueueLengthInfo(
|
|
accepted=True, num_ongoing_requests=5
|
|
),
|
|
)
|
|
)
|
|
|
|
await router.assign_request(dummy_request_metadata())
|
|
|
|
r1_completions = [
|
|
(rid, req_id)
|
|
for rid, req_id in fake_request_router.completed_requests
|
|
if rid == r1_id
|
|
]
|
|
assert len(r1_completions) == 1
|
|
|
|
async def test_multiple_requests_each_trigger_on_request_completed(
|
|
self,
|
|
setup_router: Tuple[AsyncioRouter, FakeRequestRouter],
|
|
):
|
|
"""Multiple in-flight requests should each trigger their own
|
|
on_request_completed call when they finish."""
|
|
router, fake_request_router = setup_router
|
|
|
|
r1_id = ReplicaID(
|
|
unique_id="test-replica-1", deployment_id=DeploymentID(name="test")
|
|
)
|
|
fake_request_router.set_replica_to_return(FakeReplica(r1_id))
|
|
|
|
results = []
|
|
for i in range(5):
|
|
metadata = RequestMetadata(
|
|
request_id=f"req-{i}",
|
|
internal_request_id=f"internal-req-{i}",
|
|
)
|
|
result = await router.assign_request(metadata)
|
|
results.append(result)
|
|
|
|
assert len(fake_request_router.completed_requests) == 0
|
|
|
|
# Complete them one at a time.
|
|
for i, result in enumerate(results):
|
|
result.fire_done_callbacks(result=None)
|
|
await asyncio.sleep(0)
|
|
assert len(fake_request_router.completed_requests) == i + 1
|
|
|
|
completed_ids = {req_id for _, req_id in fake_request_router.completed_requests}
|
|
expected_ids = {f"internal-req-{i}" for i in range(5)}
|
|
assert completed_ids == expected_ids
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
class TestCustomRequestRouterAPIs:
|
|
"""Tests for the new RequestRouter APIs introduced in this branch:
|
|
- supports_rejection_protocol property
|
|
- _compute_backoff_s / _backoff helpers
|
|
"""
|
|
|
|
def test_supports_rejection_protocol_defaults_to_true(self):
|
|
"""The base RequestRouter.supports_rejection_protocol should be True."""
|
|
|
|
class MinimalRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
r = MinimalRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
)
|
|
assert r.supports_rejection_protocol is True
|
|
|
|
def test_supports_rejection_protocol_can_be_overridden(self):
|
|
"""A subclass can override supports_rejection_protocol to False."""
|
|
|
|
class NoRejectionRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
@property
|
|
def supports_rejection_protocol(self) -> bool:
|
|
return False
|
|
|
|
r = NoRejectionRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
)
|
|
assert r.supports_rejection_protocol is False
|
|
|
|
def test_compute_backoff_s_uses_class_parameters(self):
|
|
"""_compute_backoff_s should respect the class-level backoff params."""
|
|
|
|
class MinimalRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
r = MinimalRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
)
|
|
|
|
b0 = r._compute_backoff_s(0)
|
|
b1 = r._compute_backoff_s(1)
|
|
assert b0 == r.initial_backoff_s
|
|
assert b1 == pytest.approx(r.initial_backoff_s * r.backoff_multiplier)
|
|
|
|
def test_compute_backoff_s_with_custom_multiplier(self):
|
|
"""A subclass can override backoff_multiplier and _compute_backoff_s
|
|
will respect it."""
|
|
|
|
class CustomBackoffRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
r = CustomBackoffRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
backoff_multiplier=1.5,
|
|
)
|
|
|
|
b0 = r._compute_backoff_s(0)
|
|
b1 = r._compute_backoff_s(1)
|
|
b2 = r._compute_backoff_s(2)
|
|
|
|
assert b0 == r.initial_backoff_s
|
|
assert b1 == pytest.approx(r.initial_backoff_s * 1.5)
|
|
assert b2 == pytest.approx(r.initial_backoff_s * 1.5**2)
|
|
|
|
def test_compute_backoff_s_capped_at_max(self):
|
|
"""Backoff should never exceed max_backoff_s."""
|
|
|
|
class MinimalRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
r = MinimalRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
)
|
|
|
|
for attempt in [50, 100, 1000]:
|
|
assert r._compute_backoff_s(attempt) <= r.max_backoff_s
|
|
|
|
async def test_backoff_is_awaitable(self):
|
|
"""_backoff should be an awaitable coroutine."""
|
|
|
|
class MinimalRouter(RequestRouter):
|
|
async def choose_replicas(self, candidate_replicas, pending_request=None):
|
|
return [candidate_replicas]
|
|
|
|
r = MinimalRouter(
|
|
deployment_id=DeploymentID(name="test"),
|
|
handle_source=DeploymentHandleSource.UNKNOWN,
|
|
create_replica_wrapper_func=lambda ri: FakeReplica(ri),
|
|
)
|
|
# Should complete without error.
|
|
await r._backoff(0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(pytest.main(["-v", "-s", __file__]))
|