459 lines
16 KiB
Python
459 lines
16 KiB
Python
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__]))
|