Files
2026-07-13 13:17:40 +08:00

591 lines
23 KiB
Python

import asyncio
import concurrent.futures
import inspect
import logging
import pickle
import threading
import time
from abc import ABC, abstractmethod
from asyncio import run_coroutine_threadsafe
from functools import wraps
from typing import Any, AsyncIterator, Callable, Coroutine, Iterator, Optional, Union
import grpc
import ray
from ray.exceptions import ActorUnavailableError, RayTaskError, TaskCancelledError
from ray.serve._private.common import (
OBJ_REF_NOT_SUPPORTED_ERROR,
ReplicaQueueLengthInfo,
RequestMetadata,
)
from ray.serve._private.constants import SERVE_LOGGER_NAME
from ray.serve._private.http_util import MessageQueue
from ray.serve._private.serialization import RPCSerializer
from ray.serve._private.utils import calculate_remaining_timeout, generate_request_id
from ray.serve.exceptions import RequestCancelledError
from ray.serve.generated.serve_pb2 import ASGIResponse
logger = logging.getLogger(SERVE_LOGGER_NAME)
def is_running_in_asyncio_loop() -> bool:
try:
asyncio.get_running_loop()
return True
except RuntimeError:
return False
class ReplicaResult(ABC):
@abstractmethod
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
raise NotImplementedError
@abstractmethod
def get(self, timeout_s: Optional[float]):
raise NotImplementedError
@abstractmethod
async def get_async(self):
raise NotImplementedError
@abstractmethod
def __next__(self):
raise NotImplementedError
@abstractmethod
async def __anext__(self):
raise NotImplementedError
@abstractmethod
def add_done_callback(self, callback: Callable):
raise NotImplementedError
@abstractmethod
def cancel(self):
raise NotImplementedError
@abstractmethod
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
raise NotImplementedError
@abstractmethod
async def to_object_ref_async(self) -> ray.ObjectRef:
raise NotImplementedError
@abstractmethod
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
# NOTE(edoakes): there is only a sync version of this method because it
# does not block like `to_object_ref` (so there's also no timeout argument).
raise NotImplementedError
class ActorReplicaResult(ReplicaResult):
def __init__(
self,
obj_ref_or_gen: Union[ray.ObjectRef, ray.ObjectRefGenerator],
metadata: RequestMetadata,
*,
with_rejection: bool = False,
):
self._obj_ref: Optional[ray.ObjectRef] = None
self._obj_ref_gen: Optional[ray.ObjectRefGenerator] = None
self._is_streaming: bool = metadata.is_streaming
self._request_id: str = metadata.request_id
self._object_ref_or_gen_sync_lock = threading.Lock()
self._with_rejection = with_rejection
self._rejection_response = None
if isinstance(obj_ref_or_gen, ray.ObjectRefGenerator):
self._obj_ref_gen = obj_ref_or_gen
else:
self._obj_ref = obj_ref_or_gen
if self._is_streaming:
assert (
self._obj_ref_gen is not None
), "An ObjectRefGenerator must be passed for streaming requests."
request_context = ray.serve.context._get_serve_request_context()
if request_context.cancel_on_parent_request_cancel:
# Keep track of in-flight requests.
self._response_id = generate_request_id()
ray.serve.context._add_in_flight_request(
request_context._internal_request_id, self._response_id, self
)
self.add_done_callback(
lambda _: ray.serve.context._remove_in_flight_request(
request_context._internal_request_id, self._response_id
)
)
def _process_response(f: Union[Callable, Coroutine]):
@wraps(f)
def wrapper(self, *args, **kwargs):
try:
return f(self, *args, **kwargs)
except ray.exceptions.TaskCancelledError:
raise RequestCancelledError(self._request_id)
@wraps(f)
async def async_wrapper(self, *args, **kwargs):
try:
return await f(self, *args, **kwargs)
except ray.exceptions.TaskCancelledError:
raise asyncio.CancelledError()
if inspect.iscoroutinefunction(f):
return async_wrapper
else:
return wrapper
@_process_response
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
"""Get the queue length info from the replica to handle rejection."""
assert (
self._with_rejection and self._obj_ref_gen is not None
), "get_rejection_response() can only be called when request rejection is enabled."
try:
if self._rejection_response is None:
response = await (await self._obj_ref_gen.__anext__())
self._rejection_response = pickle.loads(response)
return self._rejection_response
except asyncio.CancelledError as e:
# HTTP client disconnected or request was explicitly canceled.
logger.info(
"Cancelling request that has already been assigned to a replica."
)
self.cancel()
raise e from None
except TaskCancelledError:
raise asyncio.CancelledError()
@_process_response
def get(self, timeout_s: Optional[float]):
assert (
not self._is_streaming
), "get() can only be called on a unary ActorReplicaResult."
start_time_s = time.time()
object_ref = self.to_object_ref(timeout_s=timeout_s)
remaining_timeout_s = calculate_remaining_timeout(
timeout_s=timeout_s,
start_time_s=start_time_s,
curr_time_s=time.time(),
)
return ray.get(object_ref, timeout=remaining_timeout_s)
@_process_response
async def get_async(self):
assert (
not self._is_streaming
), "get_async() can only be called on a unary ActorReplicaResult."
return await (await self.to_object_ref_async())
@_process_response
def __next__(self):
assert (
self._is_streaming
), "next() can only be called on a streaming ActorReplicaResult."
next_obj_ref = self._obj_ref_gen.__next__()
return ray.get(next_obj_ref)
@_process_response
async def __anext__(self):
assert (
self._is_streaming
), "__anext__() can only be called on a streaming ActorReplicaResult."
next_obj_ref = await self._obj_ref_gen.__anext__()
return await next_obj_ref
def add_done_callback(self, callback: Callable):
if self._obj_ref_gen is not None:
self._obj_ref_gen.completed()._on_completed(callback)
else:
self._obj_ref._on_completed(callback)
def cancel(self):
if self._obj_ref_gen is not None:
ray.cancel(self._obj_ref_gen)
else:
ray.cancel(self._obj_ref)
def to_object_ref(self, *, timeout_s: Optional[float] = None) -> ray.ObjectRef:
assert (
not self._is_streaming
), "to_object_ref can only be called on a unary ReplicaActorResult."
# NOTE(edoakes): this section needs to be guarded with a lock and the resulting
# object ref cached in order to avoid calling `__next__()` to
# resolve to the underlying object ref more than once.
# See: https://github.com/ray-project/ray/issues/43879.
with self._object_ref_or_gen_sync_lock:
if self._obj_ref is None:
obj_ref = self._obj_ref_gen._next_sync(timeout_s=timeout_s)
if obj_ref.is_nil():
raise TimeoutError("Timed out resolving to ObjectRef.")
self._obj_ref = obj_ref
return self._obj_ref
async def to_object_ref_async(self) -> ray.ObjectRef:
assert (
not self._is_streaming
), "to_object_ref_async can only be called on a unary ReplicaActorResult."
# NOTE(edoakes): this section needs to be guarded with a lock and the resulting
# object ref cached in order to avoid calling `__anext__()` to
# resolve to the underlying object ref more than once.
# See: https://github.com/ray-project/ray/issues/43879.
#
# IMPORTANT: We use a threading lock instead of asyncio.Lock because this method
# can be called from multiple event loops concurrently:
# 1. From the user's code (on the replica's event loop) when awaiting a response
# 2. From the router's event loop when resolving a DeploymentResponse argument
# asyncio.Lock is NOT thread-safe and NOT designed for cross-loop usage, which
# causes deadlocks.
#
# We use a non-blocking acquire pattern to avoid blocking the event loop:
# - Try to acquire the lock without blocking
# - If already held, yield and retry (allows other async tasks to run)
# - Once acquired, check if result is already available (double-check pattern)
while True:
# Fast path: already computed
if self._obj_ref is not None:
return self._obj_ref
acquired = self._object_ref_or_gen_sync_lock.acquire(blocking=False)
if acquired:
try:
# Double-check under lock
if self._obj_ref is None:
self._obj_ref = await self._obj_ref_gen.__anext__()
return self._obj_ref
finally:
self._object_ref_or_gen_sync_lock.release()
else:
# Lock is held by another task/thread, yield and retry
await asyncio.sleep(0)
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
assert (
self._is_streaming
), "to_object_ref_gen can only be called on a streaming ReplicaActorResult."
return self._obj_ref_gen
class gRPCReplicaResult(ReplicaResult):
def __init__(
self,
call: grpc.aio.Call,
metadata: RequestMetadata,
actor_id: ray.ActorID,
loop: asyncio.AbstractEventLoop = None,
*,
with_rejection: bool = False,
):
self._call: grpc.aio.Call = call
self._actor_id: ray.ActorID = actor_id
self._metadata: RequestMetadata = metadata # Store metadata for serialization
self._result_queue: MessageQueue = MessageQueue()
# This is the asyncio event loop that the gRPC Call object is attached to
self._grpc_call_loop = loop or asyncio._get_running_loop()
self._is_streaming = metadata.is_streaming
self._with_rejection = with_rejection
self._rejection_response = None
self._gen = None
self._fut = None
# NOTE(zcin): for now, these two concepts will be synonymous.
# In other words, using a queue means the router is running on
# a separate thread/event loop, and vice versa not using a queue
# means the router is running on the main event loop, where the
# DeploymentHandle lives.
self._calling_from_same_loop = not metadata._on_separate_loop
if hasattr(self._call, "__aiter__"):
self._gen = self._call.__aiter__()
# If the grpc call IS streaming, AND it was created on a
# a separate loop, then use a queue to fetch the objects
self._use_queue = metadata._on_separate_loop
else:
self._use_queue = False
# Start a background task that continuously fetches from the
# streaming grpc call. This way callbacks will actually be
# called when the request finishes even without the user
# explicitly consuming the response.
self._consume_task = None
if self._use_queue:
self._consume_task = self._grpc_call_loop.create_task(
self.consume_messages_from_gen()
)
# Keep track of in-flight requests.
self._response_id = generate_request_id()
request_context = ray.serve.context._get_serve_request_context()
ray.serve.context._add_in_flight_request(
request_context._internal_request_id, self._response_id, self
)
self.add_done_callback(
lambda _: ray.serve.context._remove_in_flight_request(
request_context._internal_request_id, self._response_id
)
)
def _process_grpc_response(f: Union[Callable, Coroutine]):
def deserialize_or_raise_error(
grpc_response: ASGIResponse,
metadata: RequestMetadata,
):
# Create serializer with options from metadata
serializer = RPCSerializer(
metadata.request_serialization,
metadata.response_serialization,
)
if grpc_response.is_error:
err = serializer.loads_response(grpc_response.serialized_message)
if isinstance(err, RayTaskError):
raise err.as_instanceof_cause()
else:
raise err
else:
# If it's an HTTP request, then the proxy response generator is
# expecting a pickled dictionary, so we return result directly
# without deserializing. Otherwise, we deserialize the result.
if ray.serve.context._get_serve_request_context().is_http_request:
return grpc_response.serialized_message
else:
return serializer.loads_response(grpc_response.serialized_message)
@wraps(f)
def wrapper(self, *args, **kwargs):
try:
grpc_response = f(self, *args, **kwargs)
except grpc.aio.AioRpcError as e:
if e.code() == grpc.StatusCode.UNAVAILABLE:
raise ActorUnavailableError(
"Actor is unavailable.",
self._actor_id.binary(),
)
raise
except concurrent.futures.CancelledError:
raise RequestCancelledError from None
return deserialize_or_raise_error(grpc_response, self._metadata)
@wraps(f)
async def async_wrapper(self, *args, **kwargs):
try:
grpc_response = await f(self, *args, **kwargs)
except grpc.aio.AioRpcError as e:
if e.code() == grpc.StatusCode.UNAVAILABLE:
raise ActorUnavailableError(
"Actor is unavailable.",
self._actor_id.binary(),
)
raise
return deserialize_or_raise_error(grpc_response, self._metadata)
if inspect.iscoroutinefunction(f):
return async_wrapper
else:
return wrapper
def __aiter__(self) -> AsyncIterator[Any]:
return self
def __iter__(self) -> Iterator[Any]:
return self
async def consume_messages_from_gen(self):
try:
async for resp in self._gen:
self._result_queue.put_nowait(resp)
except BaseException as e:
self._result_queue.set_error(e)
finally:
self._result_queue.close()
async def _get_internal(self):
"""Gets the result from the gRPC call object.
If the call object is a UnaryUnaryCall, we await the call.
Otherwise the call object is a UnaryStreamCall.
- If the request was sent on a separate loop, then the
streamed results are being consumed and put onto the in-memory
queue, so we read from that queue.
- Otherwise the request was sent on the current loop, so we
fetch the next object from the async generator.
"""
if self._gen is None:
return await self._call
elif self._use_queue:
return await self._result_queue.get_one_message()
else:
return await self._gen.__anext__()
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
"""Get the queue length info from the replica to handle rejection."""
assert (
self._with_rejection
), "get_rejection_response() can only be called when request rejection is enabled."
try:
if self._rejection_response is None:
# NOTE(edoakes): this is required for gRPC to raise an AioRpcError if something
# goes wrong establishing the connection (for example, a bug in our code).
await self._call.wait_for_connection()
metadata = await self._call.initial_metadata()
accepted = metadata.get("accepted", None)
num_ongoing_requests = metadata.get("num_ongoing_requests", None)
if accepted is None or num_ongoing_requests is None:
code = await self._call.code()
details = await self._call.details()
raise RuntimeError(f"Unexpected error ({code}): {details}.")
self._rejection_response = ReplicaQueueLengthInfo(
accepted=bool(int(accepted)),
num_ongoing_requests=int(num_ongoing_requests),
)
return self._rejection_response
except asyncio.CancelledError as e:
# HTTP client disconnected or request was explicitly canceled.
logger.info(
"Cancelling request that has already been assigned to a replica."
)
self.cancel()
raise e from None
except grpc.aio.AioRpcError as e:
# If we received an `UNAVAILABLE` grpc error, that is
# equivalent to `RayActorError`, although we don't know
# whether it's `ActorDiedError` or `ActorUnavailableError`.
# Conservatively, we assume it is `ActorUnavailableError`,
# and we raise it here so that it goes through the unified
# code path for handling RayActorErrors.
# The router will retry scheduling the request with the
# cache invalidated, at which point if the actor is actually
# dead, the router will realize through active probing.
if not self._is_streaming:
# In UnaryUnary calls, initial metadata is sent back with the request
# response, so we can't determine if the request was accepted until
# after the request is handled. If the replica crashed while handling
# the request, we can still get initial metadata via the AioRpcError,
# since the server sets the metadata before handling the request.
# If there is no metadata, we know the replica was already unavailable
# prior to the request being sent. We only raise an ActorUnavailableError
# (and thus retry the request) if the request was rejected or if the
# replica was already unavailable.
metadata = e.initial_metadata()
accepted = metadata.get("accepted", None)
if accepted is not None and bool(int(accepted)):
num_ongoing_requests = metadata.get("num_ongoing_requests", None)
if num_ongoing_requests is None:
raise RuntimeError(
f"Unexpected error ({e.code()}): {e.details()}."
)
return ReplicaQueueLengthInfo(
accepted=True,
num_ongoing_requests=int(num_ongoing_requests),
)
# Peer-sent CANCELLED means the replica's gRPC server cancelled
# the call before the handler ran (graceful-shutdown window).
# Without `accepted` metadata (checked above) the request never
# executed, so it is safe to retry, same as UNAVAILABLE. A local
# cancellation raises asyncio.CancelledError, not AioRpcError.
if e.code() in (
grpc.StatusCode.UNAVAILABLE,
grpc.StatusCode.CANCELLED,
):
raise ActorUnavailableError(
"Actor is unavailable.",
self._actor_id.binary(),
)
raise e from None
@_process_grpc_response
def get(self, timeout_s: Optional[float]):
if is_running_in_asyncio_loop():
raise RuntimeError(
"Sync method `get()` should not be called from within an `asyncio` "
"event loop. Use `get_async()` instead."
)
if self._fut is None:
self._fut = run_coroutine_threadsafe(
self._get_internal(), self._grpc_call_loop
)
try:
return self._fut.result(timeout=timeout_s)
except concurrent.futures.TimeoutError:
raise TimeoutError("Timed out waiting for result.") from None
@_process_grpc_response
async def get_async(self):
if self._fut is None:
if self._calling_from_same_loop:
return await self._get_internal()
else:
self._fut = run_coroutine_threadsafe(
self._get_internal(), self._grpc_call_loop
)
return await asyncio.wrap_future(self._fut)
@_process_grpc_response
def __next__(self):
if is_running_in_asyncio_loop():
raise RuntimeError(
"Sync method `__next__()` should not be called from within an "
"`asyncio` event loop. Use `__anext__()` instead."
)
fut = run_coroutine_threadsafe(self._get_internal(), loop=self._grpc_call_loop)
try:
return fut.result()
except StopAsyncIteration:
# We need to raise the synchronous version, StopIteration
raise StopIteration
@_process_grpc_response
async def __anext__(self):
if self._calling_from_same_loop:
return await self._get_internal()
else:
fut = run_coroutine_threadsafe(
self._get_internal(), loop=self._grpc_call_loop
)
return await asyncio.wrap_future(fut)
def add_done_callback(self, callback: Callable):
self._call.add_done_callback(callback)
def cancel(self):
self._call.cancel()
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
raise OBJ_REF_NOT_SUPPORTED_ERROR
async def to_object_ref_async(self) -> ray.ObjectRef:
raise OBJ_REF_NOT_SUPPORTED_ERROR
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
raise OBJ_REF_NOT_SUPPORTED_ERROR