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

327 lines
12 KiB
Python

import asyncio
import pickle
import sys
import grpc
import pytest
import ray
from ray import cloudpickle
from ray._common.test_utils import SignalActor
from ray._common.utils import get_or_create_event_loop
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.generated import serve_pb2, serve_pb2_grpc
from ray.serve.tests.conftest import * # noqa
@ray.remote(num_cpus=0)
class FakeReplicaActor:
def __init__(self):
self._replica_queue_length_info = None
self._server = grpc.aio.server()
async def start(self):
serve_pb2_grpc.add_ASGIServiceServicer_to_server(self, self._server)
self._internal_grpc_port = self._server.add_insecure_port("[::]:0")
await self._server.start()
return self._internal_grpc_port
def set_replica_queue_length_info(self, info: ReplicaQueueLengthInfo):
self._replica_queue_length_info = info
async def HandleRequest(
self,
request: serve_pb2.ASGIRequest,
context: grpc.aio.ServicerContext,
):
args = cloudpickle.loads(request.request_args)
return serve_pb2.ASGIResponse(serialized_message=cloudpickle.dumps(args[0]))
async def HandleRequestStreaming(
self,
request: serve_pb2.ASGIRequest,
context: grpc.aio.ServicerContext,
):
request_metadata = pickle.loads(request.pickled_request_metadata)
args = cloudpickle.loads(request.request_args)
message = args[0]
for i in range(5):
if request_metadata.is_http_request:
yield serve_pb2.ASGIResponse(serialized_message=f"{message}-{i}")
else:
yield serve_pb2.ASGIResponse(
serialized_message=cloudpickle.dumps(f"{message}-{i}")
)
async def HandleRequestWithRejection(
self,
request: serve_pb2.ASGIRequest,
context: grpc.aio.ServicerContext,
):
args = cloudpickle.loads(request.request_args)
kwargs = cloudpickle.loads(request.request_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
await context.send_initial_metadata(
[
("accepted", str(int(self._replica_queue_length_info.accepted))),
(
"num_ongoing_requests",
str(self._replica_queue_length_info.num_ongoing_requests),
),
]
)
if not self._replica_queue_length_info.accepted:
# NOTE(edoakes): in gRPC, it's not guaranteed that the initial metadata sent
# by the server will be delivered for a stream with no messages. Therefore,
# we send a dummy message here to ensure it is populated in every case.
# The same is done in the actual gRPC replica implementation.
return serve_pb2.ASGIResponse(serialized_message=b"")
message = args[0]
return serve_pb2.ASGIResponse(serialized_message=cloudpickle.dumps(message))
async def HandleRequestWithRejectionStreaming(
self,
request: serve_pb2.ASGIRequest,
context: grpc.aio.ServicerContext,
):
request_metadata = pickle.loads(request.pickled_request_metadata)
args = cloudpickle.loads(request.request_args)
kwargs = cloudpickle.loads(request.request_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
await context.send_initial_metadata(
[
("accepted", str(int(self._replica_queue_length_info.accepted))),
(
"num_ongoing_requests",
str(self._replica_queue_length_info.num_ongoing_requests),
),
]
)
if not self._replica_queue_length_info.accepted:
# NOTE(edoakes): in gRPC, it's not guaranteed that the initial metadata sent
# by the server will be delivered for a stream with no messages. Therefore,
# we send a dummy message here to ensure it is populated in every case.
# The same is done in the actual gRPC replica implementation.
yield serve_pb2.ASGIResponse(serialized_message=b"")
return
message = args[0]
for i in range(5):
if request_metadata.is_http_request:
yield serve_pb2.ASGIResponse(
serialized_message=pickle.dumps(f"{message}-{i}")
)
else:
yield serve_pb2.ASGIResponse(
serialized_message=cloudpickle.dumps(f"{message}-{i}")
)
@pytest.fixture
def setup_fake_replica(ray_instance, request) -> RunningReplicaInfo:
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()
actor_handle = FakeReplicaActor.options(
name=actor_name, namespace=SERVE_NAMESPACE, lifetime="detached"
).remote()
port = ray.get(actor_handle.start.remote())
return RunningReplicaInfo(
replica_id=replica_id,
node_id=None,
# Just use local node IP
node_ip="127.0.0.1",
availability_zone=None,
actor_name=actor_name,
max_ongoing_requests=10,
is_cross_language=False,
# Get grpc port from FakeReplicaActor
port=port,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_streaming", [False, True])
@pytest.mark.parametrize("on_separate_loop", [True, False])
async def test_to_object_ref_not_supported(
setup_fake_replica: RunningReplicaInfo, is_streaming: bool, on_separate_loop: 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,
_by_reference=False, # use gRPC transport
_on_separate_loop=on_separate_loop,
),
)
err_msg = "Converting by-value DeploymentResponses to ObjectRefs is not supported."
replica_result = replica.try_send_request(pr, with_rejection=False)
if is_streaming:
with pytest.raises(RuntimeError, match=err_msg):
replica_result.to_object_ref_gen()
else:
with pytest.raises(RuntimeError, match=err_msg):
await replica_result.to_object_ref_async()
with pytest.raises(RuntimeError, match=err_msg):
replica_result.to_object_ref(timeout_s=0.0)
@pytest.mark.asyncio
@pytest.mark.parametrize("is_streaming", [False, True])
@pytest.mark.parametrize("on_separate_loop", [True, False])
async def test_send_request(
setup_fake_replica: RunningReplicaInfo, is_streaming: bool, on_separate_loop: 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,
_by_reference=False, # use gRPC transport
_on_separate_loop=on_separate_loop,
),
)
replica_result = replica.try_send_request(pr, with_rejection=False)
if is_streaming:
for i in range(5):
assert await replica_result.__anext__() == f"Hello-{i}"
else:
assert await replica_result.get_async() == "Hello"
@pytest.mark.asyncio
@pytest.mark.parametrize("accepted", [False, True])
@pytest.mark.parametrize("is_streaming", [False, True])
@pytest.mark.parametrize("on_separate_loop", [True, False])
async def test_send_request_with_rejection(
setup_fake_replica: RunningReplicaInfo,
accepted: bool,
is_streaming: bool,
on_separate_loop: 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,
_by_reference=False, # use gRPC transport
_on_separate_loop=on_separate_loop,
),
)
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:
for i in range(5):
assert await replica_result.__anext__() == f"Hello-{i}"
else:
assert await replica_result.__anext__() == "Hello"
@pytest.mark.asyncio
@pytest.mark.parametrize("on_separate_loop", [True, False])
async def test_send_request_with_rejection_cancellation(
setup_fake_replica: RunningReplicaInfo, on_separate_loop: bool
):
"""
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",
_by_reference=False, # use gRPC transport
_on_separate_loop=on_separate_loop,
),
)
# 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()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))