327 lines
12 KiB
Python
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__]))
|