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

4398 lines
179 KiB
Python

import asyncio
import concurrent.futures
import errno
import functools
import inspect
import logging
import math
import os
import pickle
import threading
import time
import traceback
import warnings
from collections import defaultdict, deque
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass
from functools import wraps
from importlib import import_module
from typing import (
Any,
AsyncGenerator,
Callable,
Dict,
Generator,
List,
Optional,
Set,
Tuple,
Union,
)
import grpc
import starlette.responses
from anyio import to_thread
from fastapi import Request
from starlette.types import ASGIApp, Receive, Scope, Send
import ray
from ray import cloudpickle
from ray._common.filters import CoreContextFilter
from ray._common.utils import get_or_create_event_loop
from ray.actor import ActorClass, ActorHandle
from ray.dag.py_obj_scanner import _PyObjScanner
from ray.remote_function import RemoteFunction
from ray.serve import metrics
from ray.serve._private.common import (
RUNNING_REQUESTS_KEY,
DeploymentID,
ReplicaID,
ReplicaMetricReport,
ReplicaQueueLengthInfo,
RequestMetadata,
RequestProtocol,
ServeComponentType,
StreamingHTTPRequest,
gRPCRequest,
gRPCStreamingRequest,
)
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import (
GRPC_CONTEXT_ARG_NAME,
HEALTH_CHECK_METHOD,
HEALTHY_MESSAGE,
RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR,
RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE,
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S,
RAY_SERVE_DIRECT_INGRESS_PORT_RETRY_COUNT,
RAY_SERVE_ENABLE_DIRECT_INGRESS,
RAY_SERVE_ENABLE_HA_PROXY,
RAY_SERVE_HAPROXY_METRICS_ENABLED,
RAY_SERVE_METRICS_EXPORT_INTERVAL_MS,
RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S,
RAY_SERVE_REPLICA_GRPC_MAX_MESSAGE_LENGTH,
RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_NUM_BUCKETS,
RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_REPORT_INTERVAL_S,
RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_WINDOW_S,
RAY_SERVE_REPLICA_UTILIZATION_NUM_BUCKETS,
RAY_SERVE_REPLICA_UTILIZATION_REPORT_INTERVAL_S,
RAY_SERVE_REPLICA_UTILIZATION_WINDOW_S,
RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE,
RAY_SERVE_RUN_SYNC_IN_THREADPOOL,
RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING,
RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD,
RECONFIGURE_METHOD,
RECORD_REPLICA_METADATA_METHOD,
REQUEST_LATENCY_BUCKETS_MS,
REQUEST_ROUTING_STATS_METHOD,
SERVE_CONTROLLER_NAME,
SERVE_HTTP_REQUEST_ID_HEADER,
SERVE_LOG_APPLICATION,
SERVE_LOG_COMPONENT,
SERVE_LOG_DEPLOYMENT,
SERVE_LOG_REPLICA,
SERVE_LOG_REQUEST_ID,
SERVE_LOG_ROUTE,
SERVE_LOGGER_NAME,
SERVE_NAMESPACE,
USER_HEALTH_CHECK_PROBE_INTERVAL_S,
USER_HEALTH_CHECK_PROBE_MAX_FAIL,
USER_HEALTH_CHECK_PROBE_TIMEOUT_S,
)
from ray.serve._private.default_impl import (
create_replica_impl,
create_replica_metrics_manager,
)
from ray.serve._private.direct_ingress_grpc_util import gRPCDIReceiveStream
from ray.serve._private.direct_ingress_http_util import ASGIDIReceiveProxy
from ray.serve._private.event_loop_monitoring import EventLoopMonitor
from ray.serve._private.grpc_util import (
get_grpc_response_status,
set_grpc_code_and_details,
start_grpc_server,
)
from ray.serve._private.http_util import (
ASGIAppReplicaWrapper,
ASGIArgs,
ASGIReceiveProxy,
MessageQueue,
Response,
configure_http_middlewares,
configure_http_options_with_defaults,
convert_object_to_asgi_messages,
parse_disconnect_disabled_header,
parse_request_timeout_header,
parse_session_id_header,
start_asgi_http_server,
)
from ray.serve._private.logging_utils import (
access_log_msg,
configure_component_logger,
configure_component_memory_profiler,
format_client_address,
format_grpc_peer_address,
get_component_logger_file_path,
)
from ray.serve._private.metrics_utils import InMemoryMetricsStore, MetricsPusher
from ray.serve._private.proxy_request_response import ResponseStatus, gRPCStreamingType
from ray.serve._private.replica_response_generator import ReplicaResponseGenerator
from ray.serve._private.request_ingress_metrics import RequestIngressMetrics
from ray.serve._private.rolling_window import (
RollingWindowAccumulator,
RollingWindowMax,
)
from ray.serve._private.serialization import RPCSerializer
from ray.serve._private.task_consumer import TaskConsumerWrapper
from ray.serve._private.thirdparty.get_asgi_route_name import (
extract_route_patterns,
get_asgi_route_name,
)
from ray.serve._private.tracing_utils import (
TraceContextManager,
extract_propagated_context,
is_span_recording,
is_tracing_enabled,
set_http_span_attributes,
set_rpc_span_attributes,
set_span_attributes,
set_span_exception,
setup_tracing,
)
from ray.serve._private.usage import ServeUsageTag
from ray.serve._private.utils import (
Semaphore,
_callable_uses_multiplexing,
asyncio_grpc_exception_handler,
check_obj_ref_ready_nowait,
compress_metric_report,
generate_request_id,
get_component_file_name, # noqa: F401
is_grpc_enabled,
parse_import_path,
)
from ray.serve._private.version import DeploymentVersion
from ray.serve.config import (
AutoscalingConfig,
HTTPOptions,
ProxyLocation,
gRPCOptions,
)
from ray.serve.context import _get_in_flight_requests
from ray.serve.deployment import Deployment
from ray.serve.exceptions import (
BackPressureError,
DeploymentUnavailableError,
RayServeException,
gRPCStatusError,
)
from ray.serve.gang import GangContext
from ray.serve.generated.serve_pb2 import (
ASGIRequest,
ASGIResponse,
HealthzResponse,
ListApplicationsResponse,
)
from ray.serve.generated.serve_pb2_grpc import add_ASGIServiceServicer_to_server
from ray.serve.grpc_util import (
RayServegRPCContext,
gRPCInputStream,
)
from ray.serve.handle import DeploymentHandle
from ray.serve.schema import EncodingType, LoggingConfig, ReplicaRank
from ray.types import ObjectRef
logger = logging.getLogger(SERVE_LOGGER_NAME)
SERVE_BUILD_ASGI_APP_METHOD = "__serve_build_asgi_app__"
def _validate_replica_metadata(metadata: Any) -> Dict[str, Any]:
"""Validate the return value of a user ``record_replica_metadata`` hook.
Returns an empty dict for ``None``; raises ``TypeError`` if the hook returned
something other than a dict (it must be a JSON-serializable mapping that the
controller can propagate to routers).
"""
if metadata is None:
return {}
if not isinstance(metadata, dict):
raise TypeError(
f"{RECORD_REPLICA_METADATA_METHOD} must return a dict, got "
f"{type(metadata).__name__}."
)
return metadata
def _wrap_grpc_call(f):
"""Decorator that processes grpc methods."""
def serialize(result, metadata):
if metadata.is_streaming and metadata.is_http_request:
return result
else:
# Use cached serializer to avoid per-request instantiation overhead
serializer = RPCSerializer.get_cached_serializer(
metadata.request_serialization,
metadata.response_serialization,
)
return serializer.dumps_response(result)
@wraps(f)
async def wrapper(
self,
request: ASGIRequest,
context: grpc.aio.ServicerContext,
):
request_metadata = pickle.loads(request.pickled_request_metadata)
# Get cached serializer with options from metadata
serializer = RPCSerializer.get_cached_serializer(
request_metadata.request_serialization,
request_metadata.response_serialization,
)
request_args = serializer.loads_request(request.request_args)
request_kwargs = serializer.loads_request(request.request_kwargs)
if request_metadata.is_http_request or request_metadata.is_grpc_request:
request_args = (pickle.loads(request_args[0]),)
try:
result = await f(
self, context, request_metadata, *request_args, **request_kwargs
)
return ASGIResponse(serialized_message=serialize(result, request_metadata))
except (Exception, asyncio.CancelledError) as e:
return ASGIResponse(
serialized_message=serializer.dumps_response(e),
is_error=True,
)
@wraps(f)
async def gen_wrapper(
self,
request: ASGIRequest,
context: grpc.aio.ServicerContext,
):
request_metadata = pickle.loads(request.pickled_request_metadata)
# Get cached serializer with options from metadata
serializer = RPCSerializer.get_cached_serializer(
request_metadata.request_serialization,
request_metadata.response_serialization,
)
request_args = serializer.loads_request(request.request_args)
request_kwargs = serializer.loads_request(request.request_kwargs)
if request_metadata.is_http_request or request_metadata.is_grpc_request:
request_args = (pickle.loads(request_args[0]),)
try:
async for result in f(
self, context, request_metadata, *request_args, **request_kwargs
):
yield ASGIResponse(
serialized_message=serialize(result, request_metadata)
)
except (Exception, asyncio.CancelledError) as e:
yield ASGIResponse(
serialized_message=serializer.dumps_response(e),
is_error=True,
)
if inspect.isasyncgenfunction(f):
return gen_wrapper
else:
return wrapper
ReplicaMetadata = Tuple[
DeploymentConfig,
DeploymentVersion,
Optional[float],
Optional[int],
Optional[str],
int,
int,
ReplicaRank, # rank
Optional[List[str]], # route_patterns
Optional[List[DeploymentID]], # outbound_deployments
bool, # has_user_routing_stats_method
Optional[GangContext], # gang_context
Dict[str, Any], # replica_metadata
]
def _load_deployment_def_from_import_path(import_path: str) -> Callable:
module_name, attr_name = parse_import_path(import_path)
deployment_def = getattr(import_module(module_name), attr_name)
# For ray or serve decorated class or function, strip to return
# original body.
if isinstance(deployment_def, RemoteFunction):
deployment_def = deployment_def._function
elif isinstance(deployment_def, ActorClass):
deployment_def = deployment_def.__ray_metadata__.modified_class
elif isinstance(deployment_def, Deployment):
logger.warning(
f'The import path "{import_path}" contains a '
"decorated Serve deployment. The decorator's settings "
"are ignored when deploying via import path."
)
deployment_def = deployment_def.func_or_class
return deployment_def
class ReplicaMetricsManager:
"""Manages metrics for the replica.
A variety of metrics are managed:
- Fine-grained metrics are set for every request.
- Autoscaling statistics are periodically pushed to the controller.
- Queue length metrics are periodically recorded as user-facing gauges.
"""
PUSH_METRICS_TO_CONTROLLER_TASK_NAME = "push_metrics_to_controller"
RECORD_METRICS_TASK_NAME = "record_metrics"
SET_REPLICA_REQUEST_METRIC_GAUGE_TASK_NAME = "set_replica_request_metric_gauge"
def __init__(
self,
replica_id: ReplicaID,
event_loop: asyncio.BaseEventLoop,
autoscaling_config: Optional[AutoscalingConfig],
ingress: bool,
max_ongoing_requests: int,
):
self._replica_id = replica_id
self._deployment_id = replica_id.deployment_id
self._metrics_pusher = MetricsPusher()
self._metrics_store = InMemoryMetricsStore()
self._ingress = ingress
self._controller_handle = ray.get_actor(
SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE
)
self._num_ongoing_requests = 0
self._max_ongoing_requests = max_ongoing_requests
# Store event loop for scheduling async tasks from sync context
self._event_loop = event_loop or asyncio.get_event_loop()
# Cache user_callable_wrapper initialization state to avoid repeated runtime checks
self._custom_metrics_enabled = False
# On first call to _fetch_custom_autoscaling_metrics. Failing validation disables _custom_metrics_enabled
self._checked_custom_metrics = False
self._record_autoscaling_stats_fn = None
# 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
# Request counter (only set on replica startup).
self._restart_counter = metrics.Counter(
"serve_deployment_replica_starts",
description=(
"The number of times this replica has been restarted due to failure."
),
)
self._restart_counter.inc()
# Per-request metrics.
self._request_counter = metrics.Counter(
"serve_deployment_request_counter",
description=(
"The number of queries that have been processed in this replica."
),
tag_keys=("route",),
)
if self._cached_metrics_enabled:
self._cached_request_counter = defaultdict(int)
self._error_counter = metrics.Counter(
"serve_deployment_error_counter",
description=(
"The number of exceptions that have occurred in this replica."
),
tag_keys=("route", "exception_type"),
)
if self._cached_metrics_enabled:
self._cached_error_counter = defaultdict(int)
# log REQUEST_LATENCY_BUCKET_MS
logger.debug(f"REQUEST_LATENCY_BUCKETS_MS: {REQUEST_LATENCY_BUCKETS_MS}")
self._processing_latency_tracker = metrics.Histogram(
"serve_deployment_processing_latency_ms",
description="The latency for queries to be processed.",
boundaries=REQUEST_LATENCY_BUCKETS_MS,
tag_keys=("route",),
)
if self._cached_metrics_enabled:
self._cached_latencies = defaultdict(deque)
self._event_loop.create_task(self._report_cached_metrics_forever())
# Track maximum processing latency over a rolling window.
self._max_processing_latency_trackers = defaultdict(
lambda: RollingWindowMax(
window_duration_s=RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_WINDOW_S,
num_buckets=RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_NUM_BUCKETS,
)
)
self._max_processing_latency_gauge = metrics.Gauge(
"serve_deployment_max_processing_latency_ms",
description="The maximum observed time spent processing a query.",
tag_keys=("route",),
)
self._max_processing_latency_report_interval_s = (
RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_REPORT_INTERVAL_S
)
self._event_loop.create_task(self._report_max_processing_latency_forever())
self._num_ongoing_requests_gauge = metrics.Gauge(
"serve_replica_processing_queries",
description="The current number of queries being processed.",
)
self.record_autoscaling_stats_failed_counter = metrics.Counter(
"serve_record_autoscaling_stats_failed",
tag_keys=("exception_name",),
description="The number of errored record_autoscaling_stats invocations.",
)
self.user_autoscaling_stats_latency_tracker = metrics.Histogram(
"serve_user_autoscaling_stats_latency_ms",
description=(
"Time taken to execute the user-defined autoscaling stats function "
"in milliseconds."
),
boundaries=REQUEST_LATENCY_BUCKETS_MS,
)
# Replica utilization tracking with rolling window.
# Tracks total user code execution time over a rolling window to calculate
# utilization as: user_code_time / (window_duration * max_ongoing_requests).
self._user_code_time_accumulator = RollingWindowAccumulator(
window_duration_s=RAY_SERVE_REPLICA_UTILIZATION_WINDOW_S,
num_buckets=RAY_SERVE_REPLICA_UTILIZATION_NUM_BUCKETS,
)
self._replica_utilization_gauge = metrics.Gauge(
"serve_replica_utilization_percent",
description=(
"Percentage of replica capacity utilized by user code execution "
"over a rolling window. Calculated as: "
"user_code_time / (window_duration * max_ongoing_requests)."
),
)
self._utilization_report_interval_s = (
RAY_SERVE_REPLICA_UTILIZATION_REPORT_INTERVAL_S
)
self._event_loop.create_task(self._report_utilization_forever())
self.set_autoscaling_config(autoscaling_config)
# Only populated if direct ingress is enabled.
self._ingress_metrics: Dict[RequestProtocol, RequestIngressMetrics] = {}
self._ingress_ongoing_requests: Dict[RequestProtocol, int] = {}
if self._is_direct_ingress:
# These ingress metrics share the same names, tag keys, and emission
# logic as those collected by the proxy (see RequestIngressMetrics). When
# direct ingress is enabled traffic bypasses the proxy, so a given
# request is recorded by exactly one of the two.
self._ingress_node_id = ray.get_runtime_context().get_node_id()
self._ingress_node_ip_address = ray.util.get_node_ip_address()
# gRPC ingress metrics are allocated lazily via
# `enable_grpc_ingress_metrics()` once the gRPC config has been fetched from
# the controller, which is not available at construction time.
if self._should_emit_request_ingress_metrics(RequestProtocol.HTTP):
self._add_ingress_metrics(RequestProtocol.HTTP)
if self._cached_metrics_enabled:
# Mapping from protocol -> {request_tags -> value}.
self._cached_ingress_request_counter = defaultdict(
lambda: defaultdict(int)
)
self._cached_ingress_request_error_counter = defaultdict(
lambda: defaultdict(int)
)
self._cached_deployment_request_error_counter = defaultdict(
lambda: defaultdict(int)
)
self._cached_ingress_processing_latencies = defaultdict(
lambda: defaultdict(deque)
)
@property
def _is_direct_ingress(self) -> bool:
return self._ingress and RAY_SERVE_ENABLE_DIRECT_INGRESS
def _should_emit_request_ingress_metrics(self, protocol: RequestProtocol) -> bool:
# When HAProxy is enabled, http ingress request metrics are emitted by
# the HAProxyManager.
return self._is_direct_ingress and not (
RAY_SERVE_ENABLE_HA_PROXY
and RAY_SERVE_HAPROXY_METRICS_ENABLED
and protocol == RequestProtocol.HTTP
)
def _add_ingress_metrics(self, protocol: RequestProtocol):
"""Allocate metric objects and ongoing-request counter for a protocol."""
self._ingress_metrics[protocol] = RequestIngressMetrics(
protocol,
source="ingress",
node_id=self._ingress_node_id,
node_ip_address=self._ingress_node_ip_address,
)
self._ingress_ongoing_requests[protocol] = 0
def enable_grpc_ingress_metrics(self):
"""Allocate gRPC ingress metrics for a direct-ingress replica.
gRPC config is fetched from the controller after this manager is
constructed, so gRPC ingress metrics are allocated here (from the replica's
server-start path) rather than in `__init__`.
"""
if not self._is_direct_ingress:
return
if RequestProtocol.GRPC not in self._ingress_metrics:
self._add_ingress_metrics(RequestProtocol.GRPC)
def _report_cached_metrics(self):
for route, count in self._cached_request_counter.items():
self._request_counter.inc(count, tags={"route": route})
self._cached_request_counter.clear()
for (route, exception_type), count in self._cached_error_counter.items():
self._error_counter.inc(
count, tags={"route": route, "exception_type": exception_type}
)
self._cached_error_counter.clear()
for route, latencies in self._cached_latencies.items():
for latency_ms in latencies:
self._processing_latency_tracker.observe(
latency_ms, tags={"route": route}
)
self._cached_latencies.clear()
self._num_ongoing_requests_gauge.set(self._num_ongoing_requests)
if not self._is_direct_ingress:
return
for protocol in self._ingress_metrics:
protocol_metrics = self._ingress_metrics[protocol]
protocol_metrics.set_num_ongoing_requests(
self._ingress_ongoing_requests[protocol]
)
for request_tags, count in self._cached_ingress_request_counter[
protocol
].items():
protocol_metrics.request_counter.inc(count, tags=dict(request_tags))
for request_tags, count in self._cached_ingress_request_error_counter[
protocol
].items():
protocol_metrics.request_error_counter.inc(
count, tags=dict(request_tags)
)
for request_tags, count in self._cached_deployment_request_error_counter[
protocol
].items():
protocol_metrics.deployment_request_error_counter.inc(
count, tags=dict(request_tags)
)
for latency_tags, latencies in self._cached_ingress_processing_latencies[
protocol
].items():
for latency_ms in latencies:
protocol_metrics.processing_latency_tracker.observe(
latency_ms, tags=dict(latency_tags)
)
self._cached_ingress_request_counter.clear()
self._cached_ingress_request_error_counter.clear()
self._cached_deployment_request_error_counter.clear()
self._cached_ingress_processing_latencies.clear()
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)
async def shutdown(self):
"""Stop periodic background tasks."""
await self._metrics_pusher.graceful_shutdown()
def start_metrics_pusher(self):
self._metrics_pusher.start()
# Push autoscaling metrics to the controller periodically.
self._metrics_pusher.register_or_update_task(
self.PUSH_METRICS_TO_CONTROLLER_TASK_NAME,
self._push_autoscaling_metrics,
self._autoscaling_config.metrics_interval_s,
)
# Collect autoscaling metrics locally periodically.
record_interval_s = (
self._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_async,
min(record_interval_s, self._autoscaling_config.metrics_interval_s),
)
def should_collect_ongoing_requests(self) -> bool:
"""Determine if replicas should collect ongoing request metrics.
┌────────────────────────────────────────────────────────────────┐
│ Replica-based metrics collection │
├────────────────────────────────────────────────────────────────┤
│ │
│ Client Handle Replicas │
│ ┌──────┐ ┌────────┐ │
│ │ App │─────>│ Handle │────┬───>┌─────────┐ │
│ │ │ │ Tracks │ │ │ Replica │ │
│ └──────┘ │ Queued │ │ │ 1 │ │
│ │Requests│ │ │ Tracks │ │
│ └────────┘ │ │ Running │ │
│ │ │ └─────────┘ │
│ │ │ │ │
│ │ │ │ │
│ │ │ ┌─────────┐ │
│ │ └───>│ Replica │ │
│ │ │ 2 │ │
│ │ │ Tracks │ │
│ │ │ Running │ │
│ │ └─────────┘ │
│ │ │ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────┐ │
│ │ Controller │ │
│ │ • Queued metrics (handle) │ │
│ │ • Running metrics (replica1)│ │
│ │ • Running metrics (replica2)│ │
│ └──────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
For direct ingress deployments, metrics must be collected from replicas regardless
of whether autoscaling metrics are being collected via handles. This is necessary
because direct ingress traffic bypasses deployment handles and goes directly to
the replicas.
"""
if self._is_direct_ingress and self._autoscaling_config:
return True
return not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE
def set_autoscaling_config(self, autoscaling_config: Optional[AutoscalingConfig]):
"""Dynamically update autoscaling config."""
self._autoscaling_config = autoscaling_config
if self._autoscaling_config and self.should_collect_ongoing_requests():
self.start_metrics_pusher()
def enable_custom_autoscaling_metrics(
self,
custom_metrics_enabled: bool,
record_autoscaling_stats_fn: Callable[[], Optional[concurrent.futures.Future]],
):
"""Runs after the user callable wrapper is initialized to enable autoscaling metrics collection."""
if custom_metrics_enabled:
self._custom_metrics_enabled = custom_metrics_enabled
self._record_autoscaling_stats_fn = record_autoscaling_stats_fn
if self._autoscaling_config:
self.start_metrics_pusher()
def _change_num_ongoing_requests(
self, request_metadata: RequestMetadata, delta: int
) -> None:
self._num_ongoing_requests += delta
protocol = request_metadata.protocol
if (
self._is_direct_ingress
and request_metadata.is_direct_ingress
and protocol in self._ingress_metrics
):
self._ingress_ongoing_requests[protocol] += delta
if not self._cached_metrics_enabled:
self._num_ongoing_requests_gauge.set(self._num_ongoing_requests)
if (
self._is_direct_ingress
and request_metadata.is_direct_ingress
and protocol in self._ingress_metrics
):
self._ingress_metrics[protocol].set_num_ongoing_requests(
self._ingress_ongoing_requests[protocol]
)
def inc_num_ongoing_requests(self, request_metadata: RequestMetadata) -> None:
self._change_num_ongoing_requests(request_metadata, 1)
def dec_num_ongoing_requests(self, request_metadata: RequestMetadata) -> None:
self._change_num_ongoing_requests(request_metadata, -1)
def get_num_ongoing_requests(self) -> int:
"""Get current total queue length of requests for this replica."""
return self._num_ongoing_requests
def set_max_ongoing_requests(self, max_ongoing_requests: int) -> None:
"""Update max_ongoing_requests when deployment config changes."""
self._max_ongoing_requests = max_ongoing_requests
async def _report_max_processing_latency_forever(self) -> None:
"""Background task to emit max processing latency gauge continuously."""
consecutive_errors = 0
while True:
try:
await asyncio.sleep(self._max_processing_latency_report_interval_s)
for route, tracker in list(
self._max_processing_latency_trackers.items()
):
max_latency = tracker.get_max()
self._max_processing_latency_gauge.set(
max_latency, tags={"route": route}
)
consecutive_errors = 0
except Exception:
logger.exception(
"Unexpected error reporting max processing latency 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)
async def _report_utilization_forever(self) -> None:
"""Background task to emit utilization gauge continuously."""
consecutive_errors = 0
while True:
try:
await asyncio.sleep(self._utilization_report_interval_s)
utilization = self._calculate_utilization()
self._replica_utilization_gauge.set(utilization)
consecutive_errors = 0
except Exception:
logger.exception("Unexpected error reporting utilization 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 _calculate_utilization(self) -> float:
"""Calculate current utilization percentage based on rolling window.
Utilization is calculated as:
user_code_time / (window_duration * max_ongoing_requests)
This represents the percentage of the replica's theoretical maximum
capacity that was used for executing user code.
"""
total_user_code_time_ms = self._user_code_time_accumulator.get_total()
# Max capacity = window_duration_ms * max_ongoing_requests
window_duration_ms = RAY_SERVE_REPLICA_UTILIZATION_WINDOW_S * 1000
max_capacity_ms = window_duration_ms * self._max_ongoing_requests
if max_capacity_ms > 0:
utilization_percent = (total_user_code_time_ms / max_capacity_ms) * 100
# Cap at 100% (can theoretically exceed if requests overlap heavily)
utilization_percent = min(utilization_percent, 100.0)
else:
utilization_percent = 0.0
return utilization_percent
def record_request_metrics(
self,
*,
route: str,
latency_ms: float,
is_error: bool,
exception_type: Optional[str] = None,
):
"""Records per-request metrics."""
# Track latency for utilization calculation (rolling window).
self._user_code_time_accumulator.add(latency_ms)
self._max_processing_latency_trackers[route].add(latency_ms)
if self._cached_metrics_enabled:
self._cached_latencies[route].append(latency_ms)
if is_error:
exc_type = exception_type or "Unknown"
self._cached_error_counter[(route, exc_type)] += 1
else:
self._cached_request_counter[route] += 1
else:
self._processing_latency_tracker.observe(latency_ms, tags={"route": route})
if is_error:
exc_type = exception_type or "Unknown"
self._error_counter.inc(
tags={"route": route, "exception_type": exc_type}
)
else:
self._request_counter.inc(tags={"route": route})
def record_ingress_request_metrics(
self,
*,
protocol: RequestProtocol,
method: str,
route: str,
app_name: str,
deployment_name: str,
latency_ms: float,
is_error: bool,
status_code: str,
):
"""Record per-request metrics."""
if not self._should_emit_request_ingress_metrics(protocol):
return
if self._cached_metrics_enabled:
# Cached path: accumulate per-tag-set counts/latencies keyed by the same
# canonical tag schemas used by the direct emit path, and flush them in
# `_report_cached_metrics`.
request_tags = RequestIngressMetrics.request_tags(
route=route,
method=method,
application=app_name,
status_code=status_code,
)
self._cached_ingress_request_counter[protocol][
frozenset(request_tags.items())
] += 1
self._cached_ingress_processing_latencies[protocol][
frozenset(request_tags.items())
].append(latency_ms)
if is_error:
request_error_tags = RequestIngressMetrics.request_error_tags(
route=route,
method=method,
application=app_name,
status_code=status_code,
)
deployment_error_tags = RequestIngressMetrics.deployment_error_tags(
route=route,
method=method,
application=app_name,
status_code=status_code,
deployment=deployment_name,
)
self._cached_ingress_request_error_counter[protocol][
frozenset(request_error_tags.items())
] += 1
self._cached_deployment_request_error_counter[protocol][
frozenset(deployment_error_tags.items())
] += 1
else:
self._ingress_metrics[protocol].record_request(
route=route,
method=method,
application=app_name,
status_code=status_code,
latency_ms=latency_ms,
is_error=is_error,
deployment_name=deployment_name,
)
def _push_autoscaling_metrics(self) -> Dict[str, Any]:
look_back_period = self._autoscaling_config.look_back_period_s
self._metrics_store.prune_keys_and_compact_data(time.time() - look_back_period)
new_aggregated_metrics = {}
new_metrics = {**self._metrics_store.data}
if self.should_collect_ongoing_requests():
# Keep the legacy window_avg ongoing requests in the merged metrics dict
window_avg = (
self._metrics_store.aggregate_avg([RUNNING_REQUESTS_KEY])[0] or 0.0
)
new_aggregated_metrics.update({RUNNING_REQUESTS_KEY: window_avg})
replica_metric_report = ReplicaMetricReport(
replica_id=self._replica_id,
timestamp=time.time(),
aggregated_metrics=new_aggregated_metrics,
metrics=new_metrics,
)
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_replica.remote(
compress_metric_report(replica_metric_report)
)
)
async def _fetch_custom_autoscaling_metrics(
self,
) -> Optional[Dict[str, Union[int, float]]]:
try:
start_time = time.time()
res = await asyncio.wait_for(
self._record_autoscaling_stats_fn(),
timeout=RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S,
)
latency_ms = (time.time() - start_time) * 1000
self.user_autoscaling_stats_latency_tracker.observe(latency_ms)
# Perform validation only first call
if not self._checked_custom_metrics:
# Enforce return type to be Dict[str, Union[int, float]]
if not isinstance(res, dict):
logger.error(
f"User autoscaling stats method returned {type(res).__name__}, "
f"expected Dict[str, Union[int, float]]. Disabling autoscaling stats."
)
self._custom_metrics_enabled = False
return None
for key, value in res.items():
if not isinstance(value, (int, float)):
logger.error(
f"User autoscaling stats method returned invalid value type "
f"{type(value).__name__} for key '{key}', expected int or float. "
f"Disabling autoscaling stats."
)
self._custom_metrics_enabled = False
return None
self._checked_custom_metrics = True
return res
except asyncio.TimeoutError as e:
logger.error(
f"Replica autoscaling stats timed out after {RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S}s."
)
self.record_autoscaling_stats_failed_counter.inc(
tags={"exception_name": e.__class__.__name__}
)
except Exception as e:
logger.error(f"Replica autoscaling stats failed. {e}")
self.record_autoscaling_stats_failed_counter.inc(
tags={"exception_name": e.__class__.__name__}
)
return None
async def _add_autoscaling_metrics_point_async(self) -> None:
metrics_dict = {}
if self.should_collect_ongoing_requests():
metrics_dict = {RUNNING_REQUESTS_KEY: self._num_ongoing_requests}
# Use cached availability flag to avoid repeated runtime checks
if self._custom_metrics_enabled:
custom_metrics = await self._fetch_custom_autoscaling_metrics()
if custom_metrics:
metrics_dict.update(custom_metrics)
self._metrics_store.add_metrics_point(
metrics_dict,
time.time(),
)
StatusCodeCallback = Callable[[str], None]
class Replica:
def __init__(
self,
replica_id: ReplicaID,
deployment_def: Callable,
init_args: Tuple,
init_kwargs: Dict,
deployment_config: DeploymentConfig,
version: DeploymentVersion,
ingress: bool,
route_prefix: str,
is_ingress_request_router: bool = False,
):
self._version = version
self._replica_id = replica_id
self._deployment_id = replica_id.deployment_id
self._deployment_config = deployment_config
self._ingress = ingress
self._is_ingress_request_router = is_ingress_request_router
self._route_prefix = route_prefix
self._component_name = f"{self._deployment_id.name}"
if self._deployment_id.app_name:
self._component_name = (
f"{self._deployment_id.app_name}_" + self._component_name
)
self._component_id = self._replica_id.unique_id
self._configure_logger_and_profilers(self._deployment_config.logging_config)
self._event_loop = get_or_create_event_loop()
actor_id = ray.get_runtime_context().get_actor_id()
self._user_callable_wrapper = UserCallableWrapper(
deployment_def,
init_args,
init_kwargs,
deployment_id=self._deployment_id,
run_sync_methods_in_threadpool=RAY_SERVE_RUN_SYNC_IN_THREADPOOL,
run_user_code_in_separate_thread=RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD,
local_testing_mode=False,
deployment_config=deployment_config,
actor_id=actor_id,
ray_actor_options=self._version.ray_actor_options,
)
self._semaphore = Semaphore(lambda: self.max_ongoing_requests)
# Guards against calling the user's callable constructor multiple times.
self._user_callable_initialized = False
self._user_callable_initialized_lock = asyncio.Lock()
self._initialization_latency: Optional[float] = None
# Track deployment handles created dynamically via get_deployment_handle()
self._dynamically_created_handles: Set[DeploymentID] = set()
# Flipped to `True` when health checks pass and `False` when they fail. May be
# used by replica subclass implementations.
self._healthy = False
# Flipped to `True` once graceful shutdown is initiated. May be used by replica
# subclass implementations.
self._shutting_down = False
# Gang context for this replica.
self._gang_context: Optional[GangContext] = None
# Static, immutable per-replica metadata captured once at init time
# via the user's `record_replica_metadata` hook (if defined).
self._replica_metadata: Dict[str, Any] = {}
# Will be populated with the wrapped ASGI app if the user callable is an
# `ASGIAppReplicaWrapper` (i.e., they are using the FastAPI integration).
self._user_callable_asgi_app: Optional[ASGIApp] = None
# Set metadata for logs and metrics.
# servable_object will be populated in `initialize_and_get_metadata`.
self._set_internal_replica_context(servable_object=None, rank=None)
self._metrics_manager = create_replica_metrics_manager(
replica_id=replica_id,
event_loop=self._event_loop,
autoscaling_config=self._deployment_config.autoscaling_config,
ingress=ingress,
max_ongoing_requests=self._deployment_config.max_ongoing_requests,
)
# Start event loop monitoring for the replica's main event loop.
self._main_loop_monitor = EventLoopMonitor(
component=EventLoopMonitor.COMPONENT_REPLICA,
loop_type=EventLoopMonitor.LOOP_TYPE_MAIN,
actor_id=actor_id,
extra_tags={
"deployment": self._deployment_id.name,
"application": self._deployment_id.app_name,
},
)
self._main_loop_monitor.start(self._event_loop)
self._internal_grpc_port: Optional[int] = None
self._docs_path: Optional[str] = None
self._http_port: Optional[int] = None
self._grpc_port: Optional[int] = None
self._rank: Optional[ReplicaRank] = None
# gRPC server for inter-deployment communication
self._server = grpc.aio.server(
options=[
(
"grpc.max_receive_message_length",
RAY_SERVE_REPLICA_GRPC_MAX_MESSAGE_LENGTH,
)
]
)
# Silence spammy false positive errors from gRPC Python
self._event_loop.set_exception_handler(asyncio_grpc_exception_handler)
try:
is_tracing_setup_successful = setup_tracing(
component_type=ServeComponentType.REPLICA,
component_name=self._component_name,
component_id=self._component_id,
)
if is_tracing_setup_successful:
logger.info("Successfully set up tracing for replica")
except Exception as e:
logger.warning(
f"Failed to set up tracing: {e}. "
"The replica will continue running, but traces will not be exported."
)
self._controller_handle = ray.get_actor(
SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE
)
# get node ID
self._node_id = ray.get_runtime_context().get_node_id()
self._http_options: Optional[HTTPOptions] = None
self._grpc_options: Optional[gRPCOptions] = None
self._direct_ingress_http_server_task: Optional[asyncio.Task] = None
self._direct_ingress_grpc_server_task: Optional[asyncio.Task] = None
# Server objects backing the tasks above, used for graceful shutdown
# (uvicorn.Server and gRPCGenericServer, respectively).
self._direct_ingress_http_server = None
self._direct_ingress_grpc_server = None
# Set after the graceful shutdown drain completes; new handle-path
# requests are then rejected (the router retries them elsewhere).
self._quiescing = False
self._num_queued_requests = 0
self._reserved_slots: Set[str] = set()
@property
def max_ongoing_requests(self) -> int:
return self._deployment_config.max_ongoing_requests
def get_num_ongoing_requests(self) -> int:
return self._metrics_manager.get_num_ongoing_requests() + len(
self._reserved_slots
)
async def reserve_slot(
self, request_metadata: RequestMetadata, slot_token: str
) -> Tuple[bool, int]:
"""Reserve replica capacity for a future dispatch call."""
if request_metadata.is_direct_ingress:
raise RuntimeError(
"Slot reservation is not supported for direct-ingress requests."
)
if not self._can_accept_request(request_metadata):
return False, self.get_num_ongoing_requests()
await self._semaphore.acquire()
self._reserved_slots.add(slot_token)
return True, self.get_num_ongoing_requests()
def release_slot(self, slot_token: str) -> Tuple[bool, int]:
"""Release replica capacity reserved by choose_replica()."""
if slot_token not in self._reserved_slots:
return False, self.get_num_ongoing_requests()
self._reserved_slots.remove(slot_token)
self._semaphore.release()
return True, self.get_num_ongoing_requests()
def get_metadata(self) -> ReplicaMetadata:
current_rank = ray.serve.context._get_internal_replica_context().rank
# Extract route patterns from ASGI app if available
route_patterns = None
if self._user_callable_asgi_app is not None:
# _user_callable_asgi_app is the actual ASGI app (FastAPI/Starlette)
# It's set when initialize_callable() returns an ASGI app
if hasattr(self._user_callable_asgi_app, "routes"):
route_patterns = extract_route_patterns(self._user_callable_asgi_app)
has_user_routing_stats_method = (
self._user_callable_wrapper is not None
and self._user_callable_wrapper.has_user_routing_stats_method
)
return (
self._version.deployment_config,
self._version,
self._initialization_latency,
self._internal_grpc_port,
self._docs_path,
self._http_port,
self._grpc_port,
current_rank,
route_patterns,
self.list_outbound_deployments(),
has_user_routing_stats_method,
self._gang_context,
self._replica_metadata,
)
def get_dynamically_created_handles(self) -> Set[DeploymentID]:
return self._dynamically_created_handles
def list_outbound_deployments(self) -> List[DeploymentID]:
"""List all outbound deployment IDs this replica calls into.
This includes:
- Handles created via get_deployment_handle()
- Handles passed as init args/kwargs to the deployment constructor
This is used to determine which deployments are reachable from this replica.
The list of DeploymentIDs can change over time as new handles can be created at runtime.
Also its not guaranteed that the list of DeploymentIDs are identical across replicas
because it depends on user code.
Returns:
A list of DeploymentIDs that this replica calls into.
"""
seen_deployment_ids: Set[DeploymentID] = set()
# First, collect dynamically created handles
for deployment_id in self.get_dynamically_created_handles():
seen_deployment_ids.add(deployment_id)
# Get the init args/kwargs
init_args = self._user_callable_wrapper._init_args
init_kwargs = self._user_callable_wrapper._init_kwargs
# Use _PyObjScanner to find all DeploymentHandle objects in:
# The init_args and init_kwargs (handles might be passed as init args)
scanner = _PyObjScanner(source_type=DeploymentHandle)
try:
handles = scanner.find_nodes((init_args, init_kwargs))
for handle in handles:
deployment_id = handle.deployment_id
seen_deployment_ids.add(deployment_id)
finally:
scanner.clear()
return list(seen_deployment_ids)
def _set_internal_replica_context(
self, *, servable_object: Callable = None, rank: ReplicaRank = None
):
# Calculate world_size from deployment config instead of storing it
world_size = self._deployment_config.num_replicas
# Create callback for registering dynamically created handles
def register_handle_callback(deployment_id: DeploymentID) -> None:
self._dynamically_created_handles.add(deployment_id)
code_version = self._version.code_version
ray.serve.context._set_internal_replica_context(
replica_id=self._replica_id,
servable_object=servable_object,
_deployment_config=self._deployment_config,
rank=rank,
world_size=world_size,
handle_registration_callback=register_handle_callback,
gang_context=self._gang_context,
code_version=code_version,
)
def _configure_logger_and_profilers(
self, logging_config: Union[None, Dict, LoggingConfig]
):
if logging_config is None:
logging_config = {}
if isinstance(logging_config, dict):
logging_config = LoggingConfig(**logging_config)
configure_component_logger(
component_type=ServeComponentType.REPLICA,
component_name=self._component_name,
component_id=self._component_id,
logging_config=logging_config,
buffer_size=RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE,
)
configure_component_memory_profiler(
component_type=ServeComponentType.REPLICA,
component_name=self._component_name,
component_id=self._component_id,
)
if logging_config.encoding == EncodingType.JSON:
# Create logging context for access logs as a performance optimization.
# While logging_utils can automatically add Ray core and Serve access log context,
# we pre-compute it here since context evaluation is expensive and this context
# will be reused for multiple access log entries.
ray_core_logging_context = CoreContextFilter.get_ray_core_logging_context()
# remove task level log keys from ray core logging context, it would be nice
# to have task level log keys here but we are letting those go in favor of
# performance optimization. Also we cannot include task level log keys here because
# they would referance the current task (__init__) and not the task that is logging.
for key in CoreContextFilter.TASK_LEVEL_LOG_KEYS:
ray_core_logging_context.pop(key, None)
self._access_log_context = {
**ray_core_logging_context,
SERVE_LOG_DEPLOYMENT: self._component_name,
SERVE_LOG_REPLICA: self._component_id,
SERVE_LOG_COMPONENT: ServeComponentType.REPLICA,
SERVE_LOG_APPLICATION: self._deployment_id.app_name,
"skip_context_filter": True,
"serve_access_log": True,
}
else:
self._access_log_context = {
"skip_context_filter": True,
"serve_access_log": True,
}
def _is_replica_quiescing(self, request_metadata: RequestMetadata) -> bool:
# During graceful shutdown, reject new handle-path requests so the
# router retries them on another replica. Direct ingress requests are
# not rejected: their servers are shutting down gracefully and anything
# that still arrives is served to completion.
return self._quiescing and not request_metadata.is_direct_ingress
def _can_accept_request(self, request_metadata: RequestMetadata) -> bool:
if self._is_replica_quiescing(request_metadata):
return False
if request_metadata.is_direct_ingress:
limit = self.max_queued_requests
if limit != -1 and self._num_queued_requests >= limit:
return False
return True
# This replica gates concurrent request handling with an asyncio.Semaphore.
# Each in-flight request acquires the semaphore. When the number of ongoing
# requests reaches max_ongoing_requests, the semaphore becomes locked.
# A new request can be accepted if the semaphore is currently unlocked.
return not self._semaphore.locked()
@contextmanager
def _handle_errors_and_metrics(
self, request_metadata: RequestMetadata
) -> Generator[StatusCodeCallback, None, None]:
start_time = time.time()
user_exception = None
status_code = None
def _status_code_callback(s: str):
nonlocal status_code
status_code = s
try:
yield _status_code_callback
except asyncio.CancelledError as e:
user_exception = e
self._on_request_cancelled(request_metadata, e)
except Exception as e:
user_exception = e
logger.exception("Request failed.")
self._on_request_failed(request_metadata, e)
latency_ms = (time.time() - start_time) * 1000
self._record_errors_and_metrics(
user_exception, status_code, latency_ms, request_metadata
)
if user_exception is not None and not request_metadata.is_direct_ingress:
raise user_exception from None
def _record_errors_and_metrics(
self,
user_exception: Optional[BaseException],
status_code: Optional[str],
latency_ms: float,
request_metadata: RequestMetadata,
):
http_method = request_metadata._http_method
route = request_metadata.route
call_method = request_metadata.call_method
if user_exception is None:
status_str = "OK"
elif isinstance(user_exception, asyncio.CancelledError):
status_str = "CANCELLED"
else:
status_str = "ERROR"
# Mutating self._access_log_context is not thread safe, but since this
# is only called from the same thread, it is safe. Mutating the same object
# because creating a new dict is expensive.
self._access_log_context[SERVE_LOG_ROUTE] = route
self._access_log_context[SERVE_LOG_REQUEST_ID] = request_metadata.request_id
logger.info(
access_log_msg(
method=http_method or "CALL",
route=route if self._ingress and route else call_method,
# Prefer the HTTP status code if it was populated.
status=status_code or status_str,
latency_ms=latency_ms,
client=request_metadata._client,
),
extra=self._access_log_context,
)
exception_type = (
type(user_exception).__name__ if user_exception is not None else None
)
self._metrics_manager.record_request_metrics(
route=route,
latency_ms=latency_ms,
is_error=user_exception is not None,
exception_type=exception_type,
)
if is_span_recording():
if request_metadata.is_http_request:
set_http_span_attributes(
method=request_metadata._http_method,
status_code=status_code,
route=request_metadata.route,
)
else:
set_rpc_span_attributes(
system=request_metadata._request_protocol,
method=request_metadata.call_method,
status_code=status_code,
service=self._deployment_id.name,
)
if user_exception is not None:
set_span_exception(user_exception, escaped=False)
# Record ingress metrics for direct ingress requests
if request_metadata.is_direct_ingress and status_code is not None:
if request_metadata.is_grpc_request:
protocol = RequestProtocol.GRPC
# Match the proxy's `method` tag (the full gRPC service method).
# gRPC status codes are names (e.g. "OK", "UNAVAILABLE"); anything
# other than OK is an error, mirroring ResponseStatus.is_error.
method = request_metadata._grpc_service_method
is_error = status_code != grpc.StatusCode.OK.name
else:
protocol = RequestProtocol.HTTP
method = request_metadata._http_method
is_error = status_code.startswith(("4", "5"))
self._metrics_manager.record_ingress_request_metrics(
protocol=protocol,
method=method,
route=route,
app_name=self._deployment_id.app_name,
deployment_name=self._deployment_id.name,
latency_ms=latency_ms,
is_error=is_error,
status_code=status_code,
)
def _unpack_proxy_args(
self,
request_metadata: RequestMetadata,
request_args: Tuple[Any],
request_kwargs: Dict[str, Any],
) -> Tuple[Tuple[Any], Dict[str, Any], Any]:
# Extract _ray_trace_ctx from kwargs at the entry point.
#
# Context: When tracing is enabled, Ray's tracing decorators inject
# _ray_trace_ctx into ServeReplica actor method calls. The ServeReplica
# actor methods properly handle this, but we
# need to extract it before calling user-defined deployment methods.
#
# Design: We return it so it can be passed to _wrap_request() which
# stores it in _RequestContext. Users can then access it via serve.context
# if needed (advanced use case), while keeping it out of their method signatures.
#
# For gRPC requests (when using RAY_SERVE_USE_GRPC_BY_DEFAULT),
# the _ray_trace_ctx is not injected into kwargs since there's no Ray actor
# call. Instead, the tracing context is passed via request_metadata.tracing_context.
# We fall back to using that if _ray_trace_ctx is not in kwargs.
ray_trace_ctx = request_kwargs.pop("_ray_trace_ctx", None)
if ray_trace_ctx is None:
ray_trace_ctx = request_metadata.tracing_context
if request_metadata.is_http_request:
assert len(request_args) == 1 and isinstance(
request_args[0], StreamingHTTPRequest
)
request: StreamingHTTPRequest = request_args[0]
scope = request.asgi_scope
receive = ASGIReceiveProxy(
scope, request_metadata, request.receive_asgi_messages
)
request_metadata._http_method = scope.get("method", "WS").upper()
request_args = (scope, receive)
elif request_metadata.is_grpc_request:
if len(request_args) == 1 and isinstance(
request_args[0], gRPCStreamingRequest
):
# Handle client streaming or bidirectional streaming request
streaming_request: gRPCStreamingRequest = request_args[0]
request_args, request_kwargs = self._setup_grpc_streaming_args(
request_metadata, streaming_request
)
else:
# Handle unary or server streaming request
assert len(request_args) == 1 and isinstance(
request_args[0], gRPCRequest
)
request: gRPCRequest = request_args[0]
method_info = self._user_callable_wrapper.get_user_method_info(
request_metadata.call_method
)
request_args = (request.user_request_proto,)
request_kwargs = (
{GRPC_CONTEXT_ARG_NAME: request_metadata.grpc_context}
if method_info.takes_grpc_context_kwarg
else {}
)
return request_args, request_kwargs, ray_trace_ctx
def _setup_grpc_streaming_args(
self,
request_metadata: RequestMetadata,
streaming_request: gRPCStreamingRequest,
) -> Tuple[Tuple[Any], Dict[str, Any]]:
"""Set up request args for gRPC client/bidirectional streaming.
Creates a gRPCInputStream that wraps the callback to the proxy,
allowing the user method to iterate over incoming request messages.
"""
# Look up the proxy actor fresh per-request to avoid stale handles
# if the proxy actor restarts (same name, new actor ID). This mirrors
# the per-request lookup in StreamingHTTPRequest.receive_asgi_messages.
proxy_actor = ray.get_actor(
streaming_request.proxy_actor_name, namespace=SERVE_NAMESPACE
)
# Create a cancel event that will be set when the client cancels
cancel_event = asyncio.Event()
# Create an async iterator that fetches messages from the proxy
async def request_message_iterator():
while True:
# Ray handles serialization - no manual pickle needed
(
has_more,
message,
is_cancelled,
) = await proxy_actor.receive_grpc_messages.remote(
streaming_request.session_id
)
if is_cancelled:
# Set the cancel event so is_cancelled() returns True
cancel_event.set()
if not has_more:
break
yield message
# Create the gRPCInputStream wrapper with the cancel event
input_stream = gRPCInputStream(
request_message_iterator(), cancel_event=cancel_event
)
method_info = self._user_callable_wrapper.get_user_method_info(
request_metadata.call_method
)
request_args = (input_stream,)
request_kwargs = (
{GRPC_CONTEXT_ARG_NAME: request_metadata.grpc_context}
if method_info.takes_grpc_context_kwarg
else {}
)
return request_args, request_kwargs
async def handle_request(
self, request_metadata: RequestMetadata, *request_args, **request_kwargs
) -> Tuple[bytes, Any]:
request_args, request_kwargs, ray_trace_ctx = self._unpack_proxy_args(
request_metadata, request_args, request_kwargs
)
with self._wrap_request(request_metadata, ray_trace_ctx):
async with self._start_request(request_metadata):
try:
return await self._user_callable_wrapper.call_user_method(
request_metadata, request_args, request_kwargs
)
except Exception as e:
# For gRPC requests, wrap exception with user-set status code.
# Non-gRPC requests should preserve the original exception
# without creating a self-referential __cause__ chain.
self._raise_user_exception(e, request_metadata)
async def handle_request_streaming(
self, request_metadata: RequestMetadata, *request_args, **request_kwargs
) -> AsyncGenerator[Any, None]:
"""Generator that is the entrypoint for all `stream=True` handle calls."""
request_args, request_kwargs, ray_trace_ctx = self._unpack_proxy_args(
request_metadata, request_args, request_kwargs
)
with self._wrap_request(
request_metadata, ray_trace_ctx
) as status_code_callback:
async with self._start_request(request_metadata):
try:
if request_metadata.is_http_request:
scope, receive = request_args
async for msgs in self._user_callable_wrapper.call_http_entrypoint(
request_metadata,
status_code_callback,
scope,
receive,
):
yield pickle.dumps(msgs)
else:
async for result in self._user_callable_wrapper.call_user_generator(
request_metadata,
request_args,
request_kwargs,
):
yield result
except Exception as e:
self._raise_user_exception(e, request_metadata)
def _maybe_wrap_grpc_exception(
self, e: BaseException, request_metadata: RequestMetadata
) -> BaseException:
"""Wrap exception with gRPCStatusError if user set a status code.
For gRPC requests, if the user set a status code on the grpc_context before
raising an exception, we wrap the exception with gRPCStatusError to preserve
the user's intended status code through the error handling path.
"""
if request_metadata.is_grpc_request:
grpc_context = request_metadata.grpc_context
if grpc_context and grpc_context.code():
return gRPCStatusError(
original_exception=e,
code=grpc_context.code(),
details=grpc_context.details(),
)
return e
def _raise_user_exception(
self, e: BaseException, request_metadata: RequestMetadata
) -> None:
wrapped_exception = self._maybe_wrap_grpc_exception(e, request_metadata)
if wrapped_exception is e:
raise e
raise wrapped_exception from e
async def handle_request_with_rejection(
self, request_metadata: RequestMetadata, *request_args, **request_kwargs
):
# Reject new requests once quiescing so the router retries them on
# another replica.
if self._is_replica_quiescing(request_metadata):
logger.info(
"Replica is shutting down, rejecting request "
f"{request_metadata.request_id}.",
extra={"log_to_stderr": False},
)
yield ReplicaQueueLengthInfo(False, self.get_num_ongoing_requests())
return
# Check if the replica has capacity for the request.
if not self._can_accept_request(request_metadata):
limit = self.max_ongoing_requests
logger.warning(
f"Replica at capacity of max_ongoing_requests={limit}, "
f"rejecting request {request_metadata.request_id}.",
extra={"log_to_stderr": False},
)
yield ReplicaQueueLengthInfo(False, self.get_num_ongoing_requests())
return
request_args, request_kwargs, ray_trace_ctx = self._unpack_proxy_args(
request_metadata, request_args, request_kwargs
)
with self._wrap_request(
request_metadata, ray_trace_ctx
) as status_code_callback:
async with self._start_request(request_metadata):
yield ReplicaQueueLengthInfo(
accepted=True,
# NOTE(edoakes): `_wrap_request` will increment the number
# of ongoing requests to include this one, so re-fetch the value.
num_ongoing_requests=self.get_num_ongoing_requests(),
)
try:
if request_metadata.is_http_request:
scope, receive = request_args
async for msgs in self._user_callable_wrapper.call_http_entrypoint(
request_metadata,
status_code_callback,
scope,
receive,
):
yield pickle.dumps(msgs)
elif request_metadata.is_streaming:
async for result in self._user_callable_wrapper.call_user_generator(
request_metadata,
request_args,
request_kwargs,
):
yield result
else:
yield await self._user_callable_wrapper.call_user_method(
request_metadata, request_args, request_kwargs
)
except Exception as e:
self._raise_user_exception(e, request_metadata)
async def _on_initialized(self):
await self._maybe_start_direct_ingress_servers()
current_rank = ray.serve.context._get_internal_replica_context().rank
self._set_internal_replica_context(
servable_object=self._user_callable_wrapper.user_callable,
rank=current_rank,
)
# Start the gRPC server for inter-deployment communication
add_ASGIServiceServicer_to_server(self, self._server)
self._internal_grpc_port = self._server.add_insecure_port("[::]:0")
await self._server.start()
logger.debug(
f"Started inter-deployment gRPC server on port {self._internal_grpc_port}"
)
# Save the initialization latency if the replica is initializing
# for the first time.
if self._initialization_latency is None:
self._initialization_latency = time.time() - self._initialization_start_time
def _raise_if_multiplexing_with_direct_ingress(self):
"""Reject model multiplexing on the ingress deployment under direct ingress.
Model multiplexing relies on the multiplexed model ID being propagated through
the proxy, which direct ingress bypasses (the model ID is never populated).
This runs after the user callable is initialized so it also catches
multiplexing that is wired up dynamically in the constructor (e.g.
`self._load_model = serve.multiplexed(...)(fn)`), which is invisible to the
static check performed at deploy time.
"""
if self._ingress and RAY_SERVE_ENABLE_DIRECT_INGRESS:
if _callable_uses_multiplexing(self._user_callable_wrapper._callable):
raise RuntimeError(
"Model multiplexing (`@serve.multiplexed`) is not supported on the "
"ingress deployment when direct ingress or HAProxy is enabled "
"(RAY_SERVE_ENABLE_DIRECT_INGRESS)."
)
async def initialize(
self,
deployment_config: Optional[DeploymentConfig],
rank: Optional[ReplicaRank],
gang_context: Optional[GangContext] = None,
):
if gang_context is not None:
self._gang_context = gang_context
if rank is not None:
self._rank = rank
self._set_internal_replica_context(
servable_object=self._user_callable_wrapper.user_callable, rank=rank
)
try:
# Ensure that initialization is only performed once.
# When controller restarts, it will call this method again.
async with self._user_callable_initialized_lock:
self._initialization_start_time = time.time()
if not self._user_callable_initialized:
self._user_callable_asgi_app = (
await self._user_callable_wrapper.initialize_callable()
)
self._raise_if_multiplexing_with_direct_ingress()
self._user_callable_wrapper.start_user_loop_watchdog(
self._event_loop
)
# Capture static per-replica metadata exactly once, now that
# the user callable is initialized. Unlike routing stats,
# this is never polled and is treated as immutable.
if self._user_callable_wrapper.has_user_replica_metadata_method:
self._replica_metadata = _validate_replica_metadata(
await self._user_callable_wrapper.call_user_record_replica_metadata()
)
if self._user_callable_asgi_app:
self._docs_path = (
self._user_callable_wrapper._callable.docs_path
)
await self._on_initialized()
self._user_callable_initialized = True
if self._user_callable_wrapper is not None:
initialized = (
hasattr(
self._user_callable_wrapper, "_user_autoscaling_stats"
)
and self._user_callable_wrapper._user_autoscaling_stats
is not None
)
self._metrics_manager.enable_custom_autoscaling_metrics(
custom_metrics_enabled=initialized,
record_autoscaling_stats_fn=self._user_callable_wrapper.call_record_autoscaling_stats,
)
if deployment_config is not None:
await self._user_callable_wrapper.set_sync_method_threadpool_limit(
deployment_config.max_ongoing_requests
)
rank = ray.serve.context._get_internal_replica_context().rank
await self._user_callable_wrapper.call_reconfigure(
deployment_config.user_config,
rank=rank,
)
# A new replica should not be considered healthy until it passes
# an initial health check. If an initial health check fails,
# consider it an initialization failure.
await self.check_health()
except Exception:
raise RuntimeError(traceback.format_exc()) from None
async def reconfigure(
self,
deployment_config: DeploymentConfig,
rank: ReplicaRank,
route_prefix: Optional[str] = None,
):
try:
user_config_changed = (
deployment_config.user_config != self._deployment_config.user_config
)
rank_changed = rank != self._rank
self._rank = rank
logging_config_changed = (
deployment_config.logging_config
!= self._deployment_config.logging_config
)
self._deployment_config = deployment_config
self._version = DeploymentVersion.from_deployment_version(
self._version, deployment_config, route_prefix
)
self._metrics_manager.set_autoscaling_config(
deployment_config.autoscaling_config
)
self._metrics_manager.set_max_ongoing_requests(
deployment_config.max_ongoing_requests
)
if logging_config_changed:
self._configure_logger_and_profilers(deployment_config.logging_config)
await self._user_callable_wrapper.set_sync_method_threadpool_limit(
deployment_config.max_ongoing_requests
)
if user_config_changed or rank_changed:
await self._user_callable_wrapper.call_reconfigure(
deployment_config.user_config,
rank=rank,
)
# We need to update internal replica context to reflect the new
# deployment_config and rank.
self._set_internal_replica_context(
servable_object=self._user_callable_wrapper.user_callable,
rank=rank,
)
self._route_prefix = self._version.route_prefix
except Exception:
raise RuntimeError(traceback.format_exc()) from None
def _on_request_cancelled(
self, request_metadata: RequestMetadata, e: asyncio.CancelledError
):
"""Recursively cancel child requests.
This includes all requests that are pending assignment, and gRPC
requests that have already been assigned.
"""
# Cancel child requests pending assignment
requests_pending_assignment = (
ray.serve.context._get_requests_pending_assignment(
request_metadata.internal_request_id
)
)
for task in requests_pending_assignment.values():
task.cancel()
# Cancel child requests that have already been assigned.
# This is for gRPC requests and direct ingress requests.
in_flight_requests = _get_in_flight_requests(
request_metadata.internal_request_id
)
for replica_result in in_flight_requests.values():
replica_result.cancel()
def _on_request_failed(self, request_metadata: RequestMetadata, e: Exception):
if ray.util.pdb._is_ray_debugger_post_mortem_enabled():
ray.util.pdb._post_mortem()
@asynccontextmanager
async def _start_request(self, request_metadata: RequestMetadata):
reserved_slot_token = request_metadata._reserved_slot_token
if reserved_slot_token:
if reserved_slot_token not in self._reserved_slots:
raise RuntimeError(
"Request tried to consume an unknown reserved slot "
f"{reserved_slot_token}."
)
self._reserved_slots.remove(reserved_slot_token)
else:
await self._semaphore.acquire()
try:
try:
self._metrics_manager.inc_num_ongoing_requests(request_metadata)
yield
finally:
self._metrics_manager.dec_num_ongoing_requests(request_metadata)
finally:
self._semaphore.release()
@contextmanager
def _track_queued_request(self) -> Generator[Callable[[], None], None, None]:
"""Count this request against max_queued_requests while it waits for a slot.
A direct-ingress request is queued from admission until it acquires an
ongoing-request slot. Use this as a `with` block around that wait.
Entering adds the request to the count. There are two ways it is removed.
1. The caller invokes the yielded callback once the slot is acquired, so
a running request does not keep occupying a queue slot.
2. The block exit removes it when the slot was never acquired, for
example a cancellation while the request is still queued.
The callback is idempotent, so both happening is safe and a cancelled
request cannot leak its count and wedge backpressure.
"""
self._num_queued_requests += 1
released = False
def release() -> None:
nonlocal released
if not released:
released = True
self._num_queued_requests -= 1
try:
yield release
finally:
release()
async def _drain_ongoing_requests(self, min_draining_period_s: float = 0.0):
"""Wait until the minimum draining period has elapsed and no ongoing
requests remain.
The minimum draining period gives load balancers time to deregister
this replica; a request admitted during it becomes ongoing and is
waited for like any other.
"""
wait_loop_period_s = self._deployment_config.graceful_shutdown_wait_loop_s
deadline = time.monotonic() + min_draining_period_s
while True:
await asyncio.sleep(wait_loop_period_s)
num_ongoing_requests = self.get_num_ongoing_requests()
min_period_remaining_s = deadline - time.monotonic()
if num_ongoing_requests > 0 or min_period_remaining_s > 0:
logger.info(
f"Waiting for an additional {wait_loop_period_s}s to shut down: "
f"{num_ongoing_requests} ongoing requests, "
f"{max(0.0, min_period_remaining_s):.1f}s minimum draining "
f"period remaining."
)
else:
logger.info(
"Drained ongoing requests; shutting down servers.",
extra={"log_to_stderr": False},
)
break
async def shutdown(self):
try:
self._user_callable_wrapper.stop_user_loop_watchdog()
await self._user_callable_wrapper.call_destructor()
except: # noqa: E722
# We catch a blanket exception since the constructor may still be
# running, so instance variables used by the destructor may not exist.
if self._user_callable_initialized:
logger.exception(
"__del__ ran before replica finished initializing, and "
"raised an exception."
)
else:
logger.exception("__del__ raised an exception.")
await self._metrics_manager.shutdown()
async def perform_graceful_shutdown(self):
self._shutting_down = True
# Shutdown budget, mirroring the controller's force-kill deadline (see
# `DeploymentReplica.stop`). Each step below consumes from it; once
# exhausted, steps degrade to an abrupt close.
shutdown_budget_s = self._deployment_config.graceful_shutdown_timeout_s
shutdown_deadline = time.monotonic() + shutdown_budget_s
def remaining_grace_s() -> float:
return max(0.0, shutdown_deadline - time.monotonic())
# If the replica was never initialized it never served traffic, so we
# can skip the drain entirely.
if self._user_callable_initialized:
# In direct ingress mode, hold the replica open at least
# RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S so load balancers can
# deregister it; the drain also waits for in-flight requests.
min_draining_period_s = (
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S
if RAY_SERVE_ENABLE_DIRECT_INGRESS and self._ingress
else 0.0
)
await self._drain_ongoing_requests(min_draining_period_s)
# Requests can still arrive after the drain (stale routers, keep-alive
# connections). Quiesce before reporting shutdown complete: reject new
# handle-path requests and shut every server down gracefully, so the
# controller's subsequent `ray.kill` can't sever an in-flight request.
self._quiescing = True
if self._direct_ingress_http_server is not None:
# Stop accepting, close idle keep-alives, finish in-flight
# requests, then the serve task exits.
self._direct_ingress_http_server.should_exit = True
try:
# On timeout, `wait_for` cancels the server task (abrupt close).
await asyncio.wait_for(
self._direct_ingress_http_server_task,
timeout=remaining_grace_s(),
)
except asyncio.TimeoutError:
logger.warning(
"Direct ingress HTTP server didn't shut down gracefully "
"within the graceful shutdown budget; closing it abruptly."
)
except Exception:
logger.exception("Error shutting down the direct ingress HTTP server.")
# Always cancel the task so the listener is guaranteed to stop even if
# the graceful close above errored (mirrors the gRPC path below).
if self._direct_ingress_http_server_task:
self._direct_ingress_http_server_task.cancel()
if self._direct_ingress_grpc_server is not None:
# Stop accepting new RPCs; wait for in-flight ones up to the
# remaining grace.
try:
await self._direct_ingress_grpc_server.stop(remaining_grace_s())
except Exception:
logger.exception("Error shutting down the direct ingress gRPC server.")
if self._direct_ingress_grpc_server_task:
self._direct_ingress_grpc_server_task.cancel()
# Stop the inter-deployment gRPC server (handle-path traffic) so
# `ray.kill` can't sever an executing handle request. Gate on the
# port: `self._server` is constructed in `__init__` but only started
# in `_on_initialized`.
if self._internal_grpc_port is not None:
try:
await self._server.stop(remaining_grace_s())
except Exception:
logger.exception(
"Error shutting down the inter-deployment gRPC server."
)
await self.shutdown()
logger.info(
"Graceful shutdown complete; replica exiting.",
extra={"log_to_stderr": False},
)
async def check_health(self):
try:
# Runs the user-defined check_health on the user code loop if defined.
# Otherwise, if the background watchdog has detected the user loop is
# unresponsive, raises immediately. If the watchdog is disabled
# (MAX_FAIL=0) or the fail counter hasn't reached the threshold yet,
# returns None.
f = self._user_callable_wrapper.call_user_health_check()
if f is not None:
await f
self._healthy = True
except Exception as e:
logger.warning("Replica health check failed.")
self._healthy = False
raise e from None
async def record_routing_stats(self) -> Dict[str, Any]:
try:
f = self._user_callable_wrapper.call_user_record_routing_stats()
if f is not None:
return await f
return {}
except Exception as e:
logger.warning("Replica record routing stats failed.")
raise e from None
@property
def max_queued_requests(self) -> int:
return self._deployment_config.max_queued_requests
async def _maybe_start_direct_ingress_servers(self):
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
return
if not self._ingress and not self._is_ingress_request_router:
return
async def allocate_and_start_server(start_server_fn, protocol):
"""Attempt to allocate a port and start the server with retries."""
is_port_in_use = False
for _ in range(RAY_SERVE_DIRECT_INGRESS_PORT_RETRY_COUNT):
port = await self._controller_handle.allocate_replica_port.remote(
self._node_id, self._replica_id.unique_id, protocol
)
logger.info(f"Allocated port {port} for {protocol}")
try:
server_task_and_server = await start_server_fn(port)
logger.info(
f"Successfully started {protocol} server on port {port}"
)
return port, server_task_and_server
except RuntimeError as e:
logger.warning(
f"Failed to start {protocol} server on port {port}: {e}. Retrying..."
)
# `start_asgi_http_server` raises a RuntimeError with the original OSError as the cause.
if isinstance(e.__cause__, OSError) and e.__cause__.errno in (
errno.EADDRINUSE,
errno.EADDRNOTAVAIL,
):
is_port_in_use = True
else:
is_port_in_use = False
# setting block_port to True because we are concluding that the port is
# in use by another service on the same node. Blocking port here is a small
# optimization to avoid trying to start the server on a the same port
# multiple times by other replicas.
await self._controller_handle.release_replica_port.remote(
self._node_id,
self._replica_id.unique_id,
port,
protocol,
block_port=True,
)
err_msg = f"Failed to allocate and start {protocol} server after retries"
if is_port_in_use:
err_msg = f"""
Failed to start {protocol} server: port already in use. Suggestion: Ensure that the Ray Serve direct ingress port ranges do not overlap with the Ray worker port range (min_worker_port to max_worker_port).
"""
raise RuntimeError(err_msg)
if self._ingress:
self._http_options, self._grpc_options, resolved_proxy_location = ray.get(
[
self._controller_handle.get_http_config.remote(),
self._controller_handle.get_grpc_config.remote(),
self._controller_handle.get_proxy_location.remote(),
]
)
else:
self._http_options, resolved_proxy_location = ray.get(
[
self._controller_handle.get_http_config.remote(),
self._controller_handle.get_proxy_location.remote(),
]
)
self._grpc_options = None
grpc_enabled = self._ingress and is_grpc_enabled(self._grpc_options)
# HTTP ingress is enabled unless the resolved proxy placement is Disabled.
http_enabled = resolved_proxy_location != ProxyLocation.Disabled
# Allocate and start HTTP server
if http_enabled:
async def start_http_server(port):
options = configure_http_middlewares(
configure_http_options_with_defaults(
HTTPOptions(**{**self._http_options.model_dump(), "port": port})
)
)
return await start_asgi_http_server(
self._direct_ingress_asgi,
options,
event_loop=self._event_loop,
enable_so_reuseport=False,
)
(
self._http_port,
(
self._direct_ingress_http_server_task,
self._direct_ingress_http_server,
),
) = await allocate_and_start_server(
start_server_fn=start_http_server,
protocol=RequestProtocol.HTTP,
)
# Allocate and start gRPC server for ingress replicas if enabled.
# Ingress request router replicas only need HTTP for /internal/route.
if grpc_enabled:
self._metrics_manager.enable_grpc_ingress_metrics()
async def start_grpc_server_fn(port):
options = gRPCOptions(
**{**self._grpc_options.model_dump(), "port": port}
)
return await start_grpc_server(
self._direct_ingress_service_handler_factory,
options,
event_loop=self._event_loop,
enable_so_reuseport=False,
)
(
self._grpc_port,
(
self._direct_ingress_grpc_server_task,
self._direct_ingress_grpc_server,
),
) = await allocate_and_start_server(
start_server_fn=start_grpc_server_fn,
protocol=RequestProtocol.GRPC,
)
started = []
if http_enabled:
started.append(f"HTTP server on port {self._http_port}")
if grpc_enabled:
started.append(f"gRPC server on port {self._grpc_port}")
if started:
logger.info(f"Started {' and '.join(started)}")
@contextmanager
def _tracing_context(self, request_metadata: RequestMetadata):
if not is_tracing_enabled():
yield
return
call_method = request_metadata.call_method
trace_context = extract_propagated_context(request_metadata.tracing_context)
trace_manager = TraceContextManager(
trace_name=f"replica_handle_request {self._deployment_id.name} {call_method}",
trace_context=trace_context,
)
with trace_manager:
if is_span_recording():
trace_attributes = {
"request_id": request_metadata.request_id,
"replica_id": self._replica_id.unique_id,
"deployment": self._deployment_id.name,
"app": self._deployment_id.app_name,
"call_method": request_metadata.call_method,
"route": request_metadata.route,
"multiplexed_model_id": request_metadata.multiplexed_model_id,
"is_streaming": request_metadata.is_streaming,
}
set_span_attributes(trace_attributes)
yield
@contextmanager
def _wrap_request(
self, request_metadata: RequestMetadata, ray_trace_ctx: Optional[Any] = None
) -> Generator[StatusCodeCallback, None, None]:
"""Context manager that wraps user method calls.
1) Sets the request context var with appropriate metadata.
2) Records the access log message (if not disabled).
3) Records per-request metrics via the metrics manager.
"""
with self._tracing_context(request_metadata):
ray.serve.context._serve_request_context.set(
ray.serve.context._RequestContext(
route=request_metadata.route,
request_id=request_metadata.request_id,
_internal_request_id=request_metadata.internal_request_id,
app_name=self._deployment_id.app_name,
multiplexed_model_id=request_metadata.multiplexed_model_id,
session_id=request_metadata.session_id,
grpc_context=request_metadata.grpc_context,
_client=request_metadata._client,
cancel_on_parent_request_cancel=self._ingress
and RAY_SERVE_ENABLE_DIRECT_INGRESS,
_ray_trace_ctx=ray_trace_ctx,
)
)
with self._handle_errors_and_metrics(
request_metadata
) as status_code_callback:
yield status_code_callback
@_wrap_grpc_call
async def HandleRequest(
self,
context: grpc.aio.ServicerContext,
request_metadata: RequestMetadata,
*request_args,
**request_kwargs,
):
result = await self.handle_request(
request_metadata, *request_args, **request_kwargs
)
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
return result
@_wrap_grpc_call
async def HandleRequestStreaming(
self,
context: grpc.aio.ServicerContext,
request_metadata: RequestMetadata,
*request_args,
**request_kwargs,
):
async for result in self.handle_request_streaming(
request_metadata, *request_args, **request_kwargs
):
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
yield result
@_wrap_grpc_call
async def HandleRequestWithRejection(
self,
context: grpc.aio.ServicerContext,
request_metadata: RequestMetadata,
*request_args,
**request_kwargs,
):
"""gRPC entrypoint for all unary requests with strict max_ongoing_requests enforcement
This generator yields a system message indicating if the request was accepted,
then the actual response.
If an exception occurred while processing the request, whether it's a user
exception or an error intentionally raised by Serve, it will be returned as
a gRPC response instead of raised directly.
"""
result_gen = self.handle_request_with_rejection(
request_metadata, *request_args, **request_kwargs
)
queue_len_info: ReplicaQueueLengthInfo = await result_gen.__anext__()
await context.send_initial_metadata(
[
("accepted", str(int(queue_len_info.accepted))),
("num_ongoing_requests", str(queue_len_info.num_ongoing_requests)),
]
)
if not queue_len_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.
return b""
result = await result_gen.__anext__()
# Consume the result generator to ensure all request operations are completed.
async for _ in result_gen:
pass
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
return result
@_wrap_grpc_call
async def HandleRequestWithRejectionStreaming(
self,
context: grpc.aio.ServicerContext,
request_metadata: RequestMetadata,
*request_args,
**request_kwargs,
) -> AsyncGenerator[Any, None]:
"""gRPC entrypoint for all streaming requests with strict max_ongoing_requests enforcement
This generator yields a system message indicating if the request was accepted,
then the actual response(s).
If an exception occurred while processing the request, whether it's a user
exception or an error intentionally raised by Serve, it will be returned as
a gRPC response instead of raised directly.
"""
result_gen = self.handle_request_with_rejection(
request_metadata, *request_args, **request_kwargs
)
queue_len_info: ReplicaQueueLengthInfo = await result_gen.__anext__()
await context.send_initial_metadata(
[
("accepted", str(int(queue_len_info.accepted))),
("num_ongoing_requests", str(queue_len_info.num_ongoing_requests)),
]
)
if not queue_len_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.
yield b""
return
async for result in result_gen:
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
yield result
async def _dataplane_health_check(self) -> Tuple[bool, str]:
healthy, message = True, HEALTHY_MESSAGE
if self._shutting_down:
healthy = False
message = "DRAINING"
elif not self._healthy:
healthy = False
message = "UNHEALTHY"
return healthy, message
def get_grpc_tracing_context(self, context: RayServegRPCContext):
"""Populate tracing context for gRPC requests.
This method extracts the "traceparent" and "tracestate" metadata from the
request headers and sets the tracing context from it.
"""
if not is_tracing_enabled():
return
tracing_ctx = {}
traceparent = context.traceparent()
if traceparent is not None:
tracing_ctx["traceparent"] = traceparent
tracestate = context.tracestate()
if tracestate is not None:
tracing_ctx["tracestate"] = tracestate
return tracing_ctx
async def _gen_direct_ingress_grpc_response(
self,
service_method: str,
context: grpc._cython.cygrpc._ServicerContext,
*,
request_input: Any,
is_streaming: bool,
) -> AsyncGenerator[bytes, None]:
"""Shared generator for the four direct-ingress gRPC user-request handlers.
Yields serialized response message bytes (zero or more) and sets the final
gRPC status on `context` as a side effect. App-mismatch (NOT_FOUND) and
backpressure (RESOURCE_EXHAUSTED) short-circuit by yielding nothing.
The two axes that distinguish the four cardinalities are passed in:
- input axis: `request_input` is the deserialized request proto (unary
input) or a `gRPCInputStream` over the native request iterator
(client/bidi streaming input).
- output axis: `is_streaming` selects `call_user_generator` (many results)
vs. `call_user_method` (a single result, wrapped as a 1-item generator).
"""
start_time = time.time()
c = RayServegRPCContext(context)
request_id = c.request_id() or generate_request_id()
c.set_trailing_metadata([("request_id", request_id)])
# If the request targets a different application, return NOT_FOUND.
# If no application is specified, serve this replica's app.
requested_app = c.application()
if requested_app and requested_app != self._deployment_id.app_name:
status = ResponseStatus(
code=grpc.StatusCode.NOT_FOUND,
message=(
f"Application '{requested_app}' not found. Ping "
"/ray.serve.RayServeAPIService/ListApplications for available "
"applications."
),
is_error=True,
)
set_grpc_code_and_details(context, status)
self._metrics_manager.record_ingress_request_metrics(
protocol=RequestProtocol.GRPC,
method=service_method,
route="",
app_name="",
deployment_name="",
latency_ms=(time.time() - start_time) * 1000.0,
is_error=True,
status_code=grpc.StatusCode.NOT_FOUND.name,
)
return
request_metadata = RequestMetadata(
request_id=request_id,
internal_request_id=generate_request_id(),
call_method=service_method.split("/")[-1],
_grpc_service_method=service_method,
_request_protocol=RequestProtocol.GRPC,
grpc_context=c,
app_name=self._deployment_id.app_name,
# TODO(edoakes): populate this.
multiplexed_model_id="",
route=self._deployment_id.app_name,
tracing_context=self.get_grpc_tracing_context(c),
is_streaming=is_streaming,
is_direct_ingress=True,
_client=format_grpc_peer_address(context.peer()),
)
if not self._can_accept_request(request_metadata):
status = ResponseStatus(
code=grpc.StatusCode.RESOURCE_EXHAUSTED,
message="Request dropped due to backpressure",
)
set_grpc_code_and_details(context, status)
return
method_info = self._user_callable_wrapper.get_user_method_info(
request_metadata.call_method
)
request_args = (request_input,)
request_kwargs = (
{GRPC_CONTEXT_ARG_NAME: request_metadata.grpc_context}
if method_info.takes_grpc_context_kwarg
else {}
)
if is_streaming:
result_gen = self._user_callable_wrapper.call_user_generator(
request_metadata, request_args, request_kwargs
)
else:
async def call_unary():
yield await self._user_callable_wrapper.call_user_method(
request_metadata, request_args, request_kwargs
)
result_gen = call_unary()
with (
self._wrap_request(request_metadata) as status_code_callback,
self._track_queued_request() as release_queue_slot,
):
async with self._start_request(request_metadata):
# Acquired an ongoing-request slot, so it's running, not queued.
release_queue_slot()
# Use the generic disconnect/timeout detecting wrapper.
replica_response_generator = ReplicaResponseGenerator(
result_gen,
timeout_s=self._grpc_options.request_timeout_s,
)
status = ResponseStatus(code=grpc.StatusCode.OK)
try:
async for result in replica_response_generator:
yield result.SerializeToString()
# Apply any user-set code/details/trailing metadata once the
# request completes successfully (sent as HTTP/2 trailers).
c._set_on_grpc_context(context)
except BaseException as e:
# For gRPC requests, wrap exception with user-set status code.
e = self._maybe_wrap_grpc_exception(e, request_metadata)
status = get_grpc_response_status(
e,
self._grpc_options.request_timeout_s,
request_metadata.request_id,
)
raise e
finally:
# Closing `result_gen` runs `call_user_generator`'s `finally`,
# which cancels the unit running the user method (its task,
# or the inline generator). This is a noop if the generator is
# already exhausted.
await result_gen.aclose()
# Record the status code for both success and error paths so
# ingress metrics are emitted for successful gRPC requests.
status_code_callback(status.code.name)
set_grpc_code_and_details(context, status)
async def _maybe_handle_builtin_grpc_service(
self,
service_method: str,
context: grpc._cython.cygrpc._ServicerContext,
) -> Optional[bytes]:
"""Handle the built-in RayServeAPIService unary-unary methods.
`Healthz` and `ListApplications` are health-check-style endpoints that do
not dispatch to user code; both run the dataplane health check, set the
gRPC status, and record ingress metrics identically -- only the response
message differs. Returns the serialized response bytes if `service_method`
is one of them, otherwise None (the request targets a user-defined method).
"""
if service_method not in (
"/ray.serve.RayServeAPIService/Healthz",
"/ray.serve.RayServeAPIService/ListApplications",
):
return None
start_time = time.time()
healthy, message = await self._dataplane_health_check()
code = grpc.StatusCode.OK if healthy else grpc.StatusCode.UNAVAILABLE
context.set_code(code)
context.set_details(message)
self._metrics_manager.record_ingress_request_metrics(
protocol=RequestProtocol.GRPC,
method=service_method,
route=self._deployment_id.app_name,
app_name=self._deployment_id.app_name,
deployment_name=self._deployment_id.name,
latency_ms=(time.time() - start_time) * 1000.0,
is_error=not healthy,
status_code=code.name,
)
if service_method == "/ray.serve.RayServeAPIService/Healthz":
return HealthzResponse(message=message).SerializeToString()
# NOTE(edoakes): ListApplications may be used for health checking. It
# returns only the app name this replica is serving.
return ListApplicationsResponse(
application_names=[self._deployment_id.app_name]
).SerializeToString()
async def _direct_ingress_unary_unary(
self,
service_method: str,
request_proto: Any,
context: grpc._cython.cygrpc._ServicerContext,
) -> bytes:
builtin_response = await self._maybe_handle_builtin_grpc_service(
service_method, context
)
if builtin_response is not None:
return builtin_response
response_generator = self._gen_direct_ingress_grpc_response(
service_method,
context,
request_input=request_proto,
is_streaming=False,
)
# Fully consume the generator (so finalizers run) and return the response bytes,
# or empty bytes if none were produced (returning `None` to gRPC would cause
# serialization errors).
result = b""
async for message in response_generator:
result = message
return result
async def _direct_ingress_unary_stream(
self,
service_method: str,
request_proto: Any,
context: grpc._cython.cygrpc._ServicerContext,
):
response_generator = self._gen_direct_ingress_grpc_response(
service_method,
context,
request_input=request_proto,
is_streaming=True,
)
async for message in response_generator:
yield message
def _make_grpc_input_stream(
self, request_iterator: Any
) -> Tuple[gRPCInputStream, Optional[gRPCDIReceiveStream]]:
"""Build the gRPCInputStream the user method iterates over.
The native request iterator is bound to the replica's server event loop, so
a `gRPCDIReceiveStream` drains it on the server loop and forwards messages to
the user method.
The gRPCInputStream is built with a `cancel_event` that is set when the
client disconnects/errors mid-stream, so the user method sees
`is_cancelled()` and a graceful end (matching the proxy path) rather than a
raw gRPC error.
Args:
request_iterator: The native gRPC request iterator (bound to the server
event loop) for this client/bidirectional streaming request.
Returns:
(input_stream, receive_stream) where receive_stream is the bridge draining
the native iterator, to be torn down after the request completes.
"""
cancel_event = asyncio.Event()
receive_stream = gRPCDIReceiveStream(
request_iterator,
self._user_callable_wrapper.event_loop,
cancel_event=cancel_event,
)
receive_stream.start()
return (
gRPCInputStream(receive_stream, cancel_event=cancel_event),
receive_stream,
)
async def _direct_ingress_stream_unary(
self,
service_method: str,
request_iterator: Any,
context: grpc._cython.cygrpc._ServicerContext,
) -> bytes:
input_stream, receive_proxy = self._make_grpc_input_stream(request_iterator)
try:
response_generator = self._gen_direct_ingress_grpc_response(
service_method,
context,
request_input=input_stream,
is_streaming=False,
)
# Fully consume the generator (so finalizers run) and return the response
# bytes, or empty bytes if none were produced (returning `None` to gRPC
# would cause serialization errors).
result = b""
async for message in response_generator:
result = message
return result
finally:
if receive_proxy is not None:
receive_proxy.cancel()
async def _direct_ingress_stream_stream(
self,
service_method: str,
request_iterator: Any,
context: grpc._cython.cygrpc._ServicerContext,
):
input_stream, receive_proxy = self._make_grpc_input_stream(request_iterator)
try:
response_generator = self._gen_direct_ingress_grpc_response(
service_method,
context,
request_input=input_stream,
is_streaming=True,
)
async for message in response_generator:
yield message
finally:
if receive_proxy is not None:
receive_proxy.cancel()
def _direct_ingress_service_handler_factory(
self, service_method: str, streaming_type: gRPCStreamingType
) -> Callable:
if streaming_type == gRPCStreamingType.UNARY_STREAM:
async def handler(*args, **kwargs):
async for result in self._direct_ingress_unary_stream(
service_method, *args, **kwargs
):
yield result
elif streaming_type == gRPCStreamingType.UNARY_UNARY:
async def handler(*args, **kwargs):
return await self._direct_ingress_unary_unary(
service_method, *args, **kwargs
)
elif streaming_type == gRPCStreamingType.STREAM_UNARY:
async def handler(*args, **kwargs):
return await self._direct_ingress_stream_unary(
service_method, *args, **kwargs
)
elif streaming_type == gRPCStreamingType.STREAM_STREAM:
async def handler(*args, **kwargs):
async for result in self._direct_ingress_stream_stream(
service_method, *args, **kwargs
):
yield result
else:
raise ValueError(f"Unsupported streaming type: {streaming_type}")
return handler
def get_asgi_tracing_context(self, headers: List[Tuple[bytes, bytes]]):
"""Extract tracing context from ASGI request headers.
This method extracts both "traceparent" and "tracestate" headers from the
request headers to maintain proper trace context propagation.
"""
if not is_tracing_enabled():
return None
tracing_ctx = None
for key, value in headers:
key_str = key.decode()
if key_str in ("traceparent", "tracestate"):
tracing_ctx = tracing_ctx or {}
tracing_ctx[key_str] = value.decode()
return tracing_ctx
def _determine_http_route(self, scope: Scope) -> str:
# Default to route prefix for consistency with non-DI mode
route = self._route_prefix
if self._user_callable_asgi_app is not None:
try:
matched_route = get_asgi_route_name(self._user_callable_asgi_app, scope)
if matched_route is not None:
route = matched_route
except Exception:
# If route matching fails, keep the route prefix
pass
return route
def _parse_request_timeout(self, headers: Dict[bytes, bytes]) -> Optional[float]:
"""Gets the desired request timeout from the headers.
If the header is missing or invalid, returns the default request timeout
from HttpOptions. If the header is non-positive, timeout is disabled.
"""
return parse_request_timeout_header(
headers, self._http_options.request_timeout_s
)
async def _direct_ingress_asgi(
self,
scope: Scope,
receive: Receive,
send: Send,
):
# NOTE(edoakes): it's important to only start the replica server after the
# constructor runs because we are using SO_REUSEPORT. We don't want a new
# replica to start handling connections until it's ready to serve traffic.
#
# This can be loosened to listen on the port but fail health checks once we no
# longer rely on SO_REUSEPORT.
assert (
self._user_callable_initialized
), "Replica server should only be started *after* the replica is initialized."
if self._route_prefix and self._route_prefix != "/":
scope["root_path"] = self._route_prefix
start_time = time.time()
method = scope.get("method", "WS").upper()
route = scope.get("path", "")
# Handle health check or routes request.
if route in ["/-/healthz", "/-/routes"]:
healthy, message = await self._dataplane_health_check()
status_code = 200 if healthy else 503
if route == "/-/routes" and healthy:
# routes endpoint returns only the route prefix andapp name the replica is serving.
message = {
self._route_prefix: self._deployment_id.app_name,
}
for msg in convert_object_to_asgi_messages(
message,
status_code=status_code,
):
await send(msg)
latency_ms = (time.time() - start_time) * 1000.0
self._metrics_manager.record_ingress_request_metrics(
protocol=RequestProtocol.HTTP,
method=method,
route=route,
app_name=self._deployment_id.app_name,
deployment_name=self._deployment_id.name,
latency_ms=latency_ms,
is_error=not healthy,
status_code=str(status_code),
)
return
# If the HTTP path does not match the deployment route prefix,
# it is invalid and we should not serve it. Ingress request router
# peer deployments (e.g. LLMRouter) have no route prefix; fall
# back to "" so any path (including the empty-path ASGI edge case)
# matches and downstream user code dispatches.
route_prefix = self._route_prefix or ""
if not route.startswith(route_prefix):
status_code = 404
for msg in convert_object_to_asgi_messages(
f"Path '{route}' not found. "
"Ping http://.../-/routes for available routes.",
status_code=status_code,
):
await send(msg)
latency_ms = (time.time() - start_time) * 1000.0
self._metrics_manager.record_ingress_request_metrics(
protocol=RequestProtocol.HTTP,
method=method,
route="",
app_name="",
deployment_name="",
latency_ms=latency_ms,
is_error=True,
status_code=str(status_code),
)
return
headers = dict(scope["headers"])
request_id = (
# RequestIdMiddleware populates the request ID in the headers if it isn't provided.
headers.get(SERVE_HTTP_REQUEST_ID_HEADER.encode("utf-8"), b"").decode(
"utf-8"
)
or generate_request_id()
)
request_disconnect_disabled = parse_disconnect_disabled_header(headers)
request_timeout_s = self._parse_request_timeout(headers)
session_id = parse_session_id_header(headers)
request_metadata = RequestMetadata(
request_id=request_id,
internal_request_id=generate_request_id(),
call_method="__call__",
route=self._determine_http_route(scope),
app_name=self._deployment_id.app_name,
# TODO(edoakes): populate the multiplexed model ID.
multiplexed_model_id="",
session_id=session_id,
is_streaming=True,
_request_protocol=RequestProtocol.HTTP,
tracing_context=self.get_asgi_tracing_context(scope["headers"]),
_http_method=scope.get("method", "WS").upper(),
is_direct_ingress=True,
_client=format_client_address(scope.get("client")),
)
if not self._can_accept_request(request_metadata):
# NOTE(abrar): its possible that we drop more requests than actual max_queued_requests
# because between incrementing and decrementing the queued requests, we yield to the event loop.
for msg in convert_object_to_asgi_messages(
"Request dropped due to backpressure",
status_code=503,
):
await send(msg)
return
# Optimization: we can avoid creating an async receive task if the client
# has disabled handling disconnects for this request.
if request_disconnect_disabled:
receive_proxy = receive
receive_task = None
else:
receive_proxy = ASGIDIReceiveProxy(
scope, receive, self._user_callable_wrapper.event_loop
)
receive_task = receive_proxy.fetch_until_disconnect_task()
response_started = False
response_finished = False
first_message_peeked = False
with (
self._wrap_request(request_metadata) as status_code_callback,
self._track_queued_request() as release_queue_slot,
):
async def send_user_message(msg: Dict):
nonlocal response_started
nonlocal response_finished
nonlocal first_message_peeked
if not first_message_peeked:
first_message_peeked = True
if msg["type"] == "http.response.start":
status_code_callback(str(msg["status"]))
await send(msg)
response_started = True
# more_body: "Signifies if there is additional content to come (as part
# of a Response Body message). If False, and the server is not expecting
# Response Trailers, response will be taken as complete and closed.
# Optional; if missing defaults to False."
# https://asgi.readthedocs.io/en/latest/specs/www.html#response-body-send-event
if msg["type"] == "http.response.body" and not msg.get(
"more_body", False
):
response_finished = True
async def call_asgi():
async with self._start_request(request_metadata):
# Acquired an ongoing-request slot, so it's running, not queued.
release_queue_slot()
if (
not self._user_callable_wrapper._run_user_code_in_separate_thread
):
user_method_info = (
self._user_callable_wrapper.get_user_method_info(
request_metadata.call_method
)
)
# `_call_http_entrypoint` will have already called
# `send_user_message`, so the ASGI messages will have
# already been sent back to the client.
await self._user_callable_wrapper._call_http_entrypoint(
user_method_info, scope, receive_proxy, send_user_message
)
else:
async for asgi_messages in self._user_callable_wrapper.call_http_entrypoint(
request_metadata, status_code_callback, scope, receive_proxy
):
for message in asgi_messages:
await send_user_message(message)
# Optimization: if Serve doesn't need to handle disconnects and
# timeouts for this request, we can avoid event loop overhead by
# directly awaiting the user code.
if receive_task is None and request_timeout_s is None:
return await call_asgi()
# Otherwise, we'd always need the call_asgi() task.
request_task = asyncio.create_task(call_asgi())
tasks = [request_task]
if receive_task is not None:
tasks.append(receive_task)
done, _ = await asyncio.wait(
tasks,
timeout=request_timeout_s,
return_when=asyncio.FIRST_COMPLETED,
)
# NOTE(zcin): it's possible that the request task has finished sending
# all ASGI messages, but the task is suspended and before it can fully
# complete, the client has sent a disconnect message after the request
# is completed. That is why we check for `response_finished` here.
if request_task in done or response_finished:
if receive_task is not None:
receive_task.cancel()
await request_task
elif receive_task in done:
request_task.cancel()
status_code_callback("499")
if not response_started:
msg = (
f"Client for request {request_id} disconnected, "
"cancelling request."
)
await send_http_response(msg, 499, send)
raise asyncio.CancelledError
else:
request_task.cancel()
if receive_task is not None:
receive_task.cancel()
status_code_callback("408")
if not response_started:
msg = (
f"Request {request_id} timed out after "
f"{self._http_options.request_timeout_s}s."
)
await send_http_response(msg, 408, send)
raise asyncio.CancelledError
def _get_inflight_direct_ingress_task_counts_for_testing(self) -> Dict[str, int]:
"""Return counts of in-flight direct-ingress asyncio tasks. Used for testing."""
counts = {"request_tasks": 0, "receive_tasks": 0}
for task in asyncio.all_tasks(self._event_loop):
if task.done():
continue
name = getattr(task.get_coro(), "__name__", "")
if name == "call_asgi":
counts["request_tasks"] += 1
elif name == "_fetch_until_disconnect":
counts["receive_tasks"] += 1
return counts
async def send_http_response(message, status_code, send):
for msg in convert_object_to_asgi_messages(
message,
status_code=status_code,
):
await send(msg)
class ReplicaActor:
"""Actor definition for replicas of Ray Serve deployments.
This class defines the interface that the controller and deployment handles
(i.e., from proxies and other replicas) use to interact with a replica.
All interaction with the user-provided callable is done via the
`UserCallableWrapper` class.
"""
async def __init__(
self,
replica_id: ReplicaID,
serialized_deployment_def: bytes,
serialized_init_args: bytes,
serialized_init_kwargs: bytes,
deployment_config_proto_bytes: bytes,
version: DeploymentVersion,
ingress: bool,
route_prefix: str,
is_ingress_request_router: bool = False,
):
deployment_config = DeploymentConfig.from_proto_bytes(
deployment_config_proto_bytes
)
deployment_def = cloudpickle.loads(serialized_deployment_def)
if isinstance(deployment_def, str):
deployment_def = _load_deployment_def_from_import_path(deployment_def)
self._replica_impl: Replica = create_replica_impl(
replica_id=replica_id,
deployment_def=deployment_def,
init_args=cloudpickle.loads(serialized_init_args),
init_kwargs=cloudpickle.loads(serialized_init_kwargs),
deployment_config=deployment_config,
version=version,
ingress=ingress,
route_prefix=route_prefix,
is_ingress_request_router=is_ingress_request_router,
)
def push_proxy_handle(self, handle: ActorHandle):
# NOTE(edoakes): it's important to call a method on the proxy handle to
# initialize its state in the C++ core worker.
handle.pong.remote()
def get_num_ongoing_requests(self) -> int:
"""Fetch the number of ongoing requests at this replica (queue length).
This runs on a separate thread (using a Ray concurrency group) so it will
not be blocked by user code.
"""
return self._replica_impl.get_num_ongoing_requests()
async def reserve_slot(
self, request_metadata: RequestMetadata, slot_token: str
) -> Tuple[bool, int]:
"""Reserve capacity for a future choose_replica/dispatch request."""
return await self._replica_impl.reserve_slot(request_metadata, slot_token)
def release_slot(self, slot_token: str) -> Tuple[bool, int]:
"""Release capacity reserved by choose_replica()."""
return self._replica_impl.release_slot(slot_token)
async def is_allocated(self) -> str:
"""poke the replica to check whether it's alive.
When calling this method on an ActorHandle, it will complete as
soon as the actor has started running. We use this mechanism to
detect when a replica has been allocated a worker slot.
At this time, the replica can transition from PENDING_ALLOCATION
to PENDING_INITIALIZATION startup state.
Returns:
The PID, actor ID, node ID, node IP, and log filepath id of the replica.
"""
return (
os.getpid(),
ray.get_runtime_context().get_actor_id(),
ray.get_runtime_context().get_worker_id(),
ray.get_runtime_context().get_node_id(),
ray.util.get_node_ip_address(),
ray.util.get_node_instance_id(),
get_component_logger_file_path(),
)
async def was_initialized(self) -> bool:
"""Whether this replica's user callable has finished initializing.
Used by the controller during recovery to detect actors that were
created but never received their initial
``initialize_and_get_metadata(rank=...)`` call (e.g., because the
previous controller crashed mid-startup). Such an actor has neither a
rank nor a fully-initialized user callable, and recovering it would
silently complete its initialization with ``rank=None``, breaking
rank tracking. The controller can call this method first and skip /
kill the actor when it returns False.
"""
return self._replica_impl._user_callable_initialized
def list_outbound_deployments(self) -> Optional[List[DeploymentID]]:
return self._replica_impl.list_outbound_deployments()
async def _get_inflight_direct_ingress_task_counts_for_testing(
self,
) -> Dict[str, int]:
return self._replica_impl._get_inflight_direct_ingress_task_counts_for_testing()
async def initialize_and_get_metadata(
self,
deployment_config: DeploymentConfig = None,
rank: ReplicaRank = None,
gang_context: GangContext = None,
) -> ReplicaMetadata:
"""Handles initializing the replica.
Returns: 5-tuple containing
1. DeploymentConfig of the replica
2. DeploymentVersion of the replica
3. Initialization duration in seconds
4. Port
5. FastAPI `docs_path`, if relevant (i.e. this is an ingress deployment integrated with FastAPI).
"""
# Unused `_after` argument is for scheduling: passing an ObjectRef
# allows delaying this call until after the `_after` call has returned.
await self._replica_impl.initialize(deployment_config, rank, gang_context)
return self._replica_impl.get_metadata()
async def check_health(self):
await self._replica_impl.check_health()
async def record_routing_stats(self) -> Dict[str, Any]:
return await self._replica_impl.record_routing_stats()
async def reconfigure(
self, deployment_config, rank: ReplicaRank, route_prefix: Optional[str] = None
) -> ReplicaMetadata:
await self._replica_impl.reconfigure(deployment_config, rank, route_prefix)
return self._replica_impl.get_metadata()
def _preprocess_request_args(
self,
pickled_request_metadata: bytes,
request_args: Tuple[Any],
) -> Tuple[RequestMetadata, Tuple[Any]]:
request_metadata = pickle.loads(pickled_request_metadata)
if request_metadata.is_http_request or request_metadata.is_grpc_request:
request_args = (pickle.loads(request_args[0]),)
return request_metadata, request_args
async def handle_request(
self,
pickled_request_metadata: bytes,
*request_args,
**request_kwargs,
) -> Tuple[bytes, Any]:
"""Entrypoint for `stream=False` calls."""
request_metadata, request_args = self._preprocess_request_args(
pickled_request_metadata, request_args
)
result = await self._replica_impl.handle_request(
request_metadata, *request_args, **request_kwargs
)
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
return result
async def handle_request_streaming(
self,
pickled_request_metadata: bytes,
*request_args,
**request_kwargs,
) -> AsyncGenerator[Any, None]:
"""Generator that is the entrypoint for all `stream=True` handle calls."""
request_metadata, request_args = self._preprocess_request_args(
pickled_request_metadata, request_args
)
async for result in self._replica_impl.handle_request_streaming(
request_metadata, *request_args, **request_kwargs
):
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
yield result
async def handle_request_with_rejection(
self,
pickled_request_metadata: bytes,
*request_args,
**request_kwargs,
) -> AsyncGenerator[Any, None]:
"""Entrypoint for all requests with strict max_ongoing_requests enforcement.
The first response from this generator is always a system message indicating
if the request was accepted (the replica has capacity for the request) or
rejected (the replica is already at max_ongoing_requests).
For non-streaming requests, there will only be one more message, the unary
result of the user request handler.
For streaming requests, the subsequent messages will be the results of the
user request handler (which must be a generator).
"""
request_metadata, request_args = self._preprocess_request_args(
pickled_request_metadata, request_args
)
async for result in self._replica_impl.handle_request_with_rejection(
request_metadata, *request_args, **request_kwargs
):
if isinstance(result, ReplicaQueueLengthInfo):
yield pickle.dumps(result)
else:
if request_metadata.is_grpc_request:
result = (request_metadata.grpc_context, result.SerializeToString())
yield result
async def handle_request_from_java(
self,
proto_request_metadata: bytes,
*request_args,
**request_kwargs,
) -> Any:
from ray.serve.generated.serve_pb2 import (
RequestMetadata as RequestMetadataProto,
)
proto = RequestMetadataProto.FromString(proto_request_metadata)
request_metadata: RequestMetadata = RequestMetadata(
request_id=proto.request_id,
internal_request_id=proto.internal_request_id,
call_method=proto.call_method,
multiplexed_model_id=proto.multiplexed_model_id,
route=proto.route,
)
return await self._replica_impl.handle_request(
request_metadata, *request_args, **request_kwargs
)
async def perform_graceful_shutdown(self):
await self._replica_impl.perform_graceful_shutdown()
@dataclass
class UserMethodInfo:
"""Wrapper for a user method and its relevant metadata."""
callable: Callable
name: str
is_asgi_app: bool
takes_any_args: bool
takes_grpc_context_kwarg: bool
@classmethod
def from_callable(cls, c: Callable, *, is_asgi_app: bool) -> "UserMethodInfo":
params = inspect.signature(c).parameters
return cls(
callable=c,
name=c.__name__,
is_asgi_app=is_asgi_app,
takes_any_args=len(params) > 0,
takes_grpc_context_kwarg=GRPC_CONTEXT_ARG_NAME in params,
)
class UserCallableWrapper:
"""Wraps a user-provided callable that is used to handle requests to a replica."""
service_unavailable_exceptions = (BackPressureError, DeploymentUnavailableError)
def __init__(
self,
deployment_def: Callable,
init_args: Tuple,
init_kwargs: Dict,
*,
deployment_id: DeploymentID,
run_sync_methods_in_threadpool: bool,
run_user_code_in_separate_thread: bool,
local_testing_mode: bool,
deployment_config: DeploymentConfig,
actor_id: str,
ray_actor_options: Optional[Dict] = None,
):
if not (inspect.isfunction(deployment_def) or inspect.isclass(deployment_def)):
raise TypeError(
"deployment_def must be a function or class. Instead, its type was "
f"{type(deployment_def)}."
)
self._deployment_def = deployment_def
self._init_args = init_args
self._init_kwargs = init_kwargs
self._is_function = inspect.isfunction(deployment_def)
self._deployment_id = deployment_id
self._local_testing_mode = local_testing_mode
self._destructor_called = False
self._run_sync_methods_in_threadpool = run_sync_methods_in_threadpool
self._run_user_code_in_separate_thread = run_user_code_in_separate_thread
self._warned_about_sync_method_change = False
self._cached_user_method_info: Dict[str, UserMethodInfo] = {}
# This is for performance optimization https://docs.python.org/3/howto/logging.html#optimization
self._is_enabled_for_debug = logger.isEnabledFor(logging.DEBUG)
# Will be populated in `initialize_callable`.
self._callable = None
self._user_health_check: Optional[Callable] = None
self._user_loop_probe_consecutive_fail_count: int = 0
self._user_loop_probe_task: Optional[asyncio.Task] = None
self._deployment_config = deployment_config
self._ray_actor_options = ray_actor_options or {}
self._user_code_threadpool: Optional[
concurrent.futures.ThreadPoolExecutor
] = None
if self._run_user_code_in_separate_thread:
# All interactions with user code run on this loop to avoid blocking the
# replica's main event loop.
self._user_code_event_loop: asyncio.AbstractEventLoop = (
asyncio.new_event_loop()
)
# Start event loop monitoring for the user code event loop.
# We create the monitor here but start it inside the thread function
# so the task is created on the correct thread.
self._user_code_loop_monitor = EventLoopMonitor(
component=EventLoopMonitor.COMPONENT_REPLICA,
loop_type=EventLoopMonitor.LOOP_TYPE_USER_CODE,
actor_id=actor_id,
extra_tags={
"deployment": self._deployment_id.name,
"application": self._deployment_id.app_name,
},
)
def _run_user_code_event_loop():
# Required so that calls to get the current running event loop work
# properly in user code.
asyncio.set_event_loop(self._user_code_event_loop)
self._configure_user_code_threadpool()
# Start monitoring before run_forever so the task is scheduled.
self._user_code_loop_monitor.start(self._user_code_event_loop)
self._user_code_event_loop.run_forever()
self._user_code_event_loop_thread = threading.Thread(
daemon=True,
target=_run_user_code_event_loop,
)
self._user_code_event_loop_thread.start()
else:
self._user_code_event_loop = asyncio.get_running_loop()
self._user_code_loop_monitor = None
self._configure_user_code_threadpool()
@property
def event_loop(self) -> asyncio.AbstractEventLoop:
return self._user_code_event_loop
def _user_loop_watchdog_enabled(self) -> bool:
"""Whether we run the optional background user-loop probe (and may fast-fail HC)."""
return (
self._run_user_code_in_separate_thread
and self._user_health_check is None
and USER_HEALTH_CHECK_PROBE_MAX_FAIL > 0
)
def start_user_loop_watchdog(self, main_loop: asyncio.AbstractEventLoop) -> None:
"""Periodically probe the user code loop from the replica main loop (opt-in).
With user code on a separate thread, a wedged asyncio loop can queue health
probes indefinitely. If ``RAY_SERVE_USER_HEALTH_CHECK_PROBE_MAX_FAIL`` > 0,
repeated timeouts fail ``check_health`` without waiting for the controller
RPC timeout. Applies even when ``RAY_SERVE_RUN_SYNC_IN_THREADPOOL=1``: async
work on that loop stays observable regardless of sync thread-pool idle/busy mix.
"""
if not self._user_loop_watchdog_enabled():
return
if self._user_loop_probe_task is not None:
return
self._user_loop_probe_task = main_loop.create_task(self._user_loop_probe_loop())
def stop_user_loop_watchdog(self) -> None:
if self._user_loop_probe_task is not None:
self._user_loop_probe_task.cancel()
self._user_loop_probe_task = None
# Reset so a subsequent start_user_loop_watchdog() doesn't begin with a stale
# fail count that would immediately trip call_user_health_check().
self._user_loop_probe_consecutive_fail_count = 0
async def _user_loop_probe_loop(self) -> None:
while True:
await asyncio.sleep(USER_HEALTH_CHECK_PROBE_INTERVAL_S)
fut = asyncio.run_coroutine_threadsafe(
asyncio.sleep(0), self._user_code_event_loop
)
try:
await asyncio.wait_for(
asyncio.wrap_future(fut),
timeout=USER_HEALTH_CHECK_PROBE_TIMEOUT_S,
)
except asyncio.TimeoutError:
self._user_loop_probe_consecutive_fail_count += 1
logger.warning(
"User event loop probe timed out "
f"(failed {self._user_loop_probe_consecutive_fail_count}/"
f"{USER_HEALTH_CHECK_PROBE_MAX_FAIL} before health check fails). "
f"deployment={self._deployment_id.name} "
f"app={self._deployment_id.app_name}",
)
continue
except Exception as e:
self._user_loop_probe_consecutive_fail_count += 1
logger.warning(
"User event loop probe failed: "
f"{e} ({self._user_loop_probe_consecutive_fail_count}/"
f"{USER_HEALTH_CHECK_PROBE_MAX_FAIL}). "
f"deployment={self._deployment_id.name} "
f"app={self._deployment_id.app_name}",
)
continue
self._user_loop_probe_consecutive_fail_count = 0
def _get_user_code_threadpool_max_workers(self) -> Optional[int]:
num_cpus = self._ray_actor_options.get("num_cpus")
if num_cpus is None:
return None
# Mirror ThreadPoolExecutor default behavior while respecting num_cpus.
return min(32, max(1, int(math.ceil(num_cpus))) + 4)
def _configure_user_code_threadpool(self) -> None:
max_workers = self._get_user_code_threadpool_max_workers()
if max_workers is None:
return
self._user_code_threadpool = concurrent.futures.ThreadPoolExecutor(
max_workers=max_workers
)
self._user_code_event_loop.set_default_executor(self._user_code_threadpool)
def _run_user_code(f: Callable) -> Callable:
"""Decorator to run a coroutine method on the user code event loop.
The method will be modified to be a sync function that returns a
`asyncio.Future` if user code is running in a separate event loop.
Otherwise, it will return the coroutine directly.
"""
assert inspect.iscoroutinefunction(
f
), "_run_user_code can only be used on coroutine functions."
@functools.wraps(f)
def wrapper(self, *args, **kwargs) -> Any:
coro = f(self, *args, **kwargs)
if self._run_user_code_in_separate_thread:
fut = asyncio.run_coroutine_threadsafe(coro, self._user_code_event_loop)
if self._local_testing_mode:
return fut
return asyncio.wrap_future(fut)
else:
return coro
return wrapper
@_run_user_code
async def set_sync_method_threadpool_limit(self, limit: int):
# NOTE(edoakes): the limit is thread local, so this must
# be run on the user code event loop.
to_thread.current_default_thread_limiter().total_tokens = limit
def get_user_method_info(self, method_name: str) -> UserMethodInfo:
"""Get UserMethodInfo for the provided call method name.
This method is cached to avoid repeated expensive calls to `inspect.signature`.
"""
if method_name in self._cached_user_method_info:
return self._cached_user_method_info[method_name]
if self._is_function:
user_method = self._callable
elif hasattr(self._callable, method_name):
user_method = getattr(self._callable, method_name)
else:
# Filter to methods that don't start with '__' prefix.
def callable_method_filter(attr):
if attr.startswith("__"):
return False
elif not callable(getattr(self._callable, attr)):
return False
return True
methods = list(filter(callable_method_filter, dir(self._callable)))
raise RayServeException(
f"Tried to call a method '{method_name}' "
"that does not exist. Available methods: "
f"{methods}."
)
info = UserMethodInfo.from_callable(
user_method,
is_asgi_app=isinstance(self._callable, ASGIAppReplicaWrapper),
)
self._cached_user_method_info[method_name] = info
return info
async def _send_user_result_over_asgi(
self,
result: Any,
asgi_args: ASGIArgs,
):
"""Handle the result from user code and send it over the ASGI interface.
If the result is already a Response type, it is sent directly. Otherwise, it
is converted to a custom Response type that handles serialization for
common Python objects.
"""
scope, receive, send = asgi_args.to_args_tuple()
if isinstance(result, starlette.responses.Response):
await result(scope, receive, send)
else:
await Response(result).send(scope, receive, send)
async def _call_func_or_gen(
self,
callable: Callable,
*,
args: Optional[Tuple[Any]] = None,
kwargs: Optional[Dict[str, Any]] = None,
is_streaming: bool = False,
generator_result_callback: Optional[Callable] = None,
run_sync_methods_in_threadpool_override: Optional[bool] = None,
) -> Tuple[Any, bool]:
"""Call the callable with the provided arguments.
This is a convenience wrapper that will work for `def`, `async def`,
generator, and async generator functions.
Returns the result and a boolean indicating if the result was a sync generator
that has already been consumed.
"""
sync_gen_consumed = False
args = args if args is not None else tuple()
kwargs = kwargs if kwargs is not None else dict()
run_sync_in_threadpool = (
self._run_sync_methods_in_threadpool
if run_sync_methods_in_threadpool_override is None
else run_sync_methods_in_threadpool_override
)
is_sync_method = (
inspect.isfunction(callable) or inspect.ismethod(callable)
) and not (
inspect.iscoroutinefunction(callable)
or inspect.isasyncgenfunction(callable)
)
if is_sync_method and run_sync_in_threadpool:
is_generator = inspect.isgeneratorfunction(callable)
if is_generator:
sync_gen_consumed = True
if not is_streaming:
# TODO(edoakes): make this check less redundant with the one in
# _handle_user_method_result.
raise TypeError(
f"Method '{callable.__name__}' returned a generator. "
"You must use `handle.options(stream=True)` to call "
"generators on a deployment."
)
def run_callable():
result = callable(*args, **kwargs)
if is_generator:
for r in result:
generator_result_callback(r)
result = None
return result
# NOTE(edoakes): we use anyio.to_thread here because it's what Starlette
# uses (and therefore FastAPI too). The max size of the threadpool is
# set to max_ongoing_requests in the replica wrapper.
# anyio.to_thread propagates ContextVars to the worker thread automatically.
result = await to_thread.run_sync(run_callable)
else:
if (
is_sync_method
and not self._warned_about_sync_method_change
and run_sync_methods_in_threadpool_override is None
):
self._warned_about_sync_method_change = True
warnings.warn(
RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING.format(
method_name=callable.__name__,
)
)
result = callable(*args, **kwargs)
if inspect.iscoroutine(result):
result = await result
return result, sync_gen_consumed
@property
def user_callable(self) -> Optional[Callable]:
return self._callable
async def _initialize_asgi_callable(self) -> None:
self._callable: ASGIAppReplicaWrapper
build_asgi_app = getattr(self._callable, SERVE_BUILD_ASGI_APP_METHOD, None)
is_late_bound = not hasattr(self._callable, "_asgi_app")
if is_late_bound and build_asgi_app is None:
raise TypeError(
f"ASGI app was not provided to the wrapper and "
f"`{SERVE_BUILD_ASGI_APP_METHOD}` is not defined on the deployment."
)
if build_asgi_app is not None:
app, _ = await self._call_func_or_gen(
build_asgi_app,
run_sync_methods_in_threadpool_override=False,
)
if app is None:
raise TypeError(
f"`{SERVE_BUILD_ASGI_APP_METHOD}` must return an ASGI app."
)
self._callable._set_asgi_app(app)
app: ASGIApp = self._callable.app
if hasattr(app, "add_exception_handler"):
# The reason we need to do this is because BackPressureError is a serve
# internal exception and FastAPI doesn't know how to handle it, so it
# treats it as a 500 error. With same reasoning, we are not handling
# TimeoutError because it's a generic exception the FastAPI knows how
# to handle. See https://www.starlette.io/exceptions/
def handle_exception(_: Request, exc: Exception):
return self.handle_exception(exc)
for exc in self.service_unavailable_exceptions:
app.add_exception_handler(exc, handle_exception)
await self._callable._run_asgi_lifespan_startup()
@_run_user_code
async def initialize_callable(self) -> Optional[ASGIApp]:
"""Initialize the user callable.
If the callable is an ASGI app wrapper (e.g., using @serve.ingress), returns
the ASGI app object, which may be used *read only* by the caller.
"""
if self._callable is not None:
raise RuntimeError("initialize_callable should only be called once.")
# This closure initializes user code and finalizes replica
# startup. By splitting the initialization step like this,
# we can already access this actor before the user code
# has finished initializing.
# The supervising state manager can then wait
# for allocation of this replica by using the `is_allocated`
# method. After that, it calls `reconfigure` to trigger
# user code initialization.
logger.info(
"Started initializing replica.",
extra={"log_to_stderr": False},
)
if self._is_function:
self._callable = self._deployment_def
else:
# This allows deployments to define an async __init__
# method (mostly used for testing).
self._callable = self._deployment_def.__new__(self._deployment_def)
await self._call_func_or_gen(
self._callable.__init__,
args=self._init_args,
kwargs=self._init_kwargs,
# Always run the constructor on the main user code thread.
run_sync_methods_in_threadpool_override=False,
)
if isinstance(self._callable, ASGIAppReplicaWrapper):
await self._initialize_asgi_callable()
if isinstance(self._callable, TaskConsumerWrapper):
self._callable.initialize_callable(
self._deployment_config.max_ongoing_requests
)
ServeUsageTag.NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE.record("1")
self._user_health_check = getattr(self._callable, HEALTH_CHECK_METHOD, None)
self._user_record_routing_stats = getattr(
self._callable, REQUEST_ROUTING_STATS_METHOD, None
)
self._user_record_replica_metadata = getattr(
self._callable, RECORD_REPLICA_METADATA_METHOD, None
)
self._user_autoscaling_stats = getattr(
self._callable, "record_autoscaling_stats", None
)
logger.info(
"Finished initializing replica.",
extra={"log_to_stderr": False},
)
return (
self._callable.app
if isinstance(self._callable, ASGIAppReplicaWrapper)
else None
)
def _raise_if_not_initialized(self, method_name: str):
if self._callable is None:
raise RuntimeError(
f"`initialize_callable` must be called before `{method_name}`."
)
def call_user_health_check(self) -> Optional[concurrent.futures.Future]:
self._raise_if_not_initialized("call_user_health_check")
# If the user provided a health check, call it on the user code thread. If user
# code blocks the event loop the health check may time out.
#
# When there is no user-defined health check, health is determined by the
# optional watchdog fail counter.
if (
self._user_loop_watchdog_enabled()
and self._user_loop_probe_consecutive_fail_count
>= USER_HEALTH_CHECK_PROBE_MAX_FAIL
):
raise RuntimeError(
"User event loop unresponsive: probe failed "
f"{self._user_loop_probe_consecutive_fail_count} consecutive times "
f"(limit {USER_HEALTH_CHECK_PROBE_MAX_FAIL})."
)
if self._user_health_check is not None:
return self._call_user_health_check()
return None
@property
def has_user_routing_stats_method(self) -> bool:
"""Whether the user has defined a record_routing_stats method."""
return self._user_record_routing_stats is not None
def call_user_record_routing_stats(self) -> Optional[concurrent.futures.Future]:
self._raise_if_not_initialized("call_user_record_routing_stats")
if self._user_record_routing_stats is not None:
return self._call_user_record_routing_stats()
return None
@property
def has_user_replica_metadata_method(self) -> bool:
"""Whether the user has defined a record_replica_metadata method."""
return self._user_record_replica_metadata is not None
def call_user_record_replica_metadata(
self,
) -> Optional[concurrent.futures.Future]:
self._raise_if_not_initialized("call_user_record_replica_metadata")
if self._user_record_replica_metadata is not None:
return self._call_user_record_replica_metadata()
return None
def call_record_autoscaling_stats(self) -> Optional[concurrent.futures.Future]:
self._raise_if_not_initialized("call_record_autoscaling_stats")
if self._user_autoscaling_stats is not None:
return self._call_user_autoscaling_stats()
return None
@_run_user_code
async def _call_user_health_check(self):
await self._call_func_or_gen(self._user_health_check)
@_run_user_code
async def _call_user_record_routing_stats(self) -> Dict[str, Any]:
result, _ = await self._call_func_or_gen(self._user_record_routing_stats)
return result
@_run_user_code
async def _call_user_record_replica_metadata(self) -> Dict[str, Any]:
result, _ = await self._call_func_or_gen(self._user_record_replica_metadata)
return result
@_run_user_code
async def _call_user_autoscaling_stats(self) -> Dict[str, Union[int, float]]:
result, _ = await self._call_func_or_gen(self._user_autoscaling_stats)
return result
@_run_user_code
async def call_reconfigure(self, user_config: Optional[Any], rank: ReplicaRank):
self._raise_if_not_initialized("call_reconfigure")
# NOTE(edoakes): there is the possibility of a race condition in user code if
# they don't have any form of concurrency control between `reconfigure` and
# other methods. See https://github.com/ray-project/ray/pull/42159.
# NOTE(abrar): The only way to subscribe to rank changes is to provide some user config.
# We can relax this in the future as more use cases arise for rank. I am reluctant to
# introduce behavior change for a feature we might not need.
user_subscribed_to_rank = False
if not self._is_function and hasattr(self._callable, RECONFIGURE_METHOD):
reconfigure_method = getattr(self._callable, RECONFIGURE_METHOD)
params = inspect.signature(reconfigure_method).parameters
user_subscribed_to_rank = "rank" in params
if user_config is not None or user_subscribed_to_rank:
if self._is_function:
raise ValueError(
"deployment_def must be a class to use user_config or rank"
)
elif not hasattr(self._callable, RECONFIGURE_METHOD):
raise RayServeException(
"user_config or rank specified but deployment "
+ self._deployment_id
+ " missing "
+ RECONFIGURE_METHOD
+ " method"
)
kwargs = {}
if user_subscribed_to_rank:
# For backwards compatibility, only pass rank if it is an argument to the reconfigure method.
kwargs["rank"] = rank
await self._call_func_or_gen(
getattr(self._callable, RECONFIGURE_METHOD),
args=(user_config,),
kwargs=kwargs,
)
async def _handle_user_method_result(
self,
result: Any,
user_method_info: UserMethodInfo,
*,
is_streaming: bool,
is_http_request: bool,
sync_gen_consumed: bool,
generator_result_callback: Optional[Callable],
asgi_args: Optional[ASGIArgs],
) -> Any:
"""Postprocess the result of a user method.
User methods can be regular unary functions or return a sync or async generator.
This method will raise an exception if the result is not of the expected type
(e.g., non-generator for streaming requests or generator for unary requests).
Generator outputs will be written to the `generator_result_callback`.
Note that HTTP requests are an exception: they are *always* streaming requests,
but for ASGI apps (like FastAPI), the actual method will be a regular function
implementing the ASGI `__call__` protocol.
"""
result_is_gen = inspect.isgenerator(result)
result_is_async_gen = inspect.isasyncgen(result)
if is_streaming:
if result_is_gen:
for r in result:
generator_result_callback(r)
elif result_is_async_gen:
async for r in result:
generator_result_callback(r)
elif is_http_request and not user_method_info.is_asgi_app:
# For the FastAPI codepath, the response has already been sent over
# ASGI, but for the vanilla deployment codepath we need to send it.
await self._send_user_result_over_asgi(result, asgi_args)
elif not is_http_request and not sync_gen_consumed:
# If a unary method is called with stream=True for anything EXCEPT
# an HTTP request, raise an error.
# HTTP requests are always streaming regardless of if the method
# returns a generator, because it's provided the result queue as its
# ASGI `send` interface to stream back results.
raise TypeError(
f"Called method '{user_method_info.name}' with "
"`handle.options(stream=True)` but it did not return a "
"generator."
)
else:
assert (
not is_http_request
), "All HTTP requests go through the streaming codepath."
if result_is_gen or result_is_async_gen:
raise TypeError(
f"Method '{user_method_info.name}' returned a generator. "
"You must use `handle.options(stream=True)` to call "
"generators on a deployment."
)
return result
async def call_http_entrypoint(
self,
request_metadata: RequestMetadata,
status_code_callback: StatusCodeCallback,
scope: Scope,
receive: Receive,
) -> Any:
result_queue = MessageQueue()
user_method_info = self.get_user_method_info(request_metadata.call_method)
if self._run_user_code_in_separate_thread:
# `asyncio.Event`s are not thread safe, so `call_soon_threadsafe` must be
# used to interact with the result queue from the user callable thread.
system_event_loop = asyncio.get_running_loop()
async def enqueue(item: Any):
system_event_loop.call_soon_threadsafe(result_queue.put_nowait, item)
call_future = self._call_http_entrypoint(
user_method_info, scope, receive, enqueue
)
else:
async def enqueue(item: Any):
result_queue.put_nowait(item)
call_future = asyncio.create_task(
self._call_http_entrypoint(user_method_info, scope, receive, enqueue)
)
first_message_peeked = False
async for messages in result_queue.fetch_messages_from_queue(call_future):
# HTTP (ASGI) messages are only consumed by the proxy so batch them
# and use vanilla pickle (we know it's safe because these messages
# only contain primitive Python types).
# Peek the first ASGI message to determine the status code.
if not first_message_peeked:
msg = messages[0]
first_message_peeked = True
if msg["type"] == "http.response.start":
# HTTP responses begin with exactly one
# "http.response.start" message containing the "status"
# field. Other response types like WebSockets may not.
status_code_callback(str(msg["status"]))
yield messages
@_run_user_code
async def _call_http_entrypoint(
self,
user_method_info: UserMethodInfo,
scope: Scope,
receive: Receive,
send: Send,
) -> Any:
"""Call an HTTP entrypoint.
`send` is used to communicate the results of streaming responses.
Raises any exception raised by the user code so it can be propagated as a
`RayTaskError`.
"""
self._raise_if_not_initialized("_call_http_entrypoint")
if self._is_enabled_for_debug:
logger.debug(
f"Started executing request to method '{user_method_info.name}'.",
extra={"log_to_stderr": False, "serve_access_log": True},
)
if user_method_info.is_asgi_app:
request_args = (scope, receive, send)
elif not user_method_info.takes_any_args:
# Edge case to support empty HTTP handlers: don't pass the Request
# argument if the callable has no parameters.
request_args = tuple()
else:
# Non-FastAPI HTTP handlers take only the starlette `Request`.
request_args = (starlette.requests.Request(scope, receive, send),)
receive_task = None
try:
if hasattr(receive, "fetch_until_disconnect"):
receive_task = asyncio.create_task(receive.fetch_until_disconnect())
result, sync_gen_consumed = await self._call_func_or_gen(
user_method_info.callable,
args=request_args,
kwargs={},
is_streaming=True,
generator_result_callback=send,
)
final_result = await self._handle_user_method_result(
result,
user_method_info,
is_streaming=True,
is_http_request=True,
sync_gen_consumed=sync_gen_consumed,
generator_result_callback=send,
asgi_args=ASGIArgs(scope, receive, send),
)
if receive_task is not None and not receive_task.done():
receive_task.cancel()
return final_result
except Exception as e:
if not user_method_info.is_asgi_app:
response = self.handle_exception(e)
await self._send_user_result_over_asgi(
response, ASGIArgs(scope, receive, send)
)
if receive_task is not None and not receive_task.done():
receive_task.cancel()
raise
except asyncio.CancelledError:
if receive_task is not None and not receive_task.done():
# Do NOT cancel the receive task if the request has been
# cancelled, but the call is a batched call. This is
# because we cannot guarantee cancelling the batched
# call, so in the case that the call continues executing
# we should continue fetching data from the client.
if not hasattr(user_method_info.callable, "set_max_batch_size"):
receive_task.cancel()
raise
async def call_user_generator(
self,
request_metadata: RequestMetadata,
request_args: Tuple[Any],
request_kwargs: Dict[str, Any],
) -> AsyncGenerator[Any, None]:
"""Calls a user method for a streaming call and yields its results.
The user method is called in an asyncio `Task` and places its results on a
`result_queue`. This method pulls and yields from the `result_queue`.
If this generator is closed before the user method finishes (e.g. the
client disconnected mid-stream), the unit running the user code is
cancelled so a `CancelledError` is raised inside the user generator: the
`call_future` task in separate-thread mode, or (inline) the user generator
itself in same-loop mode.
"""
if not self._run_user_code_in_separate_thread:
gen = await self._call_user_generator(
request_metadata, request_args, request_kwargs
)
try:
async for result in gen:
yield result
finally:
# User code runs inline; closing the wrapper injects a
# CancelledError into the user generator (see `_call_generator_async`).
await gen.aclose()
else:
result_queue = MessageQueue()
# `asyncio.Event`s are not thread safe, so `call_soon_threadsafe` must be
# used to interact with the result queue from the user callable thread.
system_event_loop = asyncio.get_running_loop()
def _enqueue_thread_safe(item: Any):
system_event_loop.call_soon_threadsafe(result_queue.put_nowait, item)
call_future = self._call_user_generator(
request_metadata,
request_args,
request_kwargs,
enqueue=_enqueue_thread_safe,
)
try:
async for messages in result_queue.fetch_messages_from_queue(
call_future
):
for msg in messages:
yield msg
finally:
# Cancel the user-code task so a CancelledError is raised inside the
# user method. No-op if it already completed.
if not call_future.done():
call_future.cancel()
@_run_user_code
async def _call_user_generator(
self,
request_metadata: RequestMetadata,
request_args: Tuple[Any],
request_kwargs: Dict[str, Any],
*,
enqueue: Optional[Callable] = None,
) -> Optional[AsyncGenerator[Any, None]]:
"""Call a user generator.
The `generator_result_callback` is used to communicate the results of generator
methods.
Raises any exception raised by the user code so it can be propagated as a
`RayTaskError`.
"""
self._raise_if_not_initialized("_call_user_generator")
request_args = request_args if request_args is not None else tuple()
request_kwargs = request_kwargs if request_kwargs is not None else dict()
user_method_info = self.get_user_method_info(request_metadata.call_method)
callable = user_method_info.callable
is_sync_method = (
inspect.isfunction(callable) or inspect.ismethod(callable)
) and not (
inspect.iscoroutinefunction(callable)
or inspect.isasyncgenfunction(callable)
)
if self._is_enabled_for_debug:
logger.debug(
f"Started executing request to method '{user_method_info.name}'.",
extra={"log_to_stderr": False, "serve_access_log": True},
)
async def _call_generator_async() -> AsyncGenerator[Any, None]:
gen = callable(*request_args, **request_kwargs)
if inspect.iscoroutine(gen):
gen = await gen
try:
if inspect.isgenerator(gen):
for result in gen:
yield result
elif inspect.isasyncgen(gen):
async for result in gen:
yield result
else:
raise TypeError(
f"Called method '{user_method_info.name}' with "
"`handle.options(stream=True)` but it did not return a "
"generator."
)
finally:
# If this wrapper is closed before the user generator finishes
# (e.g. the client disconnected), inject a CancelledError into the
# user's async generator so its cancellation handling runs. (Sync
# generators have no await points to cancel.) No-op if it already
# finished, in which case athrow raises StopAsyncIteration.
if inspect.isasyncgen(gen):
try:
await gen.athrow(asyncio.CancelledError())
except (StopAsyncIteration, asyncio.CancelledError):
pass
def _call_generator_sync():
gen = callable(*request_args, **request_kwargs)
if inspect.isgenerator(gen):
for result in gen:
enqueue(result)
else:
raise TypeError(
f"Called method '{user_method_info.name}' with "
"`handle.options(stream=True)` but it did not return a generator."
)
if enqueue and is_sync_method and self._run_sync_methods_in_threadpool:
await to_thread.run_sync(_call_generator_sync)
elif enqueue:
async def gen_coro_wrapper():
async for result in _call_generator_async():
enqueue(result)
await gen_coro_wrapper()
else:
return _call_generator_async()
@_run_user_code
async def call_user_method(
self,
request_metadata: RequestMetadata,
request_args: Tuple[Any],
request_kwargs: Dict[str, Any],
) -> Any:
"""Call a (unary) user method.
Raises any exception raised by the user code so it can be propagated as a
`RayTaskError`.
"""
self._raise_if_not_initialized("call_user_method")
if self._is_enabled_for_debug:
logger.debug(
f"Started executing request to method '{request_metadata.call_method}'.",
extra={"log_to_stderr": False, "serve_access_log": True},
)
user_method_info = self.get_user_method_info(request_metadata.call_method)
result, _ = await self._call_func_or_gen(
user_method_info.callable,
args=request_args,
kwargs=request_kwargs,
is_streaming=False,
)
if inspect.isgenerator(result) or inspect.isasyncgen(result):
raise TypeError(
f"Method '{user_method_info.name}' returned a generator. "
"You must use `handle.options(stream=True)` to call "
"generators on a deployment."
)
return result
def handle_exception(self, exc: Exception):
if isinstance(exc, self.service_unavailable_exceptions):
return starlette.responses.Response(exc.message, status_code=503)
else:
return starlette.responses.Response(
"Internal Server Error", status_code=500
)
@_run_user_code
async def call_destructor(self):
"""Explicitly call the `__del__` method of the user callable.
Calling this multiple times has no effect; only the first call will
actually call the destructor.
"""
if self._callable is None:
logger.debug(
"This replica has not yet started running user code. "
"Skipping __del__."
)
return
# Only run the destructor once. This is safe because there is no `await` between
# checking the flag here and flipping it to `True` below.
if self._destructor_called:
return
self._destructor_called = True
try:
if hasattr(self._callable, "__del__"):
# Make sure to accept `async def __del__(self)` as well.
await self._call_func_or_gen(
self._callable.__del__,
# Always run the destructor on the main user callable thread.
run_sync_methods_in_threadpool_override=False,
)
if hasattr(self._callable, "__serve_multiplex_wrapper"):
await getattr(self._callable, "__serve_multiplex_wrapper").shutdown()
except Exception as e:
logger.exception(f"Exception during graceful shutdown of replica: {e}")
finally:
if self._user_code_threadpool is not None:
self._user_code_threadpool.shutdown(wait=False)