1904 lines
71 KiB
Python
1904 lines
71 KiB
Python
import asyncio
|
|
import gc
|
|
import json
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from typing import Any, Callable, Dict, Generator, Optional, Set, Tuple
|
|
|
|
import grpc
|
|
import starlette
|
|
import starlette.routing
|
|
from packaging import version
|
|
from starlette.types import Receive
|
|
|
|
import ray
|
|
from ray._common.filters import CoreContextFilter
|
|
from ray._common.utils import get_or_create_event_loop
|
|
from ray.serve._private.common import (
|
|
DeploymentID,
|
|
EndpointInfo,
|
|
NodeId,
|
|
ReplicaID,
|
|
RequestMetadata,
|
|
RequestProtocol,
|
|
gRPCStreamingRequest,
|
|
)
|
|
from ray.serve._private.constants import (
|
|
HEALTHY_MESSAGE,
|
|
PROXY_MIN_DRAINING_PERIOD_S,
|
|
RAY_SERVE_ENABLE_HA_PROXY,
|
|
RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS,
|
|
RAY_SERVE_HAPROXY_METRICS_ENABLED,
|
|
RAY_SERVE_PROXY_GC_THRESHOLD,
|
|
RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE,
|
|
RAY_SERVE_WORKER_PROXY_GRPC_PORT,
|
|
RAY_SERVE_WORKER_PROXY_HTTP_PORT,
|
|
SERVE_CONTROLLER_NAME,
|
|
SERVE_HTTP_REQUEST_ID_HEADER,
|
|
SERVE_LOG_COMPONENT,
|
|
SERVE_LOG_COMPONENT_ID,
|
|
SERVE_LOG_REQUEST_ID,
|
|
SERVE_LOG_ROUTE,
|
|
SERVE_LOGGER_NAME,
|
|
SERVE_MULTIPLEXED_MODEL_ID,
|
|
SERVE_NAMESPACE,
|
|
)
|
|
from ray.serve._private.default_impl import get_proxy_handle
|
|
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 (
|
|
MessageQueue,
|
|
_matches_session_id_header,
|
|
configure_http_middlewares,
|
|
convert_object_to_asgi_messages,
|
|
get_http_response_status,
|
|
parse_disconnect_disabled_header,
|
|
parse_request_timeout_header,
|
|
receive_http_body,
|
|
send_http_response_on_exception,
|
|
start_asgi_http_server,
|
|
)
|
|
from ray.serve._private.logging_utils import (
|
|
access_log_msg,
|
|
configure_component_logger,
|
|
configure_component_memory_profiler,
|
|
format_client_address,
|
|
get_component_logger_file_path,
|
|
)
|
|
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
|
|
from ray.serve._private.proxy_request_response import (
|
|
ASGIProxyRequest,
|
|
HandlerMetadata,
|
|
ProxyRequest,
|
|
ResponseGenerator,
|
|
ResponseHandlerInfo,
|
|
ResponseStatus,
|
|
gRPCProxyRequest,
|
|
gRPCStreamingType,
|
|
)
|
|
from ray.serve._private.proxy_response_generator import ProxyResponseGenerator
|
|
from ray.serve._private.proxy_router import ProxyRouter
|
|
from ray.serve._private.request_ingress_metrics import RequestIngressMetrics
|
|
from ray.serve._private.tracing_utils import (
|
|
is_span_recording,
|
|
set_http_span_attributes,
|
|
set_rpc_span_attributes,
|
|
set_span_attributes,
|
|
set_span_exception,
|
|
set_span_name,
|
|
set_trace_status,
|
|
setup_tracing,
|
|
tracing_decorator_factory,
|
|
)
|
|
from ray.serve._private.usage import ServeUsageTag
|
|
from ray.serve._private.utils import (
|
|
asyncio_grpc_exception_handler,
|
|
generate_request_id,
|
|
get_head_node_id,
|
|
is_grpc_enabled,
|
|
)
|
|
from ray.serve.config import HTTPOptions, gRPCOptions
|
|
from ray.serve.generated.serve_pb2 import HealthzResponse, ListApplicationsResponse
|
|
from ray.serve.handle import DeploymentHandle
|
|
from ray.serve.schema import EncodingType, LoggingConfig
|
|
|
|
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
|
|
|
SOCKET_REUSE_PORT_ENABLED = (
|
|
os.environ.get("SERVE_SOCKET_REUSE_PORT_ENABLED", "1") == "1"
|
|
)
|
|
|
|
if os.environ.get("SERVE_REQUEST_PROCESSING_TIMEOUT_S") is not None:
|
|
logger.warning(
|
|
"The `SERVE_REQUEST_PROCESSING_TIMEOUT_S` environment variable has "
|
|
"been deprecated. Please set `request_timeout_s` in your Serve config's "
|
|
"`http_options` or `grpc_options` field instead. `SERVE_REQUEST_PROCESSING_TIMEOUT_S` will be "
|
|
"ignored in future versions. See: https://docs.ray.io/en/releases-2.5.1/serve/a"
|
|
"pi/doc/ray.serve.schema.HTTPOptionsSchema.html#ray.serve.schema.HTTPOptionsSch"
|
|
"ema.request_timeout_s and https://docs.ray.io/en/latest/serve/api/"
|
|
"doc/ray.serve.config.gRPCOptions.request_timeout_s.html#"
|
|
"ray.serve.config.gRPCOptions.request_timeout_s"
|
|
)
|
|
|
|
|
|
INITIAL_BACKOFF_PERIOD_SEC = 0.05
|
|
MAX_BACKOFF_PERIOD_SEC = 5
|
|
|
|
DRAINING_MESSAGE = "This node is being drained."
|
|
|
|
|
|
class GenericProxy(ABC):
|
|
"""This class is served as the base class for different types of proxies.
|
|
It contains all the common setup and methods required for running a proxy.
|
|
|
|
The proxy subclass need to implement the following methods:
|
|
- `protocol()`
|
|
- `not_found_response()`
|
|
- `routes_response()`
|
|
- `health_response()`
|
|
- `setup_request_context_and_handle()`
|
|
- `send_request_to_replica()`
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
node_id: NodeId,
|
|
node_ip_address: str,
|
|
is_head: bool,
|
|
proxy_router: ProxyRouter,
|
|
request_timeout_s: Optional[float] = None,
|
|
access_log_context: Dict[str, Any] = None,
|
|
):
|
|
self.request_timeout_s = request_timeout_s
|
|
if self.request_timeout_s is not None and self.request_timeout_s < 0:
|
|
self.request_timeout_s = None
|
|
|
|
self._node_id = node_id
|
|
self._is_head = is_head
|
|
|
|
self.proxy_router = proxy_router
|
|
|
|
self._proxy_metrics = RequestIngressMetrics(
|
|
self.protocol,
|
|
source="proxy",
|
|
node_id=node_id,
|
|
node_ip_address=node_ip_address,
|
|
)
|
|
|
|
# `self._ongoing_requests` is used to count the number of ongoing requests
|
|
self._ongoing_requests = 0
|
|
# The time when the node starts to drain.
|
|
# The node is not draining if it's None.
|
|
self._draining_start_time: Optional[float] = None
|
|
|
|
self._access_log_context = access_log_context or {}
|
|
|
|
getattr(ServeUsageTag, f"{self.protocol.upper()}_PROXY_USED").record("1")
|
|
|
|
@property
|
|
@abstractmethod
|
|
def protocol(self) -> RequestProtocol:
|
|
"""Protocol used in the proxy.
|
|
|
|
Each proxy needs to implement its own logic for setting up the protocol.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def _is_draining(self) -> bool:
|
|
"""Whether is proxy actor is in the draining status or not."""
|
|
return self._draining_start_time is not None
|
|
|
|
def is_drained(self):
|
|
"""Check whether the proxy actor is drained or not.
|
|
|
|
A proxy actor is drained if it has no ongoing requests
|
|
AND it has been draining for more than
|
|
`PROXY_MIN_DRAINING_PERIOD_S` seconds.
|
|
"""
|
|
if not self._is_draining():
|
|
return False
|
|
|
|
return (not self._ongoing_requests) and (
|
|
(time.time() - self._draining_start_time) > PROXY_MIN_DRAINING_PERIOD_S
|
|
)
|
|
|
|
def update_draining(self, draining: bool):
|
|
"""Update the draining status of the proxy.
|
|
|
|
This is called by the proxy state manager
|
|
to drain or un-drain the proxy actor.
|
|
"""
|
|
|
|
if draining and (not self._is_draining()):
|
|
logger.info(
|
|
f"Start to drain the proxy actor on node {self._node_id}.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
self._draining_start_time = time.time()
|
|
if (not draining) and self._is_draining():
|
|
logger.info(
|
|
f"Stop draining the proxy actor on node {self._node_id}.",
|
|
extra={"log_to_stderr": False},
|
|
)
|
|
self._draining_start_time = None
|
|
|
|
@abstractmethod
|
|
async def not_found_response(
|
|
self, proxy_request: ProxyRequest
|
|
) -> ResponseGenerator:
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def routes_response(
|
|
self, *, healthy: bool, message: str
|
|
) -> ResponseGenerator:
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def health_response(
|
|
self, *, healthy: bool, message: str
|
|
) -> ResponseGenerator:
|
|
raise NotImplementedError
|
|
|
|
def _should_emit_request_ingress_metrics(self) -> bool:
|
|
"""Whether this proxy emits the RequestIngressMetrics family itself.
|
|
|
|
In HAProxy mode (with HAProxy metrics enabled), this proxy runs as the
|
|
head-node fallback behind HAProxy. HAProxy already counts every request
|
|
it forwards -- including the ones it routes to this fallback -- from its
|
|
per-request log datagrams, so emitting here too would double-count HTTP
|
|
ingress metrics. gRPC is not proxied by HAProxy, so the fallback proxy
|
|
still emits gRPC.
|
|
"""
|
|
return not (
|
|
RAY_SERVE_ENABLE_HA_PROXY
|
|
and RAY_SERVE_HAPROXY_METRICS_ENABLED
|
|
and self.protocol == RequestProtocol.HTTP
|
|
)
|
|
|
|
def _ongoing_requests_start(self):
|
|
"""Ongoing requests start.
|
|
|
|
The current autoscale logic can downscale nodes with ongoing requests if the
|
|
node doesn't have replicas and has no primary copies of objects in the object
|
|
store. The counter and the dummy object reference will help to keep the node
|
|
alive while draining requests, so they are not dropped unintentionally.
|
|
"""
|
|
self._ongoing_requests += 1
|
|
if self._should_emit_request_ingress_metrics():
|
|
self._proxy_metrics.set_num_ongoing_requests(self._ongoing_requests)
|
|
|
|
def _ongoing_requests_end(self):
|
|
"""Ongoing requests end.
|
|
|
|
Decrement the ongoing request counter and drop the dummy object reference
|
|
signaling that the node can be downscaled safely.
|
|
"""
|
|
self._ongoing_requests -= 1
|
|
if self._should_emit_request_ingress_metrics():
|
|
self._proxy_metrics.set_num_ongoing_requests(self._ongoing_requests)
|
|
|
|
def _setup_proxy_tracing(
|
|
self,
|
|
request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: ProxyRequest,
|
|
additional_attributes: Optional[Dict[str, Any]] = None,
|
|
):
|
|
"""Set up tracing attributes for proxy requests.
|
|
|
|
Args:
|
|
request_id: The unique request ID.
|
|
handle: The deployment handle.
|
|
proxy_request: The proxy request object.
|
|
additional_attributes: Optional additional tracing attributes to set.
|
|
"""
|
|
trace_attributes = {
|
|
"request_id": request_id,
|
|
"deployment": handle.deployment_name,
|
|
"app": handle.app_name,
|
|
"request_type": proxy_request.request_type,
|
|
}
|
|
if additional_attributes:
|
|
trace_attributes.update(additional_attributes)
|
|
set_span_attributes(trace_attributes)
|
|
|
|
def _finalize_proxy_tracing(
|
|
self,
|
|
status: Optional[ResponseStatus],
|
|
exc: Optional[BaseException],
|
|
):
|
|
"""Finalize tracing for proxy requests.
|
|
|
|
Set exception and trace status. This is a common helper used by
|
|
both HTTP and gRPC proxies. Protocol-specific attributes should be
|
|
set separately (e.g., via set_http_span_attributes or set_rpc_span_attributes).
|
|
|
|
Args:
|
|
status: The response status, if available.
|
|
exc: The exception that occurred, if any.
|
|
"""
|
|
if exc:
|
|
set_span_exception(exc, escaped=True)
|
|
if status is not None:
|
|
set_trace_status(status.is_error, str(exc))
|
|
elif status is not None:
|
|
set_trace_status(status.is_error)
|
|
|
|
def _get_health_or_routes_reponse(
|
|
self, proxy_request: ProxyRequest
|
|
) -> ResponseHandlerInfo:
|
|
"""Get the response handler for system health and route endpoints.
|
|
|
|
If the proxy is draining or has not yet received a route table update from the
|
|
controller, both will return a non-OK status.
|
|
"""
|
|
router_ready_for_traffic, router_msg = self.proxy_router.ready_for_traffic(
|
|
self._is_head
|
|
)
|
|
if self._is_draining():
|
|
healthy = False
|
|
message = DRAINING_MESSAGE
|
|
elif not router_ready_for_traffic:
|
|
healthy = False
|
|
message = router_msg
|
|
else:
|
|
healthy = True
|
|
message = HEALTHY_MESSAGE
|
|
|
|
if proxy_request.is_health_request:
|
|
response_generator = self.health_response(healthy=healthy, message=message)
|
|
else:
|
|
assert proxy_request.is_route_request
|
|
response_generator = self.routes_response(healthy=healthy, message=message)
|
|
|
|
return ResponseHandlerInfo(
|
|
response_generator=response_generator,
|
|
metadata=HandlerMetadata(
|
|
route=proxy_request.route_path,
|
|
),
|
|
should_record_access_log=False,
|
|
should_increment_ongoing_requests=False,
|
|
)
|
|
|
|
def _get_response_handler_info(
|
|
self, proxy_request: ProxyRequest
|
|
) -> ResponseHandlerInfo:
|
|
if proxy_request.is_health_request or proxy_request.is_route_request:
|
|
return self._get_health_or_routes_reponse(proxy_request)
|
|
|
|
proxy_request.populate_tracing_context()
|
|
matched_route = None
|
|
if self.protocol == RequestProtocol.HTTP:
|
|
matched_route = self.proxy_router.match_route(proxy_request.route_path)
|
|
elif self.protocol == RequestProtocol.GRPC:
|
|
matched_route = self.proxy_router.get_handle_for_endpoint(
|
|
proxy_request.route_path
|
|
)
|
|
|
|
if matched_route is None:
|
|
return ResponseHandlerInfo(
|
|
response_generator=self.not_found_response(proxy_request),
|
|
metadata=HandlerMetadata(
|
|
# Don't include the invalid route prefix because it can blow up our
|
|
# metrics' cardinality.
|
|
# See: https://github.com/ray-project/ray/issues/47999
|
|
route="",
|
|
),
|
|
should_record_access_log=True,
|
|
should_increment_ongoing_requests=False,
|
|
)
|
|
else:
|
|
route_prefix, handle, app_is_cross_language = matched_route
|
|
# Modify the path and root path so that reverse lookups and redirection
|
|
# work as expected. We do this here instead of in replicas so it can be
|
|
# changed without restarting the replicas.
|
|
route_path = proxy_request.route_path
|
|
if route_prefix != "/" and self.protocol == RequestProtocol.HTTP:
|
|
assert not route_prefix.endswith("/")
|
|
proxy_request.set_root_path(proxy_request.root_path + route_prefix)
|
|
# NOTE(edoakes): starlette<0.33.0 expected the ASGI 'root_prefix'
|
|
# to be stripped from the 'path', which wasn't technically following
|
|
# the standard. See https://github.com/encode/starlette/pull/2352.
|
|
if version.parse(starlette.__version__) < version.parse("0.33.0"):
|
|
proxy_request.set_path(route_path.replace(route_prefix, "", 1))
|
|
|
|
# NOTE(abrar): we try to match to a specific route pattern (e.g., /api/{user_id})
|
|
# for logs & metrics when available. If no pattern matches, we fall back to the
|
|
# route_prefix to avoid high cardinality.
|
|
# See: https://github.com/ray-project/ray/issues/47999 and
|
|
# https://github.com/ray-project/ray/issues/52212
|
|
if self.protocol == RequestProtocol.HTTP:
|
|
logs_and_metrics_route = self.proxy_router.match_route_pattern(
|
|
route_prefix, proxy_request.scope
|
|
)
|
|
else:
|
|
logs_and_metrics_route = handle.deployment_id.app_name
|
|
internal_request_id = generate_request_id()
|
|
handle, request_id = self.setup_request_context_and_handle(
|
|
app_name=handle.deployment_id.app_name,
|
|
handle=handle,
|
|
route=logs_and_metrics_route,
|
|
proxy_request=proxy_request,
|
|
internal_request_id=internal_request_id,
|
|
)
|
|
|
|
response_generator = self.send_request_to_replica(
|
|
request_id=request_id,
|
|
internal_request_id=internal_request_id,
|
|
handle=handle,
|
|
proxy_request=proxy_request,
|
|
app_is_cross_language=app_is_cross_language,
|
|
)
|
|
|
|
return ResponseHandlerInfo(
|
|
response_generator=response_generator,
|
|
metadata=HandlerMetadata(
|
|
application_name=handle.deployment_id.app_name,
|
|
deployment_name=handle.deployment_id.name,
|
|
route=logs_and_metrics_route,
|
|
),
|
|
should_record_access_log=True,
|
|
should_increment_ongoing_requests=True,
|
|
)
|
|
|
|
async def proxy_request(self, proxy_request: ProxyRequest) -> ResponseGenerator:
|
|
"""Wrapper for proxy request.
|
|
|
|
This method is served as common entry point by the proxy. It handles the
|
|
routing, including routes and health checks, ongoing request counter,
|
|
and metrics.
|
|
"""
|
|
assert proxy_request.request_type in {"http", "websocket", "grpc"}
|
|
|
|
response_handler_info = self._get_response_handler_info(proxy_request)
|
|
|
|
start_time = time.time()
|
|
if response_handler_info.should_increment_ongoing_requests:
|
|
self._ongoing_requests_start()
|
|
|
|
try:
|
|
# The final message yielded must always be the `ResponseStatus`.
|
|
status: Optional[ResponseStatus] = None
|
|
async for message in response_handler_info.response_generator:
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
|
|
yield message
|
|
|
|
assert status is not None and isinstance(status, ResponseStatus)
|
|
finally:
|
|
# If anything during the request failed, we still want to ensure the ongoing
|
|
# request counter is decremented.
|
|
if response_handler_info.should_increment_ongoing_requests:
|
|
self._ongoing_requests_end()
|
|
|
|
latency_ms = (time.time() - start_time) * 1000.0
|
|
status_code = (
|
|
status.code.name
|
|
if self.protocol == RequestProtocol.GRPC
|
|
else str(status.code)
|
|
)
|
|
if response_handler_info.should_record_access_log:
|
|
request_context = ray.serve.context._get_serve_request_context()
|
|
self._access_log_context[SERVE_LOG_ROUTE] = request_context.route
|
|
self._access_log_context[SERVE_LOG_REQUEST_ID] = request_context.request_id
|
|
logger.info(
|
|
access_log_msg(
|
|
method=proxy_request.method,
|
|
route=request_context.route,
|
|
status=status_code,
|
|
latency_ms=latency_ms,
|
|
client=format_client_address(proxy_request.client),
|
|
),
|
|
extra=self._access_log_context,
|
|
)
|
|
|
|
if self._should_emit_request_ingress_metrics():
|
|
self._proxy_metrics.record_request(
|
|
route=response_handler_info.metadata.route,
|
|
method=proxy_request.method,
|
|
application=response_handler_info.metadata.application_name,
|
|
status_code=status_code,
|
|
latency_ms=latency_ms,
|
|
is_error=status.is_error,
|
|
deployment_name=response_handler_info.metadata.deployment_name,
|
|
)
|
|
|
|
@abstractmethod
|
|
def setup_request_context_and_handle(
|
|
self,
|
|
app_name: str,
|
|
handle: DeploymentHandle,
|
|
route: str,
|
|
proxy_request: ProxyRequest,
|
|
internal_request_id: str,
|
|
) -> Tuple[DeploymentHandle, str]:
|
|
"""Setup the request context and handle for the request.
|
|
|
|
Each proxy needs to implement its own logic for setting up the request context
|
|
and handle.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
@abstractmethod
|
|
async def send_request_to_replica(
|
|
self,
|
|
request_id: str,
|
|
internal_request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: ProxyRequest,
|
|
app_is_cross_language: bool = False,
|
|
) -> ResponseGenerator:
|
|
"""Send the request to the replica and handle streaming response.
|
|
|
|
Each proxy needs to implement its own logic for sending the request and
|
|
handling the streaming response.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class gRPCProxy(GenericProxy):
|
|
"""This class is meant to be instantiated and run by an gRPC server.
|
|
|
|
This is the servicer class for the gRPC server. It implements `unary_unary`
|
|
as the entry point for unary gRPC request and `unary_stream` as the entry
|
|
point for streaming gRPC request. It also implements `stream_unary` and
|
|
`stream_stream` for client streaming and bidirectional streaming RPCs.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
node_id: NodeId,
|
|
node_ip_address: str,
|
|
is_head: bool,
|
|
proxy_router: "ProxyRouter",
|
|
request_timeout_s: Optional[float] = None,
|
|
access_log_context: Dict[str, Any] = None,
|
|
):
|
|
super().__init__(
|
|
node_id,
|
|
node_ip_address,
|
|
is_head,
|
|
proxy_router,
|
|
request_timeout_s=request_timeout_s,
|
|
access_log_context=access_log_context,
|
|
)
|
|
# Dictionary to store active streaming sessions for client/bidi streaming.
|
|
# Maps session_id -> (request_iterator, cancel_event)
|
|
self._streaming_sessions: Dict[str, Tuple[Any, asyncio.Event]] = {}
|
|
|
|
@property
|
|
def protocol(self) -> RequestProtocol:
|
|
return RequestProtocol.GRPC
|
|
|
|
async def not_found_response(
|
|
self, proxy_request: ProxyRequest
|
|
) -> ResponseGenerator:
|
|
if not proxy_request.app_name:
|
|
application_message = "Application metadata not set."
|
|
else:
|
|
application_message = f"Application '{proxy_request.app_name}' not found."
|
|
not_found_message = (
|
|
f"{application_message} Ping "
|
|
"/ray.serve.RayServeAPIService/ListApplications for available applications."
|
|
)
|
|
|
|
yield ResponseStatus(
|
|
code=grpc.StatusCode.NOT_FOUND,
|
|
message=not_found_message,
|
|
is_error=True,
|
|
)
|
|
|
|
async def routes_response(
|
|
self, *, healthy: bool, message: str
|
|
) -> ResponseGenerator:
|
|
yield ListApplicationsResponse(
|
|
application_names=[
|
|
endpoint.app_name for endpoint in self.proxy_router.endpoints
|
|
],
|
|
).SerializeToString()
|
|
|
|
yield ResponseStatus(
|
|
code=grpc.StatusCode.OK if healthy else grpc.StatusCode.UNAVAILABLE,
|
|
message=message,
|
|
is_error=not healthy,
|
|
)
|
|
|
|
async def health_response(self, *, healthy: bool, message) -> ResponseGenerator:
|
|
yield HealthzResponse(message=message).SerializeToString()
|
|
yield ResponseStatus(
|
|
code=grpc.StatusCode.OK if healthy else grpc.StatusCode.UNAVAILABLE,
|
|
message=message,
|
|
is_error=not healthy,
|
|
)
|
|
|
|
def service_handler_factory(
|
|
self, service_method: str, streaming_type: gRPCStreamingType
|
|
) -> Callable:
|
|
async def unary_unary(
|
|
request_proto: Any, context: grpc._cython.cygrpc._ServicerContext
|
|
) -> bytes:
|
|
"""Entry point of the gRPC proxy unary request.
|
|
|
|
This method is called by the gRPC server when a unary request is received.
|
|
It wraps the request in a ProxyRequest object and calls proxy_request.
|
|
The return value is serialized user defined protobuf bytes.
|
|
"""
|
|
proxy_request = gRPCProxyRequest(
|
|
request_proto=request_proto,
|
|
context=context,
|
|
service_method=service_method,
|
|
stream=False,
|
|
streaming_type=gRPCStreamingType.UNARY_UNARY,
|
|
)
|
|
|
|
status = None
|
|
response = None
|
|
async for message in self.proxy_request(proxy_request=proxy_request):
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
else:
|
|
response = message
|
|
|
|
set_grpc_code_and_details(context, status)
|
|
|
|
# When only ResponseStatus is yielded (not-found, errors), response stays
|
|
# None. Returning None to gRPC causes serialization/type errors; return
|
|
# empty bytes so the error status is sent cleanly.
|
|
return response if response is not None else b""
|
|
|
|
async def unary_stream(
|
|
request_proto: Any, context: grpc._cython.cygrpc._ServicerContext
|
|
) -> Generator[bytes, None, None]:
|
|
"""Entry point of the gRPC proxy streaming request.
|
|
|
|
This method is called by the gRPC server when a streaming request is
|
|
received. It wraps the request in a ProxyRequest object and calls
|
|
proxy_request. The return value is a generator of serialized user defined
|
|
protobuf bytes.
|
|
"""
|
|
proxy_request = gRPCProxyRequest(
|
|
request_proto=request_proto,
|
|
context=context,
|
|
service_method=service_method,
|
|
stream=True,
|
|
streaming_type=gRPCStreamingType.UNARY_STREAM,
|
|
)
|
|
|
|
status = None
|
|
async for message in self.proxy_request(proxy_request=proxy_request):
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
else:
|
|
yield message
|
|
|
|
set_grpc_code_and_details(context, status)
|
|
|
|
async def stream_unary(
|
|
request_iterator: Any, context: grpc._cython.cygrpc._ServicerContext
|
|
) -> bytes:
|
|
"""Entry point of the gRPC proxy client streaming request.
|
|
|
|
This method is called by the gRPC server when a client streaming request
|
|
is received. It wraps the request iterator and calls proxy_request.
|
|
The return value is serialized user defined protobuf bytes.
|
|
"""
|
|
# Create async iterator wrapper for the request stream
|
|
async def async_request_iterator():
|
|
async for request in request_iterator:
|
|
yield request
|
|
|
|
proxy_request = gRPCProxyRequest(
|
|
request_proto=None,
|
|
context=context,
|
|
service_method=service_method,
|
|
stream=False,
|
|
streaming_type=gRPCStreamingType.STREAM_UNARY,
|
|
request_iterator=async_request_iterator(),
|
|
)
|
|
|
|
status = None
|
|
response = None
|
|
async for message in self.proxy_request(proxy_request=proxy_request):
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
else:
|
|
response = message
|
|
|
|
set_grpc_code_and_details(context, status)
|
|
|
|
# When only ResponseStatus is yielded (not-found, errors), response stays
|
|
# None. Returning None to gRPC causes serialization/type errors; return
|
|
# empty bytes so the error status is sent cleanly.
|
|
return response if response is not None else b""
|
|
|
|
async def stream_stream(
|
|
request_iterator: Any, context: grpc._cython.cygrpc._ServicerContext
|
|
) -> Generator[bytes, None, None]:
|
|
"""Entry point of the gRPC proxy bidirectional streaming request.
|
|
|
|
This method is called by the gRPC server when a bidirectional streaming
|
|
request is received. It wraps the request iterator and calls proxy_request.
|
|
The return value is a generator of serialized user defined protobuf bytes.
|
|
"""
|
|
# Create async iterator wrapper for the request stream
|
|
async def async_request_iterator():
|
|
async for request in request_iterator:
|
|
yield request
|
|
|
|
proxy_request = gRPCProxyRequest(
|
|
request_proto=None,
|
|
context=context,
|
|
service_method=service_method,
|
|
stream=True,
|
|
streaming_type=gRPCStreamingType.STREAM_STREAM,
|
|
request_iterator=async_request_iterator(),
|
|
)
|
|
|
|
status = None
|
|
async for message in self.proxy_request(proxy_request=proxy_request):
|
|
if isinstance(message, ResponseStatus):
|
|
status = message
|
|
else:
|
|
yield message
|
|
|
|
set_grpc_code_and_details(context, status)
|
|
|
|
handler_map = {
|
|
gRPCStreamingType.UNARY_UNARY: unary_unary,
|
|
gRPCStreamingType.UNARY_STREAM: unary_stream,
|
|
gRPCStreamingType.STREAM_UNARY: stream_unary,
|
|
gRPCStreamingType.STREAM_STREAM: stream_stream,
|
|
}
|
|
return handler_map[streaming_type]
|
|
|
|
def setup_request_context_and_handle(
|
|
self,
|
|
app_name: str,
|
|
handle: DeploymentHandle,
|
|
route: str,
|
|
proxy_request: ProxyRequest,
|
|
internal_request_id: str,
|
|
) -> Tuple[DeploymentHandle, str]:
|
|
"""Setup request context and handle for the request.
|
|
|
|
Unpack gRPC request metadata and extract info to set up request context and
|
|
handle.
|
|
"""
|
|
multiplexed_model_id = proxy_request.multiplexed_model_id
|
|
session_id = proxy_request.session_id
|
|
request_id = proxy_request.request_id
|
|
if not request_id:
|
|
request_id = generate_request_id()
|
|
proxy_request.request_id = request_id
|
|
|
|
handle = handle.options(
|
|
stream=proxy_request.stream,
|
|
multiplexed_model_id=multiplexed_model_id,
|
|
session_id=session_id,
|
|
method_name=proxy_request.method_name,
|
|
)
|
|
|
|
request_context_info = {
|
|
"route": route,
|
|
"request_id": request_id,
|
|
"_internal_request_id": internal_request_id,
|
|
"app_name": app_name,
|
|
"multiplexed_model_id": multiplexed_model_id,
|
|
"session_id": session_id,
|
|
"grpc_context": proxy_request.ray_serve_grpc_context,
|
|
"_client": proxy_request.client,
|
|
}
|
|
ray.serve.context._serve_request_context.set(
|
|
ray.serve.context._RequestContext(**request_context_info)
|
|
)
|
|
proxy_request.send_request_id(request_id=request_id)
|
|
return handle, request_id
|
|
|
|
async def receive_grpc_messages(
|
|
self, session_id: str
|
|
) -> Tuple[bool, Optional[Any], bool]:
|
|
"""Receive the next message from a gRPC streaming session.
|
|
|
|
This method is called by replicas to receive messages from
|
|
client/bidirectional streaming sessions.
|
|
|
|
Args:
|
|
session_id: The session ID of the streaming session.
|
|
|
|
Returns:
|
|
A tuple of (has_more, message, is_cancelled).
|
|
- has_more: True if there are more messages, False if stream is done.
|
|
- message: The protobuf message object, or None if stream is done.
|
|
- is_cancelled: True if the stream was cancelled by client or error.
|
|
|
|
Note:
|
|
If the session ID is not found (e.g., after cleanup due to timeout,
|
|
error, or completion), returns (False, None, True) for graceful
|
|
termination instead of raising an exception.
|
|
"""
|
|
if session_id not in self._streaming_sessions:
|
|
# Session was already cleaned up - return graceful termination
|
|
# This is consistent with the behavior when cancel_event.is_set()
|
|
return (False, None, True)
|
|
|
|
request_iterator, cancel_event = self._streaming_sessions[session_id]
|
|
|
|
if cancel_event.is_set():
|
|
return (False, None, True)
|
|
|
|
try:
|
|
message = await request_iterator.__anext__()
|
|
# Return message directly - let Ray handle serialization
|
|
return (True, message, False)
|
|
except StopAsyncIteration:
|
|
return (False, None, False)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error receiving gRPC message for session {session_id}: {e}"
|
|
)
|
|
cancel_event.set()
|
|
return (False, None, True)
|
|
|
|
def _cleanup_streaming_session(self, session_id: str):
|
|
"""Clean up a streaming session."""
|
|
session = self._streaming_sessions.pop(session_id, None)
|
|
if session is not None:
|
|
_, cancel_event = session
|
|
cancel_event.set()
|
|
|
|
def _setup_grpc_tracing(
|
|
self,
|
|
request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: "gRPCProxyRequest",
|
|
):
|
|
"""Set up tracing for gRPC requests.
|
|
|
|
This helper function sets up span attributes and span name for gRPC requests,
|
|
used by both standard and streaming request paths.
|
|
"""
|
|
self._setup_proxy_tracing(
|
|
request_id=request_id,
|
|
handle=handle,
|
|
proxy_request=proxy_request,
|
|
)
|
|
set_span_name(
|
|
f"proxy_{proxy_request.request_type}_request {handle.deployment_name} {proxy_request.method}"
|
|
)
|
|
|
|
def _finalize_grpc_tracing(
|
|
self,
|
|
proxy_request: "gRPCProxyRequest",
|
|
status: Optional[ResponseStatus],
|
|
exc: Optional[BaseException],
|
|
):
|
|
"""Finalize tracing for gRPC requests.
|
|
|
|
This helper function sets RPC span attributes, exception tracking, and trace status
|
|
in the finally block, used by both standard and streaming request paths.
|
|
"""
|
|
if status is not None:
|
|
set_rpc_span_attributes(
|
|
system=proxy_request.request_type,
|
|
method=proxy_request.method,
|
|
status_code=status.code.name
|
|
if isinstance(status.code, grpc.StatusCode)
|
|
else grpc.StatusCode.UNKNOWN.name,
|
|
)
|
|
self._finalize_proxy_tracing(status=status, exc=exc)
|
|
|
|
@tracing_decorator_factory(
|
|
trace_name="proxy_grpc_request",
|
|
)
|
|
async def send_request_to_replica(
|
|
self,
|
|
request_id: str,
|
|
internal_request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: ProxyRequest,
|
|
app_is_cross_language: bool = False,
|
|
) -> ResponseGenerator:
|
|
# handle the streaming input that exists in client-streaming and bidi-streaming RPC types.
|
|
if (
|
|
isinstance(proxy_request, gRPCProxyRequest)
|
|
and proxy_request.has_input_stream
|
|
):
|
|
async for message in self._send_streaming_request_to_replica(
|
|
request_id=request_id,
|
|
internal_request_id=internal_request_id,
|
|
handle=handle,
|
|
proxy_request=proxy_request,
|
|
):
|
|
yield message
|
|
return
|
|
|
|
# Standard server-unary/server-streaming path
|
|
self._setup_grpc_tracing(request_id, handle, proxy_request)
|
|
|
|
response_generator = ProxyResponseGenerator(
|
|
handle.remote(proxy_request.serialized_replica_arg()),
|
|
timeout_s=self.request_timeout_s,
|
|
)
|
|
status = None
|
|
exc = None
|
|
try:
|
|
async for context, result in response_generator:
|
|
context._set_on_grpc_context(proxy_request.context)
|
|
yield result
|
|
|
|
status = ResponseStatus(code=grpc.StatusCode.OK)
|
|
except BaseException as e:
|
|
status = get_grpc_response_status(e, self.request_timeout_s, request_id)
|
|
exc = e
|
|
finally:
|
|
self._finalize_grpc_tracing(proxy_request, status, exc)
|
|
|
|
# The status code should always be set.
|
|
assert status is not None
|
|
yield status
|
|
|
|
async def _send_streaming_request_to_replica(
|
|
self,
|
|
request_id: str,
|
|
internal_request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: "gRPCProxyRequest",
|
|
) -> ResponseGenerator:
|
|
"""Handle sending a streaming request (client/bidi) to replica.
|
|
|
|
For client streaming (stream_unary), we create a streaming session that
|
|
the replica can use to receive messages from the client.
|
|
|
|
For bidirectional streaming (stream_stream), we do the same but the
|
|
response is also a stream.
|
|
"""
|
|
# Set up tracing attributes for streaming
|
|
self._setup_grpc_tracing(request_id, handle, proxy_request)
|
|
|
|
# Create a streaming session
|
|
session_id = internal_request_id
|
|
cancel_event = asyncio.Event()
|
|
self._streaming_sessions[session_id] = (
|
|
proxy_request.request_iterator,
|
|
cancel_event,
|
|
)
|
|
|
|
status = None
|
|
exc = None
|
|
try:
|
|
# Get the proxy actor name for callback
|
|
proxy_actor_name = ray.get_runtime_context().get_actor_name()
|
|
|
|
# Create the streaming request
|
|
streaming_request = gRPCStreamingRequest(
|
|
session_id=session_id,
|
|
proxy_actor_name=proxy_actor_name,
|
|
)
|
|
|
|
# Serialize the streaming request
|
|
serialized_arg = pickle.dumps(streaming_request)
|
|
|
|
response_generator = ProxyResponseGenerator(
|
|
handle.remote(serialized_arg),
|
|
timeout_s=self.request_timeout_s,
|
|
)
|
|
|
|
try:
|
|
async for context, result in response_generator:
|
|
context._set_on_grpc_context(proxy_request.context)
|
|
yield result
|
|
|
|
status = ResponseStatus(code=grpc.StatusCode.OK)
|
|
except BaseException as e:
|
|
status = get_grpc_response_status(e, self.request_timeout_s, request_id)
|
|
exc = e
|
|
# Yield the error status and return immediately to avoid falling through
|
|
# to the outer except block, which would produce a duplicate ResponseStatus.
|
|
yield status
|
|
return
|
|
|
|
# The status code should always be set.
|
|
assert status is not None
|
|
yield status
|
|
except Exception as e:
|
|
# Handle exceptions that occur before response_generator is created
|
|
# (e.g., in get_actor_name, pickle.dumps, ProxyResponseGenerator)
|
|
status = get_grpc_response_status(e, self.request_timeout_s, request_id)
|
|
exc = e
|
|
yield status
|
|
finally:
|
|
# Clean up the streaming session first to ensure no resource leaks
|
|
self._cleanup_streaming_session(session_id)
|
|
|
|
# Finalize tracing for streaming requests (best-effort)
|
|
try:
|
|
self._finalize_grpc_tracing(proxy_request, status, exc)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to finalize tracing for session {session_id}: {e}"
|
|
)
|
|
|
|
|
|
class HTTPProxy(GenericProxy):
|
|
"""This class is meant to be instantiated and run by an ASGI HTTP server."""
|
|
|
|
def __init__(
|
|
self,
|
|
node_id: NodeId,
|
|
node_ip_address: str,
|
|
is_head: bool,
|
|
proxy_router: ProxyRouter,
|
|
self_actor_name: str,
|
|
request_timeout_s: Optional[float] = None,
|
|
access_log_context: Dict[str, Any] = None,
|
|
):
|
|
super().__init__(
|
|
node_id,
|
|
node_ip_address,
|
|
is_head,
|
|
proxy_router,
|
|
request_timeout_s=request_timeout_s,
|
|
access_log_context=access_log_context,
|
|
)
|
|
self.self_actor_name = self_actor_name
|
|
self.asgi_receive_queues: Dict[str, MessageQueue] = dict()
|
|
|
|
@property
|
|
def protocol(self) -> RequestProtocol:
|
|
return RequestProtocol.HTTP
|
|
|
|
async def not_found_response(
|
|
self, proxy_request: ProxyRequest
|
|
) -> ResponseGenerator:
|
|
status_code = 404
|
|
for message in convert_object_to_asgi_messages(
|
|
f"Path '{proxy_request.path}' not found. "
|
|
"Ping http://.../-/routes for available routes.",
|
|
status_code=status_code,
|
|
):
|
|
yield message
|
|
|
|
yield ResponseStatus(code=status_code, is_error=True)
|
|
|
|
async def routes_response(
|
|
self, *, healthy: bool, message: str
|
|
) -> ResponseGenerator:
|
|
status_code = 200 if healthy else 503
|
|
if healthy:
|
|
response = dict()
|
|
for endpoint, info in self.proxy_router.endpoints.items():
|
|
# For 2.x deployments, return {route -> app name}
|
|
if endpoint.app_name:
|
|
response[info.route] = endpoint.app_name
|
|
# Keep compatibility with 1.x deployments.
|
|
else:
|
|
response[info.route] = endpoint.name
|
|
else:
|
|
response = message
|
|
|
|
for asgi_message in convert_object_to_asgi_messages(
|
|
response,
|
|
status_code=status_code,
|
|
):
|
|
yield asgi_message
|
|
|
|
yield ResponseStatus(
|
|
code=status_code,
|
|
message=message,
|
|
is_error=not healthy,
|
|
)
|
|
|
|
async def health_response(
|
|
self, *, healthy: bool, message: str = ""
|
|
) -> ResponseGenerator:
|
|
status_code = 200 if healthy else 503
|
|
for asgi_message in convert_object_to_asgi_messages(
|
|
message,
|
|
status_code=status_code,
|
|
):
|
|
yield asgi_message
|
|
|
|
yield ResponseStatus(
|
|
code=status_code,
|
|
is_error=not healthy,
|
|
message=message,
|
|
)
|
|
|
|
async def receive_asgi_messages(
|
|
self, request_metadata: RequestMetadata
|
|
) -> ResponseGenerator:
|
|
queue = self.asgi_receive_queues.get(request_metadata.internal_request_id, None)
|
|
if queue is None:
|
|
raise KeyError(f"Request ID {request_metadata.request_id} not found.")
|
|
|
|
await queue.wait_for_message()
|
|
return queue.get_messages_nowait()
|
|
|
|
async def __call__(self, scope, receive, send):
|
|
"""Implements the ASGI protocol.
|
|
|
|
See details at:
|
|
https://asgi.readthedocs.io/en/latest/specs/index.html.
|
|
"""
|
|
proxy_request = ASGIProxyRequest(scope=scope, receive=receive, send=send)
|
|
async for message in self.proxy_request(proxy_request):
|
|
if not isinstance(message, ResponseStatus):
|
|
await send(message)
|
|
|
|
async def proxy_asgi_receive(
|
|
self, receive: Receive, queue: MessageQueue
|
|
) -> Optional[int]:
|
|
"""Proxies the `receive` interface, placing its messages into the queue.
|
|
|
|
Once a disconnect message is received, the call exits and `receive` is no longer
|
|
called.
|
|
|
|
For HTTP messages, `None` is always returned.
|
|
For websocket messages, the disconnect code is returned if a disconnect code is
|
|
received.
|
|
"""
|
|
try:
|
|
while True:
|
|
msg = await receive()
|
|
await queue(msg)
|
|
|
|
if msg["type"] == "http.disconnect":
|
|
return None
|
|
|
|
if msg["type"] == "websocket.disconnect":
|
|
return msg["code"]
|
|
finally:
|
|
# Close the queue so any subsequent calls to fetch messages return
|
|
# immediately: https://github.com/ray-project/ray/issues/38368.
|
|
queue.close()
|
|
|
|
def setup_request_context_and_handle(
|
|
self,
|
|
app_name: str,
|
|
handle: DeploymentHandle,
|
|
route: str,
|
|
proxy_request: ProxyRequest,
|
|
internal_request_id: str,
|
|
) -> Tuple[DeploymentHandle, str]:
|
|
"""Setup request context and handle for the request.
|
|
|
|
Unpack HTTP request headers and extract info to set up request context and
|
|
handle.
|
|
"""
|
|
request_context_info = {
|
|
"route": route,
|
|
"app_name": app_name,
|
|
"_internal_request_id": internal_request_id,
|
|
"is_http_request": True,
|
|
"_client": format_client_address(proxy_request.client),
|
|
}
|
|
for key, value in proxy_request.headers:
|
|
# Normalize the header key: lowercase and replace hyphens with
|
|
# underscores so that both "serve_multiplexed_model_id" and
|
|
# "serve-multiplexed-model-id" (the form produced by proxies such
|
|
# as nginx / AWS API Gateway that convert underscores to hyphens)
|
|
# are recognised correctly.
|
|
normalized_key = key.decode().lower().replace("-", "_")
|
|
if normalized_key == SERVE_MULTIPLEXED_MODEL_ID:
|
|
multiplexed_model_id = value.decode()
|
|
handle = handle.options(multiplexed_model_id=multiplexed_model_id)
|
|
request_context_info["multiplexed_model_id"] = multiplexed_model_id
|
|
elif _matches_session_id_header(key.decode()):
|
|
session_id = value.decode()
|
|
handle = handle.options(session_id=session_id)
|
|
request_context_info["session_id"] = session_id
|
|
if key.decode() == SERVE_HTTP_REQUEST_ID_HEADER:
|
|
request_context_info["request_id"] = value.decode()
|
|
ray.serve.context._serve_request_context.set(
|
|
ray.serve.context._RequestContext(**request_context_info)
|
|
)
|
|
return handle, request_context_info["request_id"]
|
|
|
|
def _setup_http_tracing(
|
|
self,
|
|
request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: ProxyRequest,
|
|
):
|
|
"""Set up tracing for HTTP requests.
|
|
|
|
This helper function sets up span attributes and span name for HTTP requests.
|
|
"""
|
|
self._setup_proxy_tracing(
|
|
request_id=request_id,
|
|
handle=handle,
|
|
proxy_request=proxy_request,
|
|
additional_attributes={
|
|
"request_method": proxy_request.method,
|
|
"request_route_path": proxy_request.route_path,
|
|
},
|
|
)
|
|
set_span_name(
|
|
f"proxy_{proxy_request.request_type}_request {handle.deployment_name} {proxy_request.method} {proxy_request.route_path}"
|
|
)
|
|
|
|
def _finalize_http_tracing(
|
|
self,
|
|
proxy_request: ProxyRequest,
|
|
status_code: str,
|
|
is_error: bool,
|
|
exc: Optional[BaseException],
|
|
):
|
|
"""Finalize tracing for HTTP requests.
|
|
|
|
This helper function sets HTTP span attributes, exception tracking, and trace status
|
|
in finally block.
|
|
"""
|
|
set_http_span_attributes(
|
|
method=proxy_request.method,
|
|
status_code=int(status_code),
|
|
route=proxy_request.route_path,
|
|
)
|
|
self._finalize_proxy_tracing(
|
|
status=ResponseStatus(code=status_code, is_error=is_error),
|
|
exc=exc,
|
|
)
|
|
|
|
async def _format_handle_arg_for_java(
|
|
self,
|
|
proxy_request: ProxyRequest,
|
|
) -> bytes:
|
|
"""Convert an HTTP request to the Java-accepted format (single byte string)."""
|
|
query_string = proxy_request.scope.get("query_string")
|
|
http_body_bytes = await receive_http_body(
|
|
proxy_request.scope, proxy_request.receive, proxy_request.send
|
|
)
|
|
if query_string:
|
|
arg = query_string.decode().split("=", 1)[1]
|
|
else:
|
|
arg = http_body_bytes.decode()
|
|
|
|
return arg
|
|
|
|
@tracing_decorator_factory(
|
|
trace_name="proxy_http_request",
|
|
)
|
|
async def send_request_to_replica(
|
|
self,
|
|
request_id: str,
|
|
internal_request_id: str,
|
|
handle: DeploymentHandle,
|
|
proxy_request: ProxyRequest,
|
|
app_is_cross_language: bool = False,
|
|
) -> ResponseGenerator:
|
|
"""Send the request to the replica and yield its response messages.
|
|
|
|
The yielded values will be ASGI messages until the final one, which will be
|
|
the status code.
|
|
"""
|
|
if is_span_recording():
|
|
self._setup_http_tracing(request_id, handle, proxy_request)
|
|
|
|
if app_is_cross_language:
|
|
handle_arg_bytes = await self._format_handle_arg_for_java(proxy_request)
|
|
# Response is returned as raw bytes, convert it to ASGI messages.
|
|
result_callback = convert_object_to_asgi_messages
|
|
else:
|
|
handle_arg_bytes = proxy_request.serialized_replica_arg(
|
|
proxy_actor_name=self.self_actor_name,
|
|
)
|
|
# Messages are returned as pickled dictionaries.
|
|
result_callback = pickle.loads
|
|
|
|
# Proxy the receive interface by placing the received messages on a queue.
|
|
# The downstream replica must call back into `receive_asgi_messages` on this
|
|
# actor to receive the messages.
|
|
receive_queue = MessageQueue()
|
|
self.asgi_receive_queues[internal_request_id] = receive_queue
|
|
proxy_asgi_receive_task = get_or_create_event_loop().create_task(
|
|
self.proxy_asgi_receive(proxy_request.receive, receive_queue)
|
|
)
|
|
|
|
# Per-request headers override the global HTTPOptions timeout and disconnect
|
|
# policy, enabling HAProxy (or other callers) to pass per-request hints.
|
|
request_headers = dict(proxy_request.headers)
|
|
request_timeout_s = parse_request_timeout_header(
|
|
request_headers, self.request_timeout_s
|
|
)
|
|
request_disconnect_disabled = parse_disconnect_disabled_header(request_headers)
|
|
|
|
response_generator = ProxyResponseGenerator(
|
|
handle.remote(handle_arg_bytes),
|
|
timeout_s=request_timeout_s,
|
|
disconnected_task=(
|
|
None if request_disconnect_disabled else proxy_asgi_receive_task
|
|
),
|
|
result_callback=result_callback,
|
|
)
|
|
|
|
status: Optional[ResponseStatus] = None
|
|
response_started = False
|
|
expecting_trailers = False
|
|
exc = None
|
|
status_code = None
|
|
try:
|
|
async for asgi_message_batch in response_generator:
|
|
# See the ASGI spec for message details:
|
|
# https://asgi.readthedocs.io/en/latest/specs/www.html.
|
|
for asgi_message in asgi_message_batch:
|
|
if asgi_message["type"] == "http.response.start":
|
|
# HTTP responses begin with exactly one
|
|
# "http.response.start" message containing the "status"
|
|
# field. Other response types (e.g., WebSockets) may not.
|
|
status_code = str(asgi_message["status"])
|
|
status = ResponseStatus(
|
|
code=status_code,
|
|
is_error=status_code.startswith(("4", "5")),
|
|
)
|
|
expecting_trailers = asgi_message.get("trailers", False)
|
|
elif asgi_message["type"] == "websocket.accept":
|
|
# Websocket code explicitly handles client disconnects,
|
|
# so let the ASGI disconnect message propagate instead of
|
|
# cancelling the handler.
|
|
response_generator.stop_checking_for_disconnect()
|
|
elif (
|
|
asgi_message["type"] == "http.response.body"
|
|
and not asgi_message.get("more_body", False)
|
|
and not expecting_trailers
|
|
):
|
|
# If the body is completed and we aren't expecting trailers, the
|
|
# response is done so we should stop listening for disconnects.
|
|
response_generator.stop_checking_for_disconnect()
|
|
elif asgi_message["type"] == "http.response.trailers":
|
|
# If we are expecting trailers, the response is only done when
|
|
# the trailers message has been sent.
|
|
if not asgi_message.get("more_trailers", False):
|
|
response_generator.stop_checking_for_disconnect()
|
|
elif asgi_message["type"] in [
|
|
"websocket.close",
|
|
"websocket.disconnect",
|
|
]:
|
|
status_code = str(asgi_message["code"])
|
|
status = ResponseStatus(
|
|
code=status_code,
|
|
# All status codes are considered errors aside from:
|
|
# 1000 (CLOSE_NORMAL), 1001 (CLOSE_GOING_AWAY).
|
|
is_error=status_code not in ["1000", "1001"],
|
|
)
|
|
response_generator.stop_checking_for_disconnect()
|
|
|
|
yield asgi_message
|
|
response_started = True
|
|
except BaseException as e:
|
|
error_status = get_http_response_status(e, request_timeout_s, request_id)
|
|
if status is None:
|
|
status = error_status
|
|
for asgi_message in send_http_response_on_exception(
|
|
error_status, response_started
|
|
):
|
|
yield asgi_message
|
|
exc = e
|
|
|
|
finally:
|
|
# For websocket connection, queue receive task is done when receiving
|
|
# disconnect message from client.
|
|
receive_client_disconnect_msg = False
|
|
if not proxy_asgi_receive_task.done():
|
|
proxy_asgi_receive_task.cancel()
|
|
else:
|
|
receive_client_disconnect_msg = True
|
|
|
|
# If the server disconnects, status_code can be set above from the
|
|
# disconnect message.
|
|
# If client disconnects, the disconnect code comes from
|
|
# a client message via the receive interface.
|
|
if status is None and proxy_request.request_type == "websocket":
|
|
if receive_client_disconnect_msg:
|
|
# The disconnect message is sent from the client.
|
|
status_code = str(proxy_asgi_receive_task.result())
|
|
status = ResponseStatus(
|
|
code=status_code,
|
|
is_error=True,
|
|
)
|
|
else:
|
|
status_code = "1000"
|
|
# The server disconnect without sending a disconnect message
|
|
# (otherwise the `status` would be set).
|
|
status = ResponseStatus(
|
|
code="1000",
|
|
is_error=True,
|
|
)
|
|
else:
|
|
status_code = status.code
|
|
|
|
del self.asgi_receive_queues[internal_request_id]
|
|
|
|
if is_span_recording():
|
|
self._finalize_http_tracing(
|
|
proxy_request, status_code, status.is_error, exc
|
|
)
|
|
|
|
# The status code should always be set.
|
|
assert status is not None
|
|
yield status
|
|
|
|
|
|
def apply_per_node_port_overrides(
|
|
http_options: HTTPOptions, grpc_options: gRPCOptions, is_head: bool
|
|
) -> None:
|
|
"""Override this proxy's HTTP and gRPC bind ports from the per-node env knobs.
|
|
|
|
Worker proxies bind RAY_SERVE_WORKER_PROXY_HTTP_PORT and
|
|
RAY_SERVE_WORKER_PROXY_GRPC_PORT when set. The head node is exempt so its
|
|
configured ports and the fallback proxy stay intact.
|
|
"""
|
|
if is_head:
|
|
return
|
|
if RAY_SERVE_WORKER_PROXY_HTTP_PORT is not None:
|
|
http_options.port = RAY_SERVE_WORKER_PROXY_HTTP_PORT
|
|
if RAY_SERVE_WORKER_PROXY_GRPC_PORT is not None:
|
|
grpc_options.port = RAY_SERVE_WORKER_PROXY_GRPC_PORT
|
|
|
|
|
|
class ProxyActorInterface(ABC):
|
|
"""Abstract interface for proxy actors in Ray Serve.
|
|
|
|
This interface defines the contract that all proxy actor implementations must follow,
|
|
allowing for different proxy backends (Ray HTTP/gRPC proxies, HAProxy, etc.).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
node_id: NodeId,
|
|
node_ip_address: str,
|
|
logging_config: LoggingConfig,
|
|
log_buffer_size: int = RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE,
|
|
):
|
|
"""Initialize the proxy actor.
|
|
|
|
Args:
|
|
node_id: ID of the node this proxy is running on
|
|
node_ip_address: IP address of the node
|
|
logging_config: Logging configuration
|
|
log_buffer_size: Size of the log buffer
|
|
"""
|
|
self._node_id = node_id
|
|
self._node_ip_address = node_ip_address
|
|
self._logging_config = logging_config
|
|
self._log_buffer_size = log_buffer_size
|
|
|
|
self._update_logging_config(logging_config)
|
|
|
|
@abstractmethod
|
|
async def ready(self) -> str:
|
|
"""Blocks until the proxy is ready to serve requests.
|
|
|
|
Returns:
|
|
JSON-serialized metadata containing proxy information (worker ID, log file path, etc.)
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def serving(self, wait_for_applications_running: bool = True) -> None:
|
|
"""Wait for the proxy to be ready to serve requests.
|
|
|
|
Args:
|
|
wait_for_applications_running: Whether to wait for the applications to be running
|
|
|
|
Returns:
|
|
None
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def update_draining(
|
|
self, draining: bool, _after: Optional[Any] = None
|
|
) -> None:
|
|
"""Update the draining status of the proxy.
|
|
|
|
Args:
|
|
draining: Whether the proxy should be draining
|
|
_after: Optional ObjectRef for scheduling dependency
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def is_drained(self, _after: Optional[Any] = None) -> bool:
|
|
"""Check whether the proxy is drained.
|
|
|
|
Args:
|
|
_after: Optional ObjectRef for scheduling dependency
|
|
|
|
Returns:
|
|
True if the proxy is drained, False otherwise
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def check_health(self) -> bool:
|
|
"""Check the health of the proxy.
|
|
|
|
Returns:
|
|
True if the proxy is healthy, False otherwise
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def pong(self) -> str:
|
|
"""Respond to ping from replicas.
|
|
|
|
Returns:
|
|
A response string
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def receive_asgi_messages(self, request_metadata: RequestMetadata) -> bytes:
|
|
"""Handle ASGI messages for HTTP requests.
|
|
|
|
Args:
|
|
request_metadata: Metadata about the request
|
|
|
|
Returns:
|
|
Serialized ASGI messages
|
|
"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def receive_grpc_messages(
|
|
self, session_id: str
|
|
) -> Tuple[bool, Optional[Any], bool]:
|
|
"""Get the next gRPC message for a streaming session.
|
|
|
|
Args:
|
|
session_id: The session ID of the streaming session
|
|
|
|
Returns:
|
|
Tuple of (has_more, message, is_cancelled)
|
|
"""
|
|
pass
|
|
|
|
# Testing and debugging methods
|
|
@abstractmethod
|
|
def _get_http_options(self) -> HTTPOptions:
|
|
"""Get HTTP options used by the proxy."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def _get_logging_config(self) -> Optional[str]:
|
|
"""Get the file path for the logger (for testing purposes)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def _dump_ingress_replicas_for_testing(self, route: str) -> Set:
|
|
"""Get replicas for a route (for testing)."""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def shutdown(self) -> None:
|
|
"""Shuts down proxy."""
|
|
pass
|
|
|
|
def _update_logging_config(self, logging_config: LoggingConfig):
|
|
configure_component_logger(
|
|
component_name="proxy",
|
|
component_id=self._node_ip_address,
|
|
logging_config=logging_config,
|
|
buffer_size=self._log_buffer_size,
|
|
)
|
|
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class ProxyActor(ProxyActorInterface):
|
|
def __init__(
|
|
self,
|
|
http_options: HTTPOptions,
|
|
grpc_options: gRPCOptions,
|
|
*,
|
|
node_id: NodeId,
|
|
node_ip_address: str,
|
|
logging_config: LoggingConfig,
|
|
long_poll_client: Optional[LongPollClient] = None,
|
|
): # noqa: F821
|
|
super().__init__(
|
|
node_id=node_id,
|
|
node_ip_address=node_ip_address,
|
|
logging_config=logging_config,
|
|
)
|
|
|
|
is_head = self._node_id == get_head_node_id()
|
|
apply_per_node_port_overrides(http_options, grpc_options, is_head)
|
|
|
|
self._grpc_options = grpc_options
|
|
self._http_options = configure_http_middlewares(http_options)
|
|
grpc_enabled = is_grpc_enabled(self._grpc_options)
|
|
event_loop = get_or_create_event_loop()
|
|
self.long_poll_client = long_poll_client or LongPollClient(
|
|
ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE),
|
|
{
|
|
LongPollNamespace.GLOBAL_LOGGING_CONFIG: self._update_logging_config,
|
|
LongPollNamespace.ROUTE_TABLE: self._update_routes_in_proxies,
|
|
},
|
|
call_in_event_loop=event_loop,
|
|
client_id=f"{type(self).__name__}:{ray.get_runtime_context().get_actor_id()}",
|
|
)
|
|
|
|
try:
|
|
is_tracing_setup_successful = setup_tracing(
|
|
component_name="proxy", component_id=node_ip_address
|
|
)
|
|
if is_tracing_setup_successful:
|
|
logger.info("Successfully set up tracing for proxy")
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Failed to set up tracing: {e}. "
|
|
"The proxy will continue running, but traces will not be exported."
|
|
)
|
|
|
|
startup_msg = f"Proxy starting on node {self._node_id} (HTTP port: {self._http_options.port}"
|
|
if grpc_enabled:
|
|
startup_msg += f", gRPC port: {self._grpc_options.port})."
|
|
else:
|
|
startup_msg += ")."
|
|
logger.info(startup_msg)
|
|
logger.debug(
|
|
f"Configure Proxy actor {ray.get_runtime_context().get_actor_id()} "
|
|
f"logger with logging config: {logging_config}"
|
|
)
|
|
|
|
configure_component_memory_profiler(
|
|
component_name="proxy", component_id=node_ip_address
|
|
)
|
|
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)
|
|
access_log_context = {
|
|
**ray_core_logging_context,
|
|
SERVE_LOG_COMPONENT: "proxy",
|
|
SERVE_LOG_COMPONENT_ID: self._node_ip_address,
|
|
"log_to_stderr": False,
|
|
"skip_context_filter": True,
|
|
"serve_access_log": True,
|
|
}
|
|
else:
|
|
access_log_context = {
|
|
"log_to_stderr": False,
|
|
"skip_context_filter": True,
|
|
"serve_access_log": True,
|
|
}
|
|
|
|
self.proxy_router = ProxyRouter(get_proxy_handle)
|
|
self.http_proxy = HTTPProxy(
|
|
node_id=self._node_id,
|
|
node_ip_address=self._node_ip_address,
|
|
is_head=is_head,
|
|
self_actor_name=ray.get_runtime_context().get_actor_name(),
|
|
proxy_router=self.proxy_router,
|
|
request_timeout_s=self._http_options.request_timeout_s,
|
|
access_log_context=access_log_context,
|
|
)
|
|
self.grpc_proxy = (
|
|
gRPCProxy(
|
|
node_id=self._node_id,
|
|
node_ip_address=self._node_ip_address,
|
|
is_head=is_head,
|
|
proxy_router=self.proxy_router,
|
|
request_timeout_s=self._grpc_options.request_timeout_s,
|
|
access_log_context=access_log_context,
|
|
)
|
|
if grpc_enabled
|
|
else None
|
|
)
|
|
if self.grpc_proxy:
|
|
get_or_create_event_loop().set_exception_handler(
|
|
asyncio_grpc_exception_handler
|
|
)
|
|
|
|
# Start a task to initialize the HTTP server.
|
|
# The result of this task is checked in the `ready` method.
|
|
self._start_http_server_task = event_loop.create_task(
|
|
start_asgi_http_server(
|
|
self.http_proxy,
|
|
self._http_options,
|
|
event_loop=event_loop,
|
|
enable_so_reuseport=SOCKET_REUSE_PORT_ENABLED,
|
|
)
|
|
)
|
|
# A task that runs the HTTP server until it exits (currently runs forever).
|
|
# Populated with the result of self._start_http_server_task.
|
|
self._running_http_server_task: Optional[asyncio.Task] = None
|
|
|
|
# Start a task to initialize the gRPC server.
|
|
# The result of this task is checked in the `ready` method.
|
|
self._start_grpc_server_task: Optional[asyncio.Task] = None
|
|
if grpc_enabled:
|
|
self._start_grpc_server_task = event_loop.create_task(
|
|
start_grpc_server(
|
|
self.grpc_proxy.service_handler_factory,
|
|
self._grpc_options,
|
|
event_loop=event_loop,
|
|
enable_so_reuseport=SOCKET_REUSE_PORT_ENABLED,
|
|
),
|
|
)
|
|
# A task that runs the gRPC server until it exits (currently runs forever).
|
|
# Populated with the result of self._start_grpc_server_task.
|
|
self._running_grpc_server_task: Optional[asyncio.Task] = None
|
|
|
|
_configure_gc_options()
|
|
|
|
# Start event loop monitoring for the proxy's main event loop.
|
|
self._event_loop_monitor = EventLoopMonitor(
|
|
component=EventLoopMonitor.COMPONENT_PROXY,
|
|
loop_type=EventLoopMonitor.LOOP_TYPE_MAIN,
|
|
actor_id=ray.get_runtime_context().get_actor_id(),
|
|
)
|
|
self._event_loop_monitor.start(event_loop)
|
|
|
|
def _update_routes_in_proxies(self, endpoints: Dict[DeploymentID, EndpointInfo]):
|
|
self.proxy_router.update_routes(endpoints)
|
|
|
|
def _get_logging_config(self) -> Tuple:
|
|
"""Get the logging configuration (for testing purposes)."""
|
|
log_file_path = None
|
|
for handler in logger.handlers:
|
|
if isinstance(handler, logging.handlers.MemoryHandler):
|
|
log_file_path = handler.target.baseFilename
|
|
return log_file_path
|
|
|
|
def _dump_ingress_replicas_for_testing(self, route: str) -> Set[ReplicaID]:
|
|
_, handle, _ = self.http_proxy.proxy_router.match_route(route)
|
|
return handle._router._asyncio_router._request_router._replica_id_set
|
|
|
|
def _dump_ingress_cache_for_testing(self, route: str) -> Set[ReplicaID]:
|
|
"""Get replica IDs that have entries in the queue length cache (for testing)."""
|
|
_, handle, _ = self.http_proxy.proxy_router.match_route(route)
|
|
request_router = handle._router._asyncio_router._request_router
|
|
cache = request_router.replica_queue_len_cache
|
|
return {
|
|
replica_id
|
|
for replica_id in request_router._replica_id_set
|
|
if cache.get(replica_id) is not None
|
|
}
|
|
|
|
async def ready(self) -> str:
|
|
"""Blocks until the proxy HTTP (and optionally gRPC) servers are running.
|
|
|
|
Returns JSON-serialized metadata containing the proxy's worker ID and log
|
|
file path.
|
|
|
|
Raises any exceptions that occur setting up the HTTP or gRPC server.
|
|
"""
|
|
try:
|
|
# The proxy has its own draining; the returned server object is
|
|
# unused.
|
|
self._running_http_server_task, _ = await self._start_http_server_task
|
|
except Exception as e:
|
|
logger.exception("Failed to start proxy HTTP server.")
|
|
raise e from None
|
|
|
|
try:
|
|
if self._start_grpc_server_task is not None:
|
|
self._running_grpc_server_task, _ = await self._start_grpc_server_task
|
|
except Exception as e:
|
|
logger.exception("Failed to start proxy gRPC server.")
|
|
raise e from None
|
|
|
|
# Return proxy metadata used by the controller.
|
|
# NOTE(zcin): We need to convert the metadata to a json string because
|
|
# of cross-language scenarios. Java can't deserialize a Python tuple.
|
|
return json.dumps(
|
|
[
|
|
ray.get_runtime_context().get_worker_id(),
|
|
get_component_logger_file_path(),
|
|
]
|
|
)
|
|
|
|
async def serving(self, wait_for_applications_running: bool = True) -> None:
|
|
"""Wait for the proxy to be ready to serve requests."""
|
|
return
|
|
|
|
def shutdown(self) -> None:
|
|
return
|
|
|
|
async def update_draining(self, draining: bool, _after: Optional[Any] = None):
|
|
"""Update the draining status of the HTTP and gRPC proxies.
|
|
|
|
Unused `_after` argument is for scheduling: passing an ObjectRef
|
|
allows delaying this call until after the `_after` call has returned.
|
|
"""
|
|
|
|
self.http_proxy.update_draining(draining)
|
|
if self.grpc_proxy:
|
|
self.grpc_proxy.update_draining(draining)
|
|
|
|
async def is_drained(self, _after: Optional[Any] = None):
|
|
"""Check whether both HTTP and gRPC proxies are drained or not.
|
|
|
|
Unused `_after` argument is for scheduling: passing an ObjectRef
|
|
allows delaying this call until after the `_after` call has returned.
|
|
"""
|
|
|
|
return self.http_proxy.is_drained() and (
|
|
self.grpc_proxy is None or self.grpc_proxy.is_drained()
|
|
)
|
|
|
|
async def check_health(self) -> bool:
|
|
"""No-op method to check on the health of the HTTP Proxy.
|
|
|
|
Make sure the async event loop is not blocked.
|
|
"""
|
|
logger.debug("Received health check.", extra={"log_to_stderr": False})
|
|
return True
|
|
|
|
def pong(self):
|
|
"""Called by the replica to initialize its handle to the proxy."""
|
|
pass
|
|
|
|
async def receive_asgi_messages(self, request_metadata: RequestMetadata) -> bytes:
|
|
"""Get ASGI messages for the provided `request_metadata`.
|
|
|
|
After the proxy has stopped receiving messages for this `request_metadata`,
|
|
this will always return immediately.
|
|
|
|
Raises `KeyError` if this request ID is not found. This will happen when the
|
|
request is no longer being handled (e.g., the user disconnects).
|
|
"""
|
|
return pickle.dumps(
|
|
await self.http_proxy.receive_asgi_messages(request_metadata)
|
|
)
|
|
|
|
async def receive_grpc_messages(
|
|
self, session_id: str
|
|
) -> Tuple[bool, Optional[Any], bool]:
|
|
"""Get the next gRPC message for a streaming session.
|
|
|
|
This method is called by replicas to receive messages from
|
|
client/bidirectional streaming sessions.
|
|
|
|
Args:
|
|
session_id: The session ID of the streaming session.
|
|
|
|
Returns:
|
|
Tuple of (has_more: bool, message: Optional[Any], is_cancelled: bool).
|
|
- has_more: True if there are more messages, False if stream is done.
|
|
- message: The protobuf message object, or None if stream is done.
|
|
- is_cancelled: True if the stream was cancelled by client or error.
|
|
Also returned when the session ID is not found (e.g., after
|
|
cleanup due to timeout, error, or completion).
|
|
"""
|
|
if self.grpc_proxy is None:
|
|
raise RuntimeError("gRPC proxy is not enabled.")
|
|
|
|
# Return tuple directly - Ray handles serialization
|
|
return await self.grpc_proxy.receive_grpc_messages(session_id)
|
|
|
|
def _get_http_options(self) -> HTTPOptions:
|
|
"""Internal method to get HTTP options used by the proxy."""
|
|
return self._http_options
|
|
|
|
|
|
def _configure_gc_options():
|
|
if not RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS:
|
|
return
|
|
|
|
# Collect any objects that exist already and exclude them from future GC.
|
|
gc.collect(2)
|
|
gc.freeze()
|
|
|
|
# Tune the GC threshold to run less frequently (default is 700).
|
|
gc.set_threshold(RAY_SERVE_PROXY_GC_THRESHOLD)
|