import asyncio import pickle import sys from types import SimpleNamespace from typing import Union import pytest import ray from ray import ObjectRef, ObjectRefGenerator from ray._common.test_utils import SignalActor, async_wait_for_condition from ray._common.utils import get_or_create_event_loop from ray.exceptions import ActorDiedError, ActorUnavailableError, TaskCancelledError from ray.serve._private.common import ( DeploymentID, ReplicaID, ReplicaQueueLengthInfo, RequestMetadata, RunningReplicaInfo, ) from ray.serve._private.constants import SERVE_NAMESPACE from ray.serve._private.request_router.common import PendingRequest from ray.serve._private.request_router.replica_wrapper import RunningReplica from ray.serve._private.test_utils import send_signal_on_cancellation from ray.serve._private.utils import Semaphore class _IntMetricsManager: """Minimal metrics manager that tracks only the in-flight count.""" def __init__(self): self._n = 0 def get_num_ongoing_requests(self): return self._n def inc_num_ongoing_requests(self, _): self._n += 1 def dec_num_ongoing_requests(self, _): self._n -= 1 @ray.remote(num_cpus=0) class SlotReservationActor: """Ray actor wrapping the real Replica.reserve_slot / release_slot. Used by integration tests that need production slot-reservation logic running under Ray's actor concurrency model — unit tests share one event loop and can't observe sync/async ordering on a real ReplicaActor. """ def __init__(self, max_ongoing_requests: int): from ray.serve._private.replica import Replica replica = Replica.__new__(Replica) replica._deployment_config = SimpleNamespace( max_ongoing_requests=max_ongoing_requests ) replica._reserved_slots = set() replica._semaphore = Semaphore(lambda: max_ongoing_requests) replica._metrics_manager = _IntMetricsManager() # __init__ is bypassed; set the quiesce flag read by # _can_accept_request (reservations are rejected once quiescing). replica._quiescing = False self._replica = replica async def reserve_slot(self, request_metadata, slot_token: str): return await self._replica.reserve_slot(request_metadata, slot_token) def release_slot(self, slot_token: str): return self._replica.release_slot(slot_token) def get_num_ongoing_requests(self) -> int: return self._replica.get_num_ongoing_requests() @ray.remote(num_cpus=0) class BlockingReserveActor: """Actor whose reserve_slot blocks on a SignalActor. Records every release_slot token it receives so a test can verify the cancellation cleanup path in RunningReplica.reserve_slot. """ def __init__(self, signal_actor): self._signal = signal_actor self._released_tokens = [] async def reserve_slot(self, request_metadata, slot_token: str): await self._signal.wait.remote() return True, 1 def release_slot(self, slot_token: str): self._released_tokens.append(slot_token) return True, 0 def get_released_tokens(self): return list(self._released_tokens) @ray.remote(num_cpus=0) class FakeReplicaActor: def __init__(self): self._replica_queue_length_info = None def set_replica_queue_length_info(self, info: ReplicaQueueLengthInfo): self._replica_queue_length_info = info async def handle_request( self, request_metadata: Union[bytes, RequestMetadata], message: str, *, is_streaming: bool, ): if isinstance(request_metadata, bytes): request_metadata = pickle.loads(request_metadata) assert not is_streaming and not request_metadata.is_streaming return message async def handle_request_streaming( self, request_metadata: Union[bytes, RequestMetadata], message: str, *, is_streaming: bool, ): if isinstance(request_metadata, bytes): request_metadata = pickle.loads(request_metadata) assert is_streaming and request_metadata.is_streaming for i in range(5): yield f"{message}-{i}" async def handle_request_with_rejection( self, pickled_request_metadata: bytes, *args, **kwargs, ): cancelled_signal_actor = kwargs.pop("cancelled_signal_actor", None) if cancelled_signal_actor is not None: executing_signal_actor = kwargs.pop("executing_signal_actor") async with send_signal_on_cancellation(cancelled_signal_actor): await executing_signal_actor.send.remote() return # Special case: if "raise_task_cancelled_error" is in kwargs, raise TaskCancelledError # This simulates the scenario where the underlying Ray task gets cancelled if kwargs.pop("raise_task_cancelled_error", False): raise TaskCancelledError() yield pickle.dumps(self._replica_queue_length_info) if not self._replica_queue_length_info.accepted: return request_metadata = pickle.loads(pickled_request_metadata) if request_metadata.is_streaming: async for result in self.handle_request_streaming( request_metadata, *args, **kwargs ): yield result else: yield await self.handle_request(request_metadata, *args, **kwargs) @pytest.fixture def setup_fake_replica(ray_instance) -> RunningReplica: replica_id = ReplicaID( "fake_replica", deployment_id=DeploymentID(name="fake_deployment") ) actor_name = replica_id.to_full_id_str() # Create actor with a name so it can be retrieved by get_actor_handle() _ = FakeReplicaActor.options( name=actor_name, namespace=SERVE_NAMESPACE, lifetime="detached" ).remote() return RunningReplicaInfo( replica_id=replica_id, node_id=None, node_ip=None, availability_zone=None, actor_name=actor_name, max_ongoing_requests=10, is_cross_language=False, ) def test_update_replica_info_refreshes_backend_http_endpoint(setup_fake_replica): replica = RunningReplica(setup_fake_replica) assert replica.backend_http_endpoint is None updated_info = RunningReplicaInfo( replica_id=setup_fake_replica.replica_id, node_id=setup_fake_replica.node_id, node_ip="127.0.0.1", availability_zone=setup_fake_replica.availability_zone, actor_name=setup_fake_replica.actor_name, max_ongoing_requests=setup_fake_replica.max_ongoing_requests, is_cross_language=setup_fake_replica.is_cross_language, backend_http_port=8001, ) replica.update_replica_info(updated_info) assert replica.backend_http_endpoint == ("127.0.0.1", 8001) def test_backend_http_endpoint_requires_host_and_port(setup_fake_replica): replica = RunningReplica(setup_fake_replica) updated_info = RunningReplicaInfo( replica_id=setup_fake_replica.replica_id, node_id=setup_fake_replica.node_id, node_ip=None, availability_zone=setup_fake_replica.availability_zone, actor_name=setup_fake_replica.actor_name, max_ongoing_requests=setup_fake_replica.max_ongoing_requests, is_cross_language=setup_fake_replica.is_cross_language, backend_http_port=8001, ) replica.update_replica_info(updated_info) assert replica.backend_http_endpoint is None @pytest.mark.asyncio @pytest.mark.parametrize("is_streaming", [False, True]) async def test_send_request_without_rejection(setup_fake_replica, is_streaming: bool): replica = RunningReplica(setup_fake_replica) pr = PendingRequest( args=["Hello"], kwargs={"is_streaming": is_streaming}, metadata=RequestMetadata( request_id="abc", internal_request_id="def", is_streaming=is_streaming, ), ) replica_result = replica.try_send_request(pr, with_rejection=False) if is_streaming: assert isinstance(replica_result.to_object_ref_gen(), ObjectRefGenerator) for i in range(5): assert await replica_result.__anext__() == f"Hello-{i}" else: assert isinstance(replica_result.to_object_ref(), ObjectRef) assert isinstance(await replica_result.to_object_ref_async(), ObjectRef) assert await replica_result.get_async() == "Hello" @pytest.mark.asyncio @pytest.mark.parametrize("accepted", [False, True]) @pytest.mark.parametrize("is_streaming", [False, True]) async def test_send_request_with_rejection( setup_fake_replica, accepted: bool, is_streaming: bool ): actor_handle = setup_fake_replica.get_actor_handle() replica = RunningReplica(setup_fake_replica) ray.get( actor_handle.set_replica_queue_length_info.remote( ReplicaQueueLengthInfo(accepted=accepted, num_ongoing_requests=10), ) ) pr = PendingRequest( args=["Hello"], kwargs={"is_streaming": is_streaming}, metadata=RequestMetadata( request_id="abc", internal_request_id="def", is_streaming=is_streaming, ), ) replica_result = replica.try_send_request(pr, with_rejection=True) info = await replica_result.get_rejection_response() assert info.accepted == accepted assert info.num_ongoing_requests == 10 if not accepted: pass elif is_streaming: assert isinstance(replica_result.to_object_ref_gen(), ObjectRefGenerator) for i in range(5): assert await replica_result.__anext__() == f"Hello-{i}" else: assert isinstance(replica_result.to_object_ref(), ObjectRef) assert isinstance(await replica_result.to_object_ref_async(), ObjectRef) assert await replica_result.get_async() == "Hello" @pytest.mark.asyncio async def test_send_request_with_rejection_cancellation(setup_fake_replica): """ Verify that the downstream actor method call is cancelled if the call to send the request to the replica is cancelled. """ replica = RunningReplica(setup_fake_replica) executing_signal_actor = SignalActor.remote() cancelled_signal_actor = SignalActor.remote() pr = PendingRequest( args=["Hello"], kwargs={ "cancelled_signal_actor": cancelled_signal_actor, "executing_signal_actor": executing_signal_actor, }, metadata=RequestMetadata( request_id="abc", internal_request_id="def", ), ) # Send request should hang because the downstream actor method call blocks # before sending the system message. replica_result = replica.try_send_request(pr, with_rejection=True) request_task = get_or_create_event_loop().create_task( replica_result.get_rejection_response() ) # Check that the downstream actor method call has started. await executing_signal_actor.wait.remote() _, pending = await asyncio.wait([request_task], timeout=0.001) assert len(pending) == 1 # Cancel the task. This should cause the downstream actor method call to # be cancelled (verified via signal actor). request_task.cancel() with pytest.raises(asyncio.CancelledError): await request_task await cancelled_signal_actor.wait.remote() @pytest.mark.asyncio async def test_send_request_with_rejection_task_cancelled_error(setup_fake_replica): """ Test that TaskCancelledError from the underlying Ray task gets converted to asyncio.CancelledError when sending request with rejection. """ actor_handle = setup_fake_replica.get_actor_handle() replica = RunningReplica(setup_fake_replica) # Set up the replica to accept the request ray.get( actor_handle.set_replica_queue_length_info.remote( ReplicaQueueLengthInfo(accepted=True, num_ongoing_requests=5), ) ) pr = PendingRequest( args=["Hello"], kwargs={ "raise_task_cancelled_error": True }, # This will trigger TaskCancelledError metadata=RequestMetadata( request_id="abc", internal_request_id="def", ), ) # The TaskCancelledError should be caught and converted to asyncio.CancelledError replica_result = replica.try_send_request(pr, with_rejection=True) with pytest.raises(asyncio.CancelledError): await replica_result.get_rejection_response() def _spawn_running_replica(actor_cls, replica_id_str: str, *actor_args, **actor_kwargs): """Spawn a named actor and wrap it in a RunningReplica. Returns ``(running_replica, actor_handle)``. The actor must be created with the canonical replica-id name so RunningReplica can resolve it through its normal GCS lookup. """ replica_id = ReplicaID( replica_id_str, deployment_id=DeploymentID(name="slot_reservation_test") ) actor_name = replica_id.to_full_id_str() actor_handle = actor_cls.options( name=actor_name, namespace=SERVE_NAMESPACE, lifetime="detached" ).remote(*actor_args, **actor_kwargs) info = RunningReplicaInfo( replica_id=replica_id, node_id=None, node_ip=None, availability_zone=None, actor_name=actor_name, max_ongoing_requests=10, is_cross_language=False, ) return RunningReplica(info), actor_handle def _dummy_request_metadata() -> RequestMetadata: return RequestMetadata(request_id="abc", internal_request_id="def") @pytest.mark.asyncio async def test_reserve_slot_cancellation_releases_slot_on_actor(ray_instance): """If the awaiting reserve_slot task is cancelled, the wrapper must fire a follow-up release_slot.remote(token) so the actor doesn't leak the slot. """ signal = SignalActor.remote() replica, actor = _spawn_running_replica( BlockingReserveActor, "blocking-replica", signal ) task = get_or_create_event_loop().create_task( replica.reserve_slot(_dummy_request_metadata()) ) # Let the actor enter reserve_slot and start awaiting the signal. _, pending = await asyncio.wait([task], timeout=0.5) assert len(pending) == 1 task.cancel() with pytest.raises(asyncio.CancelledError): await task # Unblock the actor so it can process the follow-up release_slot.remote(). await signal.send.remote() # The wrapper's cancellation cleanup fires release_slot.remote(token) # without awaiting it; wait until the actor records the call. async def _release_received(): return bool(await actor.get_released_tokens.remote()) await async_wait_for_condition(_release_received, timeout=5) released_tokens = await actor.get_released_tokens.remote() assert len(released_tokens) == 1 @pytest.mark.asyncio async def test_reserve_slot_propagates_actor_died_error(ray_instance): """If the replica actor is dead, RunningReplica.reserve_slot must raise ActorDiedError so AsyncioRouter.choose_replica can retry against another replica. ActorUnavailableError is also acceptable on the brief window before the actor failure has propagated. """ replica, actor = _spawn_running_replica( SlotReservationActor, "doomed-replica", max_ongoing_requests=1 ) # Confirm liveness via a successful reservation first. _, info = await replica.reserve_slot(_dummy_request_metadata()) assert info.accepted ray.kill(actor) with pytest.raises((ActorDiedError, ActorUnavailableError)): await replica.reserve_slot(_dummy_request_metadata()) if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))