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

2084 lines
86 KiB
Python

import asyncio
import concurrent.futures
import logging
import sys
import threading
import time
import weakref
from abc import ABC, abstractmethod
from asyncio import AbstractEventLoop, ensure_future, futures
from collections import defaultdict
from collections.abc import MutableMapping
from contextlib import asynccontextmanager, contextmanager
from dataclasses import replace
from functools import lru_cache, partial
from typing import (
Any,
AsyncIterator,
Callable,
Coroutine,
DefaultDict,
Dict,
List,
Optional,
Tuple,
Union,
)
import ray
from ray.actor import ActorHandle
from ray.exceptions import (
ActorDiedError,
ActorUnavailableError,
RayError,
RayTaskError,
TaskCancelledError,
)
from ray.serve._private.common import (
RUNNING_REQUESTS_KEY,
DeploymentHandleSource,
DeploymentID,
DeploymentTargetInfo,
HandleMetricReport,
ReplicaID,
RequestMetadata,
RunningReplicaInfo,
)
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import (
DEFAULT_LATENCY_BUCKET_MS,
RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR,
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
RAY_SERVE_METRICS_EXPORT_INTERVAL_MS,
RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING,
SERVE_LOGGER_NAME,
)
from ray.serve._private.constants_utils import warn_if_deprecated_env_var_set
from ray.serve._private.event_loop_monitoring import EventLoopMonitor
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
from ray.serve._private.metrics_utils import (
QUEUED_REQUESTS_KEY,
InMemoryMetricsStore,
MetricsPusher,
TimeStampedValue,
)
from ray.serve._private.replica_result import ReplicaResult
from ray.serve._private.request_router import PendingRequest, RequestRouter
from ray.serve._private.request_router.pow_2_router import (
PowerOfTwoChoicesRequestRouter,
)
from ray.serve._private.request_router.replica_wrapper import (
ReplicaSelection,
RunningReplica,
)
from ray.serve._private.tracing_utils import (
create_propagated_context,
is_span_recording,
set_http_span_attributes,
set_rpc_span_attributes,
set_span_attributes,
set_span_exception,
set_span_name,
tracing_decorator_factory,
)
from ray.serve._private.usage import ServeUsageTag
from ray.serve._private.utils import (
check_obj_ref_ready_nowait,
compress_metric_report,
generate_request_id,
resolve_deployment_response,
)
from ray.serve.config import AutoscalingConfig
from ray.serve.exceptions import (
BackPressureError,
DeploymentUnavailableError,
RayServeException,
ReplicaUnavailableError,
)
from ray.types import ObjectRef
from ray.util import metrics
logger = logging.getLogger(SERVE_LOGGER_NAME)
class RouterMetricsManager:
"""Manages metrics for the router."""
PUSH_METRICS_TO_CONTROLLER_TASK_NAME = "push_metrics_to_controller"
RECORD_METRICS_TASK_NAME = "record_metrics"
def __init__(
self,
deployment_id: DeploymentID,
handle_id: str,
self_actor_id: str,
handle_source: DeploymentHandleSource,
controller_handle: ActorHandle,
router_requests_counter: metrics.Counter,
queued_requests_gauge: metrics.Gauge,
running_requests_gauge: metrics.Gauge,
event_loop: asyncio.BaseEventLoop,
):
self._handle_id = handle_id
self._deployment_id = deployment_id
self._self_actor_id = self_actor_id
self._handle_source = handle_source
self._controller_handle = controller_handle
# Exported metrics
self.num_router_requests = router_requests_counter
self.num_router_requests.set_default_tags(
{
"deployment": deployment_id.name,
"application": deployment_id.app_name,
"handle": self._handle_id,
"actor_id": self._self_actor_id,
}
)
self.num_queued_requests = 0
self.num_queued_requests_gauge = queued_requests_gauge
self.num_queued_requests_gauge.set_default_tags(
{
"deployment": deployment_id.name,
"application": deployment_id.app_name,
"handle": self._handle_id,
"actor_id": self._self_actor_id,
}
)
self.num_queued_requests_gauge.set(0)
# Track queries sent to replicas for the autoscaling algorithm.
self.num_requests_sent_to_replicas: DefaultDict[ReplicaID, int] = defaultdict(
int
)
self.num_running_requests_gauge = running_requests_gauge
self.num_running_requests_gauge.set_default_tags(
{
"deployment": deployment_id.name,
"application": deployment_id.app_name,
"handle": self._handle_id,
"actor_id": self._self_actor_id,
}
)
# We use Ray object ref callbacks to update state when tracking
# number of requests running on replicas. The callbacks will be
# called from a C++ thread into the router's async event loop,
# so non-atomic read and write operations need to be guarded by
# this thread-safe lock.
self._queries_lock = threading.Lock()
# Track reserved slots for choose_replica operations
self._num_reserved_slots = 0
self._reserved_slots_gauge = metrics.Gauge(
"serve_reserved_slots_active",
description=(
"The current number of reserved slots for choose_replica operations."
),
tag_keys=("deployment", "application", "handle", "actor_id"),
)
self._reserved_slots_gauge.set_default_tags(
{
"deployment": deployment_id.name,
"application": deployment_id.app_name,
"handle": self._handle_id,
"actor_id": self._self_actor_id,
}
)
self._reserved_slots_gauge.set(0)
# Regularly aggregate and push autoscaling metrics to controller
self.metrics_pusher = MetricsPusher()
self.metrics_store = InMemoryMetricsStore()
# The config for the deployment this router sends requests to will be broadcast
# by the controller. That means it is not available until we get the first
# update. This includes an optional autoscaling config.
self._deployment_config: Optional[DeploymentConfig] = None
# Track whether the metrics manager has been shutdown
self._shutdown: bool = False
# Tracks in-flight metrics push to controller. Skip if new one is sent.
self._pending_metrics_push_ref: Optional[ObjectRef] = None
self._metrics_push_lock = threading.Lock()
# If the interval is set to 0, eagerly sets all metrics.
self._cached_metrics_enabled = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS != 0
self._cached_metrics_interval_s = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS / 1000
self._cached_metrics_task: Optional[asyncio.Task] = None
if self._cached_metrics_enabled:
self._cached_num_router_requests = defaultdict(int)
def create_metrics_task():
self._cached_metrics_task = event_loop.create_task(
self._report_cached_metrics_forever()
)
# the constructor is called in the user thread, but its trying to create a task on the event loop
# which is running in the router thread. This is not thread safe, so we need to use call_soon_threadsafe
# to create the task on the event loop thread safely.
event_loop.call_soon_threadsafe(create_metrics_task)
@contextmanager
def wrap_request_assignment(self, request_meta: RequestMetadata):
max_queued_requests = (
self._deployment_config.max_queued_requests
if self._deployment_config is not None
else -1
)
if (
max_queued_requests != -1
and self.num_queued_requests >= max_queued_requests
):
# Due to the async nature of request handling, we may reject more requests
# than strictly necessary. This is more likely to happen during
# high concurrency. Here's why:
#
# When multiple requests arrive simultaneously with max_queued_requests=1:
# 1. First request increments num_queued_requests to 1
# 2. Before that request gets assigned to a replica and decrements the counter,
# we yield to the event loop
# 3. Other requests see num_queued_requests=1 and get rejected, even though
# the first request will soon free up the queue slot
#
# For example, with max_queued_requests=1 and 4 simultaneous requests:
# - Request 1 gets queued (num_queued_requests=1)
# - Requests 2,3,4 get rejected since queue appears full
# - Request 1 gets assigned and frees queue slot (num_queued_requests=0)
# - But we already rejected Request 2 which could have been queued
e = BackPressureError(
num_queued_requests=self.num_queued_requests,
max_queued_requests=max_queued_requests,
)
logger.warning(e.message)
raise e
self.inc_num_total_requests(request_meta.route)
yield
@contextmanager
def wrap_queued_request(self, is_retry: bool, num_curr_replicas: int):
"""Increment queued requests gauge and maybe push autoscaling metrics to controller."""
try:
self.inc_num_queued_requests()
# Optimization: if there are currently zero replicas for a deployment,
# push handle metric to controller to allow for fast cold start time.
# Only do this on the first attempt to route the request.
if not is_retry and self.should_send_scaled_to_zero_optimized_push(
curr_num_replicas=num_curr_replicas
):
self.push_autoscaling_metrics_to_controller()
yield
finally:
# If the request is disconnected before assignment, this coroutine
# gets cancelled by the caller and an asyncio.CancelledError is
# raised. The finally block ensures that num_queued_requests
# is correctly decremented in this case.
self.dec_num_queued_requests()
def _update_running_replicas(self, running_replicas: List[RunningReplicaInfo]):
"""Prune list of replica ids in self.num_queries_sent_to_replicas.
We want to avoid self.num_queries_sent_to_replicas from growing
in memory as the deployment upscales and downscales over time.
"""
running_replica_set = {replica.replica_id for replica in running_replicas}
with self._queries_lock:
self.num_requests_sent_to_replicas = defaultdict(
int,
{
id: self.num_requests_sent_to_replicas[id]
for id, num_queries in self.num_requests_sent_to_replicas.items()
if num_queries or id in running_replica_set
},
)
@property
def autoscaling_config(self) -> Optional[AutoscalingConfig]:
if self._deployment_config is None:
return None
return self._deployment_config.autoscaling_config
def update_deployment_config(
self, deployment_config: DeploymentConfig, curr_num_replicas: int
):
"""Update the config for the deployment this router sends requests to."""
if self._shutdown:
return
self._deployment_config = deployment_config
# Start the metrics pusher if autoscaling is enabled.
autoscaling_config = self.autoscaling_config
if autoscaling_config:
self.metrics_pusher.start()
# Optimization for autoscaling cold start time. If there are
# currently 0 replicas for the deployment, and there is at
# least one queued request on this router, then immediately
# push handle metric to the controller.
if self.should_send_scaled_to_zero_optimized_push(curr_num_replicas):
self.push_autoscaling_metrics_to_controller()
# Record number of queued + ongoing requests at regular
# intervals into the in-memory metrics store
record_interval_s = (
autoscaling_config.look_back_period_s
* RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR
)
self.metrics_pusher.register_or_update_task(
self.RECORD_METRICS_TASK_NAME,
self._add_autoscaling_metrics_point,
min(record_interval_s, autoscaling_config.metrics_interval_s),
)
# Push metrics to the controller periodically.
self.metrics_pusher.register_or_update_task(
self.PUSH_METRICS_TO_CONTROLLER_TASK_NAME,
self.push_autoscaling_metrics_to_controller,
autoscaling_config.metrics_interval_s,
)
else:
if self.metrics_pusher:
self.metrics_pusher.stop_tasks()
def _report_cached_metrics(self):
for route, count in self._cached_num_router_requests.items():
self.num_router_requests.inc(count, tags={"route": route})
self._cached_num_router_requests.clear()
self.num_queued_requests_gauge.set(self.num_queued_requests)
self.num_running_requests_gauge.set(
sum(self.num_requests_sent_to_replicas.values())
)
async def _report_cached_metrics_forever(self):
assert self._cached_metrics_interval_s > 0
consecutive_errors = 0
while True:
try:
await asyncio.sleep(self._cached_metrics_interval_s)
self._report_cached_metrics()
consecutive_errors = 0
except Exception:
logger.exception("Unexpected error reporting metrics.")
# Exponential backoff starting at 1s and capping at 10s.
backoff_time_s = min(10, 2**consecutive_errors)
consecutive_errors += 1
await asyncio.sleep(backoff_time_s)
def inc_num_total_requests(self, route: str):
if self._cached_metrics_enabled:
self._cached_num_router_requests[route] += 1
else:
self.num_router_requests.inc(tags={"route": route})
def inc_num_queued_requests(self):
self.num_queued_requests += 1
if not self._cached_metrics_enabled:
self.num_queued_requests_gauge.set(self.num_queued_requests)
def dec_num_queued_requests(self):
self.num_queued_requests -= 1
if not self._cached_metrics_enabled:
self.num_queued_requests_gauge.set(self.num_queued_requests)
def inc_reserved_slots(self):
self._num_reserved_slots += 1
self._reserved_slots_gauge.set(self._num_reserved_slots)
def dec_reserved_slots(self):
self._num_reserved_slots -= 1
self._reserved_slots_gauge.set(self._num_reserved_slots)
def inc_num_running_requests_for_replica(self, replica_id: ReplicaID):
with self._queries_lock:
self.num_requests_sent_to_replicas[replica_id] += 1
if not self._cached_metrics_enabled:
self.num_running_requests_gauge.set(
sum(self.num_requests_sent_to_replicas.values())
)
def dec_num_running_requests_for_replica(self, replica_id: ReplicaID):
with self._queries_lock:
self.num_requests_sent_to_replicas[replica_id] -= 1
if not self._cached_metrics_enabled:
self.num_running_requests_gauge.set(
sum(self.num_requests_sent_to_replicas.values())
)
def should_send_scaled_to_zero_optimized_push(self, curr_num_replicas: int) -> bool:
return (
self.autoscaling_config is not None
and curr_num_replicas == 0
and self.num_queued_requests > 0
)
def push_autoscaling_metrics_to_controller(self):
"""Pushes queued and running request metrics to the controller.
These metrics are used by the controller for autoscaling.
If a previous push is already in flight, skips this push (will try again next interval).
"""
with self._metrics_push_lock:
if self._pending_metrics_push_ref is not None:
if not check_obj_ref_ready_nowait(self._pending_metrics_push_ref):
return # Previous push still in flight, skip and try again later
self._pending_metrics_push_ref = (
self._controller_handle.record_autoscaling_metrics_from_handle.remote(
compress_metric_report(self._get_metrics_report())
)
)
def _add_autoscaling_metrics_point(self):
"""Adds metrics point for queued and running requests at replicas.
Also prunes keys in the in memory metrics store with outdated datapoints.
┌─────────────────────────────────────────────────────────────────┐
│ Handle-based metrics collection │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Client Handle Replicas │
│ ┌──────┐ ┌────────┐ ┌─────────┐ │
│ │ App │───────────>│ Handle │─────────>│ Replica │ │
│ │ │ Requests │ │ Forwards │ 1 │ │
│ └──────┘ │ Tracks │ └─────────┘ │
│ │ Queued │ │
│ │ + │ ┌─────────┐ │
│ │Running │─────────>│ Replica │ │
│ │Requests│ Forwards │ 2 │ │
│ └────────┘ └─────────┘ │
│ │ │
│ │ Push metrics │
│ └─────────────────> Controller │
│ │
└─────────────────────────────────────────────────────────────────┘
:::{note}
The long-term plan is to deprecate handle-based metrics collection in favor of
replica-based collection. Replica-based collection will become the default in a
future release. Queued requests will be continues to be tracked at the handle.
:::
"""
timestamp = time.time()
self.metrics_store.add_metrics_point(
{QUEUED_REQUESTS_KEY: self.num_queued_requests}, timestamp
)
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
self.metrics_store.add_metrics_point(
self.num_requests_sent_to_replicas, timestamp
)
# Prevent in memory metrics store memory from growing
start_timestamp = time.time() - self.autoscaling_config.look_back_period_s
self.metrics_store.prune_keys_and_compact_data(start_timestamp)
def _get_metrics_report(self) -> HandleMetricReport:
timestamp = time.time()
running_requests = dict()
avg_running_requests = dict()
look_back_period = self.autoscaling_config.look_back_period_s
self.metrics_store.prune_keys_and_compact_data(time.time() - look_back_period)
avg_queued_requests = self.metrics_store.aggregate_avg([QUEUED_REQUESTS_KEY])[0]
if avg_queued_requests is None:
# If the queued requests timeseries is empty, we set the
# average to the current number of queued requests.
avg_queued_requests = self.num_queued_requests
# If the queued requests timeseries is empty, we set the number of data points to 1.
# This is to avoid division by zero.
num_data_points = self.metrics_store.timeseries_count(QUEUED_REQUESTS_KEY) or 1
queued_requests = self.metrics_store.data.get(
QUEUED_REQUESTS_KEY, [TimeStampedValue(timestamp, self.num_queued_requests)]
)
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE and self.autoscaling_config:
for replica_id, num_requests in self.num_requests_sent_to_replicas.items():
# Calculate avg running requests.
# NOTE (abrar): The number of data points from queued requests is often higher than
# those from running requests. This is because replica metrics are only collected
# once a replica is up, whereas queued request metrics are collected continuously
# as long as the handle is alive. To approximate the true average of ongoing requests,
# we should normalize by using the same number of data points for both queued and
# running request time series.
running_requests_sum = self.metrics_store.aggregate_sum([replica_id])[0]
if running_requests_sum is None:
# If the running requests timeseries is empty, we set the sum
# to the current number of requests.
running_requests_sum = num_requests
replica_str = replica_id.to_full_id_str()
avg_running_requests[replica_str] = (
running_requests_sum / num_data_points
)
# Get running requests data
running_requests[replica_str] = self.metrics_store.data.get(
replica_id, [TimeStampedValue(timestamp, num_requests)]
)
handle_metric_report = HandleMetricReport(
deployment_id=self._deployment_id,
handle_id=self._handle_id,
actor_id=self._self_actor_id,
handle_source=self._handle_source,
aggregated_queued_requests=avg_queued_requests,
queued_requests=queued_requests,
aggregated_metrics={
RUNNING_REQUESTS_KEY: avg_running_requests,
},
metrics={
RUNNING_REQUESTS_KEY: running_requests,
},
timestamp=timestamp,
)
return handle_metric_report
async def shutdown(self):
"""Shutdown metrics manager gracefully."""
if self.metrics_pusher:
await self.metrics_pusher.graceful_shutdown()
self._shutdown = True
if self._cached_metrics_task is not None:
self._cached_metrics_task.cancel()
try:
await self._cached_metrics_task
except asyncio.CancelledError:
pass
class Router(ABC):
@property
def event_loop(self) -> Optional[AbstractEventLoop]:
"""The event loop the router runs on, or None (e.g. local testing)."""
return getattr(self, "_asyncio_loop", None)
@abstractmethod
def running_replicas_populated(self) -> bool:
pass
@abstractmethod
def assign_request(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> concurrent.futures.Future[ReplicaResult]:
pass
@abstractmethod
@asynccontextmanager
async def choose_replica(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> AsyncIterator[ReplicaSelection]:
yield
@abstractmethod
def dispatch(
self,
selection: ReplicaSelection,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> concurrent.futures.Future[ReplicaResult]:
pass
@abstractmethod
async def broadcast(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> List[ReplicaResult]:
pass
@abstractmethod
def shutdown(self) -> concurrent.futures.Future:
pass
async def create_event() -> asyncio.Event:
"""Helper to create an asyncio event in the current event loop."""
return asyncio.Event()
class AsyncioRouter:
def __init__(
self,
controller_handle: ActorHandle,
deployment_id: DeploymentID,
handle_id: str,
self_actor_id: str,
handle_source: DeploymentHandleSource,
event_loop: asyncio.BaseEventLoop,
enable_strict_max_ongoing_requests: bool,
node_id: str,
availability_zone: Optional[str],
prefer_local_node_routing: bool,
resolve_request_arg_func: Coroutine = resolve_deployment_response,
request_router_class: Optional[Callable] = None,
request_router_kwargs: Optional[Dict[str, Any]] = None,
request_router: Optional[RequestRouter] = None,
_request_router_initialized_event: Optional[asyncio.Event] = None,
):
"""Used to assign requests to downstream replicas for a deployment.
The routing behavior is delegated to a RequestRouter; this is a thin
wrapper that adds metrics and logging.
"""
self._controller_handle = controller_handle
self.deployment_id = deployment_id
self._self_actor_id = self_actor_id
self._handle_source = handle_source
self._event_loop = event_loop
self._request_router_class = request_router_class
self._request_router_kwargs = (
request_router_kwargs if request_router_kwargs else {}
)
self._enable_strict_max_ongoing_requests = enable_strict_max_ongoing_requests
self._node_id = node_id
self._availability_zone = availability_zone
self._prefer_local_node_routing = prefer_local_node_routing
# By default, deployment is available unless we receive news
# otherwise through a long poll broadcast from the controller.
self._deployment_available = True
# The request router will be lazy loaded to decouple form the initialization.
self._request_router: Optional[RequestRouter] = request_router
if _request_router_initialized_event:
self._request_router_initialized = _request_router_initialized_event
else:
future = asyncio.run_coroutine_threadsafe(create_event(), self._event_loop)
self._request_router_initialized = future.result()
if self._request_router:
self._request_router_initialized.set()
self._resolve_request_arg_func = resolve_request_arg_func
self._running_replicas: Optional[List[RunningReplicaInfo]] = None
# Flipped to `True` once the router has received a non-empty
# replica set at least once.
self._running_replicas_populated: bool = False
self._initial_backoff_s: Optional[float] = None
self._backoff_multiplier: Optional[float] = None
self._max_backoff_s: Optional[float] = None
# Initializing `self._metrics_manager` before `self.long_poll_client` is
# necessary to avoid race condition where `self.update_deployment_config()`
# might be called before `self._metrics_manager` instance is created.
self._metrics_manager = RouterMetricsManager(
deployment_id,
handle_id,
self_actor_id,
handle_source,
controller_handle,
metrics.Counter(
"serve_num_router_requests",
description="The number of requests processed by the router.",
tag_keys=("deployment", "route", "application", "handle", "actor_id"),
),
metrics.Gauge(
"serve_deployment_queued_queries",
description=(
"The current number of queries to this deployment waiting"
" to be assigned to a replica."
),
tag_keys=("deployment", "application", "handle", "actor_id"),
),
metrics.Gauge(
"serve_num_ongoing_requests_at_replicas",
description=(
"The current number of requests to this deployment that "
"have been submitted to a replica."
),
tag_keys=("deployment", "application", "handle", "actor_id"),
),
event_loop,
)
self._objref_resolution_latency_ms = metrics.Histogram(
"serve_router_args_resolution_latency_ms",
description=(
"Time in milliseconds spent resolving upstream ObjectRef or "
"DeploymentResponse arguments before a request enters the "
"routing queue."
),
boundaries=DEFAULT_LATENCY_BUCKET_MS,
tag_keys=("deployment", "application", "handle", "actor_id"),
).set_default_tags(
{
"deployment": deployment_id.name,
"application": deployment_id.app_name,
"handle": handle_id,
"actor_id": self_actor_id,
}
)
# The Router needs to stay informed about changes to the target deployment's
# running replicas and deployment config. We do this via the long poll system.
# However, for efficiency, we don't want to create a LongPollClient for every
# DeploymentHandle, so we use a shared LongPollClient that all Routers
# register themselves with. But first, the router needs to get a fast initial
# update so that it can start serving requests, which we do with a dedicated
# LongPollClient that stops running once the shared client takes over.
self.long_poll_client = LongPollClient(
controller_handle,
{
(
LongPollNamespace.DEPLOYMENT_TARGETS,
deployment_id,
): self.update_deployment_targets,
(
LongPollNamespace.DEPLOYMENT_CONFIG,
deployment_id,
): self.update_deployment_config,
},
call_in_event_loop=self._event_loop,
# Multiple AsyncioRouters can share an actor (one per downstream
# handle), so include the deployment id to disambiguate.
client_id=f"{type(self).__name__}:{self_actor_id}:{deployment_id}",
)
shared = SharedRouterLongPollClient.get_or_create(
controller_handle, self._event_loop
)
shared.register(self)
@property
def request_router(self) -> Optional[RequestRouter]:
"""Get and lazy loading request router.
If the request_router_class not provided, and the request router is not
yet initialized, then it will return None. Otherwise, if request router
is not yet initialized, it will be initialized and returned. Also,
setting `self._request_router_initialized` to signal that the request
router is initialized.
"""
if not self._request_router and self._request_router_class:
backoff_kwargs = {}
if self._initial_backoff_s is not None:
backoff_kwargs["initial_backoff_s"] = self._initial_backoff_s
if self._backoff_multiplier is not None:
backoff_kwargs["backoff_multiplier"] = self._backoff_multiplier
if self._max_backoff_s is not None:
backoff_kwargs["max_backoff_s"] = self._max_backoff_s
request_router = self._request_router_class(
deployment_id=self.deployment_id,
handle_source=self._handle_source,
self_node_id=self._node_id,
self_actor_id=self._self_actor_id,
self_actor_handle=ray.get_runtime_context().current_actor
if ray.get_runtime_context().get_actor_id()
else None,
# Streaming ObjectRefGenerators are not supported in Ray Client
use_replica_queue_len_cache=self._enable_strict_max_ongoing_requests,
create_replica_wrapper_func=lambda r: RunningReplica(r),
prefer_local_node_routing=self._prefer_local_node_routing,
prefer_local_az_routing=RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING,
self_availability_zone=self._availability_zone,
**backoff_kwargs,
)
request_router.initialize_state(**(self._request_router_kwargs))
# Populate the running replicas if they are already available.
if self._running_replicas is not None:
request_router._update_running_replicas(self._running_replicas)
self._request_router = request_router
self._request_router_initialized.set()
# Log usage telemetry to indicate that custom request router
# feature is being used in this cluster.
if (
self._request_router_class.__name__
!= PowerOfTwoChoicesRequestRouter.__name__
):
ServeUsageTag.CUSTOM_REQUEST_ROUTER_USED.record("1")
return self._request_router
def running_replicas_populated(self) -> bool:
return self._running_replicas_populated
def update_deployment_targets(self, deployment_target_info: DeploymentTargetInfo):
self._deployment_available = deployment_target_info.is_available
running_replicas = deployment_target_info.running_replicas
if self.request_router:
self.request_router._update_running_replicas(running_replicas)
else:
# In this case, the request router hasn't been initialized yet.
# Store the running replicas so that we can update the request
# router once it is initialized.
self._running_replicas = running_replicas
self._metrics_manager._update_running_replicas(running_replicas)
if running_replicas:
self._running_replicas_populated = True
def update_deployment_config(self, deployment_config: DeploymentConfig):
self._request_router_class = (
deployment_config.request_router_config.get_request_router_class()
)
self._request_router_kwargs = (
deployment_config.request_router_config.request_router_kwargs
)
# Warn if deprecated env vars are set
warn_if_deprecated_env_var_set("RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S")
warn_if_deprecated_env_var_set("RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER")
warn_if_deprecated_env_var_set("RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S")
self._initial_backoff_s = (
deployment_config.request_router_config.initial_backoff_s
)
self._backoff_multiplier = (
deployment_config.request_router_config.backoff_multiplier
)
self._max_backoff_s = deployment_config.request_router_config.max_backoff_s
if self._request_router:
self._request_router.update_backoff_params(
initial_backoff_s=self._initial_backoff_s,
backoff_multiplier=self._backoff_multiplier,
max_backoff_s=self._max_backoff_s,
)
# Guard against the case where request_router is None (e.g., when
# request_router_class is None and lazy initialization has not yet
# occurred). In that scenario there are no active replicas yet, so
# passing 0 is the correct and safe value.
_router = self.request_router
curr_num_replicas = len(_router.curr_replicas) if _router is not None else 0
self._metrics_manager.update_deployment_config(
deployment_config,
curr_num_replicas=curr_num_replicas,
)
async def _resolve_request_arguments(
self,
pr: PendingRequest,
) -> None:
"""Asynchronously resolve and replace top-level request args and kwargs."""
if pr.resolved:
return
new_args = list(pr.args)
new_kwargs = pr.kwargs.copy()
# Map from index -> task for resolving positional arg
resolve_arg_tasks = {}
for i, obj in enumerate(pr.args):
task = await self._resolve_request_arg_func(obj, pr.metadata)
if task is not None:
resolve_arg_tasks[i] = task
# Map from key -> task for resolving key-word arg
resolve_kwarg_tasks = {}
for k, obj in pr.kwargs.items():
task = await self._resolve_request_arg_func(obj, pr.metadata)
if task is not None:
resolve_kwarg_tasks[k] = task
# Gather all argument resolution tasks concurrently.
if resolve_arg_tasks or resolve_kwarg_tasks:
all_tasks = list(resolve_arg_tasks.values()) + list(
resolve_kwarg_tasks.values()
)
await asyncio.wait(all_tasks)
# Update new args and new kwargs with resolved arguments
for index, task in resolve_arg_tasks.items():
new_args[index] = task.result()
for key, task in resolve_kwarg_tasks.items():
new_kwargs[key] = task.result()
pr.args = new_args
pr.kwargs = new_kwargs
pr.resolved = True
def _process_finished_request(
self,
replica_id: ReplicaID,
internal_request_id: str,
replica_actor_id: Optional[ray.ActorID],
result: Union[Any, RayError],
) -> None:
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
self._metrics_manager.dec_num_running_requests_for_replica(replica_id)
# Notify request router that request completed (for cleanup, e.g., token release)
if self.request_router:
self.request_router.on_request_completed(replica_id, internal_request_id)
actor_died_error = self._get_actor_died_error(result)
if actor_died_error is not None:
self._handle_actor_died_error(
replica_id, replica_actor_id, actor_died_error
)
elif isinstance(result, ActorUnavailableError):
# There are network issues, or replica has died but GCS is down so
# ActorUnavailableError will be raised until GCS recovers. For the
# time being, invalidate the cache entry so that we don't try to
# send requests to this replica without actively probing, and retry
# routing request.
if self.request_router:
self.request_router.on_replica_actor_unavailable(replica_id)
logger.warning(
f"Request failed because {replica_id} is temporarily unavailable."
)
def _get_actor_died_error(
self, result: Union[Any, RayError]
) -> Optional[ActorDiedError]:
if isinstance(result, ActorDiedError):
return result
if isinstance(result, RayTaskError) and isinstance(
getattr(result, "cause", None), ActorDiedError
):
# RayTaskError wrapping ActorDiedError (e.g., from failed object ref
# resolution in chained deployment calls).
return result.cause
return None
def _handle_actor_died_error(
self,
replica_id: ReplicaID,
replica_actor_id: Optional[ray.ActorID],
actor_died_error: ActorDiedError,
) -> bool:
"""Handle an ActorDiedError from a replica request.
Returns True if the error is from this replica (i.e., this replica
died and should be retried on another replica). Returns False if the
error is from an upstream dependency (i.e., this replica is healthy
but the request failed due to a bad input).
"""
# Only mark the replica as dead if the ActorDiedError refers to this
# replica. With chained DeploymentResponses, the error may come from
# an upstream deployment that was passed as an object ref to this
# replica. In that case, this replica is still healthy.
error_actor_id = getattr(actor_died_error, "actor_id", None)
replica_actor_id_hex = (
replica_actor_id.hex() if replica_actor_id is not None else None
)
# When error_actor_id or replica_actor_id_hex is None, we cannot
# definitively compare. Treat as match to preserve conservative
# behavior: mark replica dead rather than leaving it in rotation.
if (
error_actor_id is None
or replica_actor_id_hex is None
or error_actor_id == replica_actor_id_hex
):
# Replica has died but controller hasn't notified the router yet.
if self.request_router:
self.request_router.on_replica_actor_died(replica_id)
logger.warning(
f"{replica_id} will not be considered for future "
"requests because it has died."
)
return True
else:
# Error from upstream dependency, not from this replica.
logger.debug(
f"ActorDiedError from upstream (actor_id={error_actor_id}), "
f"not from {replica_id} (actor_id={replica_actor_id_hex}). "
"Replica remains in rotation."
)
return False
def _make_upstream_crash_error(self, e: ActorDiedError) -> RayServeException:
"""Surface a clear Serve error while preserving Ray's actor death details."""
msg = (
f"Request to deployment '{self.deployment_id.name}' failed because "
f"an upstream actor died before finishing a dependent task. "
f"Ray reported:\n{e}"
)
wrapped = RayServeException(msg)
wrapped.__cause__ = e
return wrapped
async def _route_and_send_request_once(
self,
pr: PendingRequest,
response_id: str,
is_retry: bool,
) -> Optional[ReplicaResult]:
result: Optional[ReplicaResult] = None
replica: Optional[RunningReplica] = None
callback_registered = False
try:
# Resolve request arguments BEFORE incrementing queued requests.
# This ensures that queue metrics reflect actual pending work,
# not time spent waiting for upstream DeploymentResponse arguments.
# See: https://github.com/ray-project/ray/issues/60624
await self._resolve_args_with_metrics(pr)
num_curr_replicas = len(self.request_router.curr_replicas)
with self._metrics_manager.wrap_queued_request(is_retry, num_curr_replicas):
replica = await self.request_router._choose_replica_for_request(
pr, is_retry=is_retry
)
# If the queue len cache is disabled or we're sending a request to Java,
# then directly send the query and hand the response back. The replica will
# never reject requests in this code path.
with_rejection = (
self._enable_strict_max_ongoing_requests
and not replica.is_cross_language
)
result = replica.try_send_request(pr, with_rejection=with_rejection)
# Proactively update the queue length cache.
self.request_router.on_send_request(replica.replica_id)
self._register_completion_callback(result, replica, pr)
callback_registered = True
if not with_rejection:
self._register_decrement_queue_len_cache_callback(
result, replica.replica_id
)
return result
queue_info = await result.get_rejection_response()
self.request_router.on_new_queue_len_info(
replica.replica_id, queue_info.num_ongoing_requests
)
if queue_info.accepted:
self.request_router.on_request_routed(pr, replica.replica_id, result)
self._register_decrement_queue_len_cache_callback(
result, replica.replica_id
)
return result
# Request was rejected: cancel so done callbacks fire.
# Without this, same-loop (means the router is running on the main event loop,
# where the DeploymentHandle lives) gRPC streaming results are
# never consumed (no background drain task exists in that mode).
# The call stays open, the running request counter is never decremented,
# and the autoscaler sees load that blocks downscaling.
result.cancel()
except asyncio.CancelledError:
# NOTE(edoakes): this is not strictly necessary because there are
# currently no `await` statements between getting the ref and returning,
# but I'm adding it defensively.
if result is not None:
result.cancel()
raise
except ActorDiedError as e:
if replica is not None:
is_from_this_replica = self._handle_actor_died_error(
replica.replica_id, replica.actor_id, e
)
if not is_from_this_replica and callback_registered:
# Error from an upstream dependency during request
# execution (e.g., a chained DeploymentResponse whose
# source actor died). The request was already accepted
# by this (healthy) replica, but the input is
# permanently failed — retrying with another replica
# won't help. Propagate immediately so the caller gets
# a fast error.
raise self._make_upstream_crash_error(e)
elif not pr.resolved:
# ActorDiedError during argument resolution — same upstream
# cause as above, caught before a replica was even chosen.
raise self._make_upstream_crash_error(e)
except ActorUnavailableError:
# There are network issues, or replica has died but GCS is down so
# ActorUnavailableError will be raised until GCS recovers. For the
# time being, invalidate the cache entry so that we don't try to
# send requests to this replica without actively probing, and retry
# routing request.
if replica is not None:
self.request_router.on_replica_actor_unavailable(replica.replica_id)
logger.warning(f"{replica.replica_id} is temporarily unavailable.")
finally:
# Only release if callback wasn't registered (callback handles release).
if replica is not None and not callback_registered:
self.request_router.on_request_completed(
replica.replica_id, pr.metadata.internal_request_id
)
return None
async def route_and_send_request(
self,
pr: PendingRequest,
response_id: str,
) -> ReplicaResult:
"""Choose a replica for the request and send it.
This will block indefinitely if no replicas are available to handle the
request, so it's up to the caller to time out or cancel the request.
"""
# Wait for the router to be initialized before sending the request.
await self._request_router_initialized.wait()
is_retry = False
while True:
result = await self._route_and_send_request_once(
pr,
response_id,
is_retry,
)
if result is not None:
return result
# If the replica rejects the request, retry the routing process. The
# request will be placed on the front of the queue to avoid tail latencies.
# TODO(edoakes): this retry procedure is not perfect because it'll reset the
# process of choosing candidates replicas (i.e., for locality-awareness).
is_retry = True
@tracing_decorator_factory(
trace_name="route_to_replica",
)
async def assign_request(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> ReplicaResult:
"""Assign a request to a replica and return the resulting object_ref."""
if is_span_recording():
trace_attributes = {
"request_id": request_meta.request_id,
"deployment": self.deployment_id.name,
"app": self.deployment_id.app_name,
"call_method": request_meta.call_method,
"route": request_meta.route,
"multiplexed_model_id": request_meta.multiplexed_model_id,
"is_streaming": request_meta.is_streaming,
"is_http_request": request_meta.is_http_request,
"is_grpc_request": request_meta.is_grpc_request,
}
set_span_attributes(trace_attributes)
set_span_name(
f"route_to_replica {self.deployment_id.name} {request_meta.call_method}"
)
# Add context to request meta to link
# traces collected in the replica.
propagate_context = create_propagated_context()
request_meta.tracing_context = propagate_context
else:
request_meta.tracing_context = None
if not self._deployment_available:
raise DeploymentUnavailableError(self.deployment_id)
response_id = generate_request_id()
assign_request_task = asyncio.current_task()
ray.serve.context._add_request_pending_assignment(
request_meta.internal_request_id, response_id, assign_request_task
)
assign_request_task.add_done_callback(
lambda _: ray.serve.context._remove_request_pending_assignment(
request_meta.internal_request_id, response_id
)
)
# Wait for the router to be initialized before sending the request.
await self._request_router_initialized.wait()
with self._metrics_manager.wrap_request_assignment(request_meta):
replica_result = None
exc = None
try:
replica_result = await self.route_and_send_request(
PendingRequest(
args=list(request_args),
kwargs=request_kwargs,
metadata=request_meta,
),
response_id,
)
return replica_result
except asyncio.CancelledError as e:
exc = e
# NOTE(edoakes): this is not strictly necessary because
# there are currently no `await` statements between
# getting the ref and returning, but I'm adding it defensively.
if replica_result is not None:
replica_result.cancel()
raise
finally:
if is_span_recording():
if request_meta.is_http_request:
set_http_span_attributes(
method=request_meta.call_method,
route=request_meta.route,
)
else:
set_rpc_span_attributes(
system=request_meta._request_protocol,
method=request_meta.call_method,
service=self.deployment_id.name,
)
if exc:
set_span_exception(exc, escaped=True)
@asynccontextmanager
async def choose_replica(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> AsyncIterator[ReplicaSelection]:
"""Pick a replica, hold capacity on it, and yield a selection.
The yielded selection can be dispatched (consuming the held
capacity) or allowed to drop, in which case the capacity is
released automatically when the context exits.
Behavior:
1. Refuses immediately if the deployment is unavailable.
2. Waits for the router to finish initial setup before routing.
3. Refuses with backpressure if the queue limit is already
reached; otherwise the request counts against router-level
request and queue metrics for the duration of routing.
4. Resolves the request arguments (which may block on
upstream responses).
5. Picks a replica and asks it to hold capacity. Retries on
actor failure, transient unavailability, or replica-side
capacity rejection until a replica accepts.
6. Yields the selection while the reservation is held; on exit,
releases the capacity if dispatch never consumed it.
Reservation metrics stay accurate even if the release call
itself fails.
"""
# TODO(jeffreywang): Add tracing support
if not self._deployment_available:
raise DeploymentUnavailableError(self.deployment_id)
# Internal opt-out for pick-only callers (e.g. ingress router that
# forwards traffic out-of-band via HAProxy). Skips the replica-side
# reserve_slot RPC and the rejection-retry loop; the configured
# RequestRouter still drives ordering.
reserve = request_kwargs.pop("_reserve", True)
await self._request_router_initialized.wait()
with self._metrics_manager.wrap_request_assignment(request_meta):
pr = PendingRequest(
args=list(request_args),
kwargs=request_kwargs,
metadata=request_meta,
)
try:
await self._resolve_args_with_metrics(pr)
except ActorDiedError as e:
raise self._make_upstream_crash_error(e)
if reserve:
replica, slot_token = await self._pick_and_reserve_replica(pr)
else:
# Fast path: synchronously ask the configured RequestRouter to
# pick a replica from the current snapshot, bypassing the
# _pending_requests_to_fulfill queue and the routing-task
# workers. Safe because there's no reservation -> no rejection
# -> no retry, so the queue's ordering/backoff guarantees are
# unused.
ranks = await self.request_router.choose_replicas(
candidate_replicas=self.request_router._replicas_list,
pending_request=pr,
)
replica = next((r for rank in ranks for r in rank), None)
if replica is None:
raise RuntimeError(
f"no replicas available for {self.deployment_id}"
)
slot_token = None
selection = ReplicaSelection(
replica_id=replica.replica_id.unique_id,
node_ip=replica._replica_info.node_ip,
port=replica._replica_info.port,
node_id=replica.node_id,
availability_zone=replica.availability_zone,
replica_metadata=replica.replica_metadata,
_replica=replica,
_deployment_id=None, # Injected by DeploymentHandle for dispatch-time validation.
_request_metadata=request_meta,
_method_name=request_meta.call_method,
_slot_token=slot_token,
)
if not reserve:
yield selection
return
try:
self._metrics_manager.inc_reserved_slots()
yield selection
finally:
try:
await self._release_slot_and_refresh_cache(selection, replica)
# Every dispatched request must fire on_request_completed exactly
# once. When dispatch wires up the result's done-callback it fires
# there; otherwise (dispatch raised before registering the
# callback) fire it here. Skipped entirely when the caller exits
# without dispatch, since no request was sent to the replica.
if (
selection._dispatched
and not selection._completion_callback_registered
and self.request_router
):
self.request_router.on_request_completed(
replica.replica_id, request_meta.internal_request_id
)
finally:
# Decrement reserved slots metric even if release failed,
# otherwise the gauge leaks for the lifetime of the process.
self._metrics_manager.dec_reserved_slots()
async def dispatch(
self,
selection: ReplicaSelection,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> ReplicaResult:
"""Dispatch to a specific replica, consuming the reserved slot.
Args:
selection: The replica selection from choose_replica().
request_meta: Request metadata.
*request_args: Request positional arguments.
**request_kwargs: Request keyword arguments.
Returns:
ReplicaResult for the dispatched request.
Raises:
RuntimeError: If the selection has already been dispatched.
ReplicaUnavailableError: If the replica is no longer available.
"""
selection._mark_dispatched()
return await self._dispatch_to_marked_selection(
selection, request_meta, *request_args, **request_kwargs
)
async def _dispatch_to_marked_selection(
self,
selection: ReplicaSelection,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> ReplicaResult:
"""Send a request to the replica chosen by `choose_replica`.
Mirrors the send half of `_route_and_send_request_once`: build a
PendingRequest, resolve args, send with `with_rejection=False`
(the reservation guarantees acceptance), and wire up the same
completion callbacks. On failure, releases the held slot before
re-raising.
"""
replica = selection._replica
if selection._slot_token is None:
raise RuntimeError(
"Cannot dispatch a ReplicaSelection that was created without a "
"reservation (_reserve=False)."
)
# Inject the slot token so the replica skips re-acquiring its semaphore.
# Args are re-resolved here because dispatch may carry augmented args.
pr = PendingRequest(
args=list(request_args),
kwargs=request_kwargs,
metadata=replace(request_meta, _reserved_slot_token=selection._slot_token),
)
try:
if replica.replica_id not in self.request_router.curr_replicas:
raise ReplicaUnavailableError(
f"Replica {selection.replica_id} is no longer available"
)
try:
await self._resolve_args_with_metrics(pr)
except ActorDiedError as e:
raise self._make_upstream_crash_error(e)
result = replica.try_send_request(pr, with_rejection=False)
except BaseException:
# Dispatch failed; release the reservation before re-raising.
await self._release_slot_and_refresh_cache(selection, replica, force=True)
raise
self._register_completion_callback(result, replica, pr)
# The done-callback registered above will fire on_request_completed
# when the result completes; flag the selection so choose_replica's
# finally skips its manual call (exactly one per reservation).
selection._completion_callback_registered = True
self._register_decrement_queue_len_cache_callback(result, replica.replica_id)
# Only the cancellation path needs a follow-up release_slot RPC: on
# success or replica-side failure, `_start_request` already consumed
# the slot. Skipping it on the happy path saves one actor RPC per
# dispatched request.
def _maybe_release_slot(fut, sel=selection):
cancelled = isinstance(fut, TaskCancelledError) or (
callable(getattr(fut, "cancelled", None)) and fut.cancelled()
)
if cancelled:
self._event_loop.call_soon_threadsafe(
lambda: self._event_loop.create_task(
self._release_slot_if_still_reserved(sel)
)
)
result.add_done_callback(_maybe_release_slot)
return result
async def _release_slot_if_still_reserved(
self, selection: ReplicaSelection
) -> None:
"""Best-effort release_slot when a dispatched request was cancelled
before the replica could consume the reservation. A no-op on the
replica side if the slot was already consumed."""
try:
await selection._release_slot(force=True)
except Exception:
logger.debug("Failed to release reserved replica slot.", exc_info=True)
async def _resolve_args_with_metrics(self, pr: PendingRequest) -> None:
"""Resolve a PendingRequest's arguments, observing resolution latency."""
if pr.resolved:
return
resolution_start = time.monotonic()
await self._resolve_request_arguments(pr)
resolution_ms = (time.monotonic() - resolution_start) * 1000
self._objref_resolution_latency_ms.observe(resolution_ms)
async def _pick_and_reserve_replica(
self, pr: PendingRequest
) -> Tuple[RunningReplica, str]:
"""Pick a replica and hold capacity on it.
Loops until a replica accepts the reservation, retrying on
capacity rejection, replica actor death, or transient
unavailability. Updates the queue-length cache from the replica's
reported count.
"""
is_retry = False
while True:
num_curr_replicas = len(self.request_router.curr_replicas)
with self._metrics_manager.wrap_queued_request(
is_retry=is_retry, num_curr_replicas=num_curr_replicas
):
replica = await self.request_router._choose_replica_for_request(
pr, is_retry=is_retry
)
try:
slot_token, queue_info = await replica.reserve_slot(pr.metadata)
except ActorDiedError as e:
self._handle_actor_died_error(
replica.replica_id, replica.actor_id, e
)
is_retry = True
continue
except ActorUnavailableError:
self.request_router.on_replica_actor_unavailable(replica.replica_id)
logger.warning(f"{replica.replica_id} is temporarily unavailable.")
is_retry = True
continue
self.request_router.on_new_queue_len_info(
replica.replica_id, queue_info.num_ongoing_requests
)
if queue_info.accepted:
return replica, slot_token
is_retry = True
def _register_completion_callback(
self, result: ReplicaResult, replica: RunningReplica, pr: PendingRequest
) -> None:
"""Count this request as in-flight and register the cleanup
callback fired when the result completes or is cancelled.
"""
if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE:
self._metrics_manager.inc_num_running_requests_for_replica(
replica.replica_id
)
# Always register callback to notify router when request completes
# (needed for token release in queue-based routing, metrics tracking, etc.)
# NOTE: add_done_callback fires from a C++ worker thread (for actor
# ObjectRefs) or a gRPC callback thread. _process_finished_request
# and decrement_queue_len_cache both access shared router state
# (e.g., _replica_queue_len_cache) that is not thread-safe, so we
# schedule them on the router's event loop.
callback = partial(
self._process_finished_request,
replica.replica_id,
pr.metadata.internal_request_id,
replica.actor_id,
)
result.add_done_callback(
lambda _, cb=callback: self._event_loop.call_soon_threadsafe(cb, _)
)
def _register_decrement_queue_len_cache_callback(
self, result: ReplicaResult, replica_id: ReplicaID
) -> None:
"""Schedule a queue-length-cache decrement for when this result completes."""
result.add_done_callback(
lambda _: self._event_loop.call_soon_threadsafe(
self.request_router.decrement_queue_len_cache,
replica_id,
)
)
async def _release_slot_and_refresh_cache(
self,
selection: ReplicaSelection,
replica: RunningReplica,
*,
force: bool = False,
) -> None:
"""Release a reserved slot and feed the replica's reported queue
length back into the cache. Swallows release failures so callers
can put this in a finally without masking other errors."""
try:
num_ongoing_requests = await selection._release_slot(force=force)
if num_ongoing_requests is not None:
self.request_router.on_new_queue_len_info(
replica.replica_id, num_ongoing_requests
)
except Exception:
logger.exception("Failed to release reserved replica slot.")
async def broadcast(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> List[ReplicaResult]:
"""Send a request to all running replicas in parallel.
Bypasses the normal load-balancing path and sends the request
directly to every replica. Waits for the request router to be
initialized so the replica set is populated.
"""
# Propagate tracing context, matching assign_request behavior.
if is_span_recording():
propagate_context = create_propagated_context()
request_meta.tracing_context = propagate_context
else:
request_meta.tracing_context = None
if not self._deployment_available:
raise DeploymentUnavailableError(self.deployment_id)
await self._request_router_initialized.wait()
if not self._deployment_available:
raise DeploymentUnavailableError(self.deployment_id)
replicas: List[RunningReplica] = list(
self.request_router.curr_replicas.values()
)
if not replicas:
raise DeploymentUnavailableError(self.deployment_id)
# Resolve arguments (e.g. DeploymentResponse objects) before sending.
pr = PendingRequest(
args=list(request_args),
kwargs=dict(request_kwargs),
metadata=request_meta,
)
await self._resolve_request_arguments(pr)
results: List[ReplicaResult] = []
for replica in replicas:
replica_pr = PendingRequest(
args=list(pr.args),
kwargs=dict(pr.kwargs),
metadata=replace(
request_meta,
internal_request_id=generate_request_id(),
),
)
replica_pr.resolved = True
try:
result = replica.try_send_request(replica_pr, with_rejection=False)
except ActorDiedError:
# Replica has died but controller hasn't notified the router yet.
# Skip this replica and continue broadcasting to healthy replicas.
self.request_router.on_replica_actor_died(replica.replica_id)
logger.warning(
f"{replica.replica_id} will not be considered for future "
"requests because it has died."
)
continue
except ActorUnavailableError:
# Replica is temporarily unavailable. Invalidate the cache entry
# and continue broadcasting to other replicas.
self.request_router.on_replica_actor_unavailable(replica.replica_id)
logger.warning(f"{replica.replica_id} is temporarily unavailable.")
continue
# Proactively update the queue length cache.
self.request_router.on_send_request(replica.replica_id)
self._register_completion_callback(result, replica, replica_pr)
self._register_decrement_queue_len_cache_callback(
result, replica.replica_id
)
results.append(result)
if not results:
raise DeploymentUnavailableError(self.deployment_id)
return results
async def shutdown(self):
await self._metrics_manager.shutdown()
class SingletonThreadRouter(Router):
"""Wrapper class that runs an AsyncioRouter on a separate thread.
The motivation for this is to avoid user code blocking the event loop and
preventing the router from making progress.
Maintains a singleton event loop running in a daemon thread that is shared by
all AsyncioRouters.
"""
_asyncio_loop: Optional[asyncio.AbstractEventLoop] = None
_asyncio_loop_creation_lock = threading.Lock()
_event_loop_monitor: Optional[EventLoopMonitor] = None
def __init__(self, **passthrough_kwargs):
assert (
"event_loop" not in passthrough_kwargs
), "SingletonThreadRouter manages the router event loop."
if passthrough_kwargs.get("handle_source") == DeploymentHandleSource.REPLICA:
component = EventLoopMonitor.COMPONENT_REPLICA
elif passthrough_kwargs.get("handle_source") == DeploymentHandleSource.PROXY:
component = EventLoopMonitor.COMPONENT_PROXY
else:
component = EventLoopMonitor.COMPONENT_UNKNOWN
self._asyncio_router = AsyncioRouter(
event_loop=self._get_singleton_asyncio_loop(component), **passthrough_kwargs
)
@classmethod
def _get_singleton_asyncio_loop(cls, component: str) -> asyncio.AbstractEventLoop:
"""Get singleton asyncio loop running in a daemon thread.
This method is thread safe.
"""
with cls._asyncio_loop_creation_lock:
if cls._asyncio_loop is None:
cls._asyncio_loop = asyncio.new_event_loop()
# Create event loop monitor for the router loop.
# This is shared across all replicas in this process.
actor_id = ray.get_runtime_context().get_actor_id()
cls._event_loop_monitor = EventLoopMonitor(
component=component,
loop_type=EventLoopMonitor.LOOP_TYPE_ROUTER,
# actor_id is None when using DeploymentHandle.remote()
# from the driver.
actor_id=actor_id or "",
)
def _run_router_event_loop():
asyncio.set_event_loop(cls._asyncio_loop)
# Start monitoring before run_forever so the task is scheduled.
cls._event_loop_monitor.start(cls._asyncio_loop)
cls._asyncio_loop.run_forever()
thread = threading.Thread(
daemon=True,
target=_run_router_event_loop,
)
thread.start()
return cls._asyncio_loop
def running_replicas_populated(self) -> bool:
return self._asyncio_router.running_replicas_populated()
def assign_request(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> concurrent.futures.Future[ReplicaResult]:
"""Routes assign_request call on the internal asyncio loop.
This method uses `run_coroutine_threadsafe` to execute the actual request
assignment logic (`_asyncio_router.assign_request`) on the dedicated
asyncio event loop thread. It returns a `concurrent.futures.Future` that
can be awaited or queried from the calling thread.
Args:
request_meta: Metadata describing the inbound request.
*request_args: Positional arguments forwarded to the replica handler.
**request_kwargs: Keyword arguments forwarded to the replica handler.
Returns:
A concurrent.futures.Future resolving to the ReplicaResult representing
the assigned request.
"""
return self._wrap_asyncio_call_in_future(
self._asyncio_router.assign_request(
request_meta, *request_args, **request_kwargs
)
)
@asynccontextmanager
async def choose_replica(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> AsyncIterator[ReplicaSelection]:
"""Bridge async context manager to router event loop.
This ensures choose_replica runs on the singleton router loop,
maintaining thread safety for all state modifications.
"""
# Enter context on router loop
async def enter_context():
cm = self._asyncio_router.choose_replica(
request_meta, *request_args, **request_kwargs
)
selection = await cm.__aenter__()
return selection, cm
async def exit_context(cm, exc_type, exc_val, exc_tb):
return await cm.__aexit__(exc_type, exc_val, exc_tb)
async def release_entered_context(entry_future):
"""Release a slot whose caller was cancelled before owning the selection.
Awaits the entry on the router loop and drives ``__aexit__`` on the
entered context manager. The inner CM is parked at ``yield`` holding
the slot, and only an explicit ``__aexit__`` releases it reliably.
"""
try:
_, cm = await asyncio.wrap_future(entry_future)
except BaseException:
# __aenter__ was cancelled/failed: nothing entered to release.
return
try:
await cm.__aexit__(None, None, None)
except Exception:
logger.exception("Failed to release reserved replica slot.")
future = asyncio.run_coroutine_threadsafe(enter_context(), self._asyncio_loop)
# Shield so a caller cancellation does not propagate through wrap_future
# and cancel ``enter_context``: __aenter__ finishes and returns, so the
# entered CM stays reachable for release. Otherwise the CM is discarded
# mid-entry and the slot leaks until GC.
try:
selection, context_manager = await asyncio.shield(
asyncio.wrap_future(future)
)
except BaseException:
# Honor the cancellation now; release the orphaned slot on the
# router loop instead of making the cancelled caller wait on it.
asyncio.run_coroutine_threadsafe(
release_entered_context(future), self._asyncio_loop
)
raise
try:
yield selection
finally:
exc_info = sys.exc_info()
future = asyncio.run_coroutine_threadsafe(
exit_context(context_manager, *exc_info), self._asyncio_loop
)
# Shielded so a cancel landing during cleanup doesn't propagate
# through wrap_future and abort __aexit__ on the router loop.
await asyncio.shield(asyncio.wrap_future(future))
def dispatch(
self,
selection: ReplicaSelection,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> concurrent.futures.Future[ReplicaResult]:
"""Dispatch request to a previously selected replica."""
try:
selection._mark_dispatched()
except Exception as exc:
future = concurrent.futures.Future()
future.set_exception(exc)
return future
return self._wrap_asyncio_call_in_future(
self._asyncio_router._dispatch_to_marked_selection(
selection, request_meta, *request_args, **request_kwargs
)
)
def _wrap_asyncio_call_in_future(
self,
coro: Coroutine,
) -> concurrent.futures.Future[ReplicaResult]:
"""Wrap an async call in a concurrent.futures.Future for cross-thread execution.
This is a helper method to execute AsyncioRouter's async methods on the dedicated asyncio event loop thread.
Args:
coro: The coroutine to execute (e.g., _asyncio_router.assign_request(...))
Returns:
A concurrent.futures.Future that resolves to the ReplicaResult.
"""
# Extract operation name from coroutine for logging
operation_name = coro.__name__
def asyncio_future_callback(
asyncio_future: asyncio.Future, concurrent_future: concurrent.futures.Future
):
"""Callback attached to the asyncio Task running assign_request.
This runs when the asyncio Task finishes (completes, fails, or is cancelled).
Its primary goal is to propagate cancellation initiated via the
`concurrent_future` back to the `ReplicaResult` in situations where
asyncio_future didn't see the cancellation event in time. Think of it
like a second line of defense for cancellation of replica results.
"""
# Check if the cancellation originated from the concurrent.futures.Future
if (
concurrent_future.cancelled()
and not asyncio_future.cancelled()
and asyncio_future.exception() is None
):
result: ReplicaResult = asyncio_future.result()
logger.info(
f"Asyncio task completed despite cancellation attempt during {operation_name}. "
"Attempting to cancel the request."
)
result.cancel()
concurrent_future = concurrent.futures.Future()
def create_task_and_setup():
task = self._asyncio_loop.create_task(coro)
# Set up your cancellation callback
task.add_done_callback(
lambda _: asyncio_future_callback(_, concurrent_future)
)
try:
# chain the two futures to handle direction channel of cancellation
futures._chain_future(
ensure_future(task, loop=self._asyncio_loop), concurrent_future
)
except (SystemExit, KeyboardInterrupt):
raise
except BaseException as exc:
if concurrent_future.set_running_or_notify_cancel():
concurrent_future.set_exception(exc)
raise
# Schedule on the event loop thread
self._asyncio_loop.call_soon_threadsafe(create_task_and_setup)
return concurrent_future
async def broadcast(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> List[ReplicaResult]:
return await self._asyncio_router.broadcast(
request_meta, *request_args, **request_kwargs
)
def shutdown(self) -> concurrent.futures.Future:
return asyncio.run_coroutine_threadsafe(
self._asyncio_router.shutdown(), loop=self._asyncio_loop
)
class SharedRouterLongPollClient:
def __init__(self, controller_handle: ActorHandle, event_loop: AbstractEventLoop):
self.controller_handler = controller_handle
self.event_loop = event_loop
# We use a WeakSet to store the Routers so that we don't prevent them
# from being garbage-collected.
self.routers: MutableMapping[
DeploymentID, weakref.WeakSet[AsyncioRouter]
] = defaultdict(weakref.WeakSet)
# Creating the LongPollClient implicitly starts it
self.long_poll_client = LongPollClient(
controller_handle,
key_listeners={},
call_in_event_loop=self.event_loop,
client_id=f"{type(self).__name__}:{ray.get_runtime_context().get_worker_id()}",
)
@classmethod
@lru_cache(maxsize=None)
def get_or_create(
cls, controller_handle: ActorHandle, event_loop: AbstractEventLoop
) -> "SharedRouterLongPollClient":
shared = cls(controller_handle=controller_handle, event_loop=event_loop)
logger.info(f"Started {shared}.")
return shared
def update_deployment_targets(
self,
deployment_target_info: DeploymentTargetInfo,
deployment_id: DeploymentID,
) -> None:
for router in self.routers[deployment_id]:
router.update_deployment_targets(deployment_target_info)
router.long_poll_client.stop()
def update_deployment_config(
self, deployment_config: DeploymentConfig, deployment_id: DeploymentID
) -> None:
for router in self.routers[deployment_id]:
router.update_deployment_config(deployment_config)
router.long_poll_client.stop()
def register(self, router: AsyncioRouter) -> None:
# We need to run the underlying method in the same event loop that runs
# the long poll loop, because we need to mutate the mapping of routers,
# which are also being iterated over by the key listener callbacks.
# If those happened concurrently in different threads,
# we could get a `RuntimeError: Set changed size during iteration`.
# See https://github.com/ray-project/ray/pull/53613 for more details.
self.event_loop.call_soon_threadsafe(self._register, router)
def _register(self, router: AsyncioRouter) -> None:
self.routers[router.deployment_id].add(router)
# Remove the entries for any deployment ids that no longer have any routers.
# The WeakSets will automatically lose track of Routers that get GC'd,
# but the outer dict will keep the key around, so we need to clean up manually.
# Note the list(...) to avoid mutating self.routers while iterating over it.
for deployment_id, routers in list(self.routers.items()):
if not routers:
self.routers.pop(deployment_id)
# Register the new listeners on the long poll client.
# Some of these listeners may already exist, but it's safe to add them again.
key_listeners = {
(LongPollNamespace.DEPLOYMENT_TARGETS, deployment_id): partial(
self.update_deployment_targets, deployment_id=deployment_id
)
for deployment_id in self.routers.keys()
} | {
(LongPollNamespace.DEPLOYMENT_CONFIG, deployment_id): partial(
self.update_deployment_config, deployment_id=deployment_id
)
for deployment_id in self.routers.keys()
}
self.long_poll_client.add_key_listeners(key_listeners)
class CurrentLoopRouter(Router):
"""Wrapper class that runs an AsyncioRouter on the current asyncio loop.
Note that this class is NOT THREAD-SAFE, and all methods are expected to be
invoked from a single asyncio event loop.
"""
def __init__(self, **passthrough_kwargs):
assert (
"event_loop" not in passthrough_kwargs
), "CurrentLoopRouter uses the current event loop."
self._asyncio_loop = asyncio.get_running_loop()
self._asyncio_router = AsyncioRouter(
event_loop=self._asyncio_loop,
_request_router_initialized_event=asyncio.Event(),
**passthrough_kwargs,
)
def running_replicas_populated(self) -> bool:
return self._asyncio_router.running_replicas_populated()
def assign_request(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> asyncio.Future[ReplicaResult]:
return self._asyncio_loop.create_task(
self._asyncio_router.assign_request(
request_meta, *request_args, **request_kwargs
),
)
@asynccontextmanager
async def choose_replica(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> AsyncIterator[ReplicaSelection]:
"""Delegate to AsyncioRouter's choose_replica."""
async with self._asyncio_router.choose_replica(
request_meta, *request_args, **request_kwargs
) as selection:
yield selection
def dispatch(
self,
selection: ReplicaSelection,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> asyncio.Future[ReplicaResult]:
"""Dispatch request to a previously selected replica.
Returns an asyncio.Future wrapping the async dispatch call.
"""
try:
selection._mark_dispatched()
except Exception as exc:
future = self._asyncio_loop.create_future()
future.set_exception(exc)
return future
return self._asyncio_loop.create_task(
self._asyncio_router._dispatch_to_marked_selection(
selection, request_meta, *request_args, **request_kwargs
)
)
async def broadcast(
self,
request_meta: RequestMetadata,
*request_args,
**request_kwargs,
) -> List[ReplicaResult]:
return await self._asyncio_router.broadcast(
request_meta, *request_args, **request_kwargs
)
def shutdown(self) -> asyncio.Future:
return self._asyncio_loop.create_task(self._asyncio_router.shutdown())