chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<!-- Loaded on-demand when Claude works on Ray Serve files. -->
|
||||
<!-- Keep under 50 lines. Multi-step procedures → skills. Code style → rules/. -->
|
||||
|
||||
# Ray Serve
|
||||
|
||||
## Key Modules
|
||||
<!-- Entry points, important abstractions, non-obvious dependencies -->
|
||||
|
||||
## Gotchas
|
||||
<!-- Non-obvious behaviors, common mistakes, things that break silently -->
|
||||
@@ -0,0 +1,9 @@
|
||||
<!-- Add Ray Serve team-specific rules here as .md files. -->
|
||||
<!-- Rules with paths: frontmatter only load when matching files are edited. -->
|
||||
<!-- Example:
|
||||
---
|
||||
paths:
|
||||
- "python/ray/serve/**/*.py"
|
||||
---
|
||||
- Follow the deployment graph pattern for new features
|
||||
-->
|
||||
@@ -0,0 +1,35 @@
|
||||
load("@rules_python//python:defs.bzl", "py_library")
|
||||
load("//bazel:python.bzl", "doctest")
|
||||
|
||||
doctest(
|
||||
size = "small",
|
||||
data = glob(["**/*.lua.tmpl"]),
|
||||
files = glob(
|
||||
["**/*.py"],
|
||||
exclude = [
|
||||
"tests/**",
|
||||
# FIXME: Add the llm tests back with a diff tag.
|
||||
"llm/**",
|
||||
# FIXME: Failing on Windows
|
||||
"gradio_integrations.py",
|
||||
"_private/benchmarks/**",
|
||||
],
|
||||
),
|
||||
tags = ["team:serve"],
|
||||
)
|
||||
|
||||
# This is a dummy test dependency that causes the above tests to be
|
||||
# re-run if any of these files changes.
|
||||
py_library(
|
||||
name = "serve_lib",
|
||||
srcs = glob(
|
||||
["**/*.py"],
|
||||
exclude = ["tests/**/*.py"],
|
||||
),
|
||||
data = glob(["**/*.lua.tmpl"]),
|
||||
visibility = [
|
||||
"//python/ray/serve:__pkg__",
|
||||
"//python/ray/serve:__subpackages__",
|
||||
"//release:__pkg__",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
import ray._private.worker
|
||||
|
||||
try:
|
||||
from ray.serve._private.logging_utils import configure_default_serve_logger
|
||||
from ray.serve.api import (
|
||||
Application,
|
||||
Deployment,
|
||||
RunTarget,
|
||||
_run,
|
||||
_run_many,
|
||||
delete,
|
||||
deployment,
|
||||
get_app_handle,
|
||||
get_deployment_actor,
|
||||
get_deployment_actor_context,
|
||||
get_deployment_handle,
|
||||
get_multiplexed_model_id,
|
||||
get_replica_context,
|
||||
ingress,
|
||||
multiplexed,
|
||||
run,
|
||||
run_many,
|
||||
shutdown,
|
||||
shutdown_async,
|
||||
start,
|
||||
status,
|
||||
)
|
||||
from ray.serve.batching import batch
|
||||
from ray.serve.config import ControllerOptions, HTTPOptions
|
||||
from ray.serve.utils import get_trace_context
|
||||
|
||||
except ModuleNotFoundError as e:
|
||||
e.msg += (
|
||||
'. You can run `pip install "ray[serve]"` to install all Ray Serve'
|
||||
" dependencies."
|
||||
)
|
||||
raise e
|
||||
|
||||
# Setup default ray.serve logger to ensure all serve module logs are captured.
|
||||
configure_default_serve_logger()
|
||||
|
||||
# Mute the warning because Serve sometimes intentionally calls
|
||||
# ray.get inside async actors.
|
||||
ray._private.worker.blocking_get_inside_async_warned = True
|
||||
|
||||
__all__ = [
|
||||
"_run",
|
||||
"_run_many",
|
||||
"batch",
|
||||
"start",
|
||||
"ControllerOptions",
|
||||
"HTTPOptions",
|
||||
"get_replica_context",
|
||||
"get_deployment_actor",
|
||||
"get_deployment_actor_context",
|
||||
"get_trace_context",
|
||||
"shutdown",
|
||||
"shutdown_async",
|
||||
"ingress",
|
||||
"deployment",
|
||||
"run",
|
||||
"run_many",
|
||||
"RunTarget",
|
||||
"delete",
|
||||
"Application",
|
||||
"Deployment",
|
||||
"multiplexed",
|
||||
"get_multiplexed_model_id",
|
||||
"status",
|
||||
"get_app_handle",
|
||||
"get_deployment_handle",
|
||||
]
|
||||
@@ -0,0 +1,380 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
from types import FunctionType
|
||||
from typing import Any, Dict, List, Tuple, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import ray
|
||||
from ray import ObjectRef
|
||||
from ray._common.usage import usage_lib
|
||||
from ray.actor import ActorHandle
|
||||
from ray.serve._private.client import ServeControllerClient
|
||||
from ray.serve._private.constants import (
|
||||
HTTP_PROXY_TIMEOUT,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.default_impl import get_controller_impl
|
||||
from ray.serve.config import (
|
||||
ControllerOptions,
|
||||
HTTPOptions,
|
||||
ProxyLocation,
|
||||
gRPCOptions,
|
||||
)
|
||||
from ray.serve.context import (
|
||||
_check_cached_client_alive,
|
||||
_get_global_client,
|
||||
_set_global_client,
|
||||
)
|
||||
from ray.serve.deployment import Application
|
||||
from ray.serve.exceptions import RayServeException
|
||||
from ray.serve.schema import LoggingConfig
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def _coerce_controller_options(
|
||||
controller_options: Union[None, dict, ControllerOptions],
|
||||
) -> ControllerOptions:
|
||||
"""Normalize an optional dict / model into a validated ControllerOptions."""
|
||||
if controller_options is None:
|
||||
return ControllerOptions()
|
||||
if isinstance(controller_options, ControllerOptions):
|
||||
return controller_options
|
||||
if isinstance(controller_options, dict):
|
||||
return ControllerOptions.model_validate(controller_options)
|
||||
raise TypeError(
|
||||
"controller_options must be a dict, ControllerOptions, or None; got "
|
||||
f"{type(controller_options).__name__}."
|
||||
)
|
||||
|
||||
|
||||
def _check_http_options(
|
||||
client: ServeControllerClient, http_options: Union[dict, HTTPOptions]
|
||||
) -> None:
|
||||
if http_options:
|
||||
client_http_options = client.http_config
|
||||
new_http_options = (
|
||||
http_options
|
||||
if isinstance(http_options, HTTPOptions)
|
||||
else HTTPOptions.model_validate(http_options)
|
||||
)
|
||||
different_fields = []
|
||||
all_http_option_fields = new_http_options.__dict__
|
||||
for field in all_http_option_fields:
|
||||
if getattr(new_http_options, field) != getattr(client_http_options, field):
|
||||
different_fields.append(field)
|
||||
|
||||
if len(different_fields):
|
||||
logger.warning(
|
||||
"The new client HTTP config differs from the existing one "
|
||||
f"in the following fields: {different_fields}. "
|
||||
"The new HTTP config is ignored."
|
||||
)
|
||||
|
||||
|
||||
def _create_controller_and_proxy_refs(
|
||||
http_options: Union[None, dict, HTTPOptions],
|
||||
grpc_options: Union[None, dict, gRPCOptions],
|
||||
global_logging_config: Union[None, dict, LoggingConfig],
|
||||
controller_options: ControllerOptions,
|
||||
proxy_location: Union[None, str, ProxyLocation] = None,
|
||||
**kwargs,
|
||||
) -> Tuple[ActorHandle, List[ObjectRef]]:
|
||||
"""Create the detached ServeController actor in the caller process.
|
||||
|
||||
Runs everything the old ``_start_controller`` remote task did, but inline:
|
||||
ray.init if needed, arg validation, controller actor creation, and resolving
|
||||
``get_proxies``. Returns the controller handle (owned locally by the caller,
|
||||
following normal ref-counting — no cross-process transfer) plus the list of
|
||||
proxy-readiness ObjectRefs the caller must still wait on.
|
||||
|
||||
Caller resolves ``proxy_ready_refs`` synchronously (``ray.get``) or
|
||||
asynchronously (``await asyncio.gather``). Splitting the wait out of this
|
||||
helper is what lets sync and async share one creation path without
|
||||
duplication (per @edoakes's suggestion on #63597).
|
||||
|
||||
Parameters are same as ray.serve._private.api.serve_start(), except
|
||||
``controller_options`` must already be a validated ``ControllerOptions``
|
||||
(callers coerce eagerly so a bad value raises locally, not from a task).
|
||||
"""
|
||||
|
||||
# Initialize ray if needed.
|
||||
ray._private.worker.global_worker._filter_logs_by_job = False
|
||||
if not ray.is_initialized():
|
||||
ray.init(namespace=SERVE_NAMESPACE)
|
||||
|
||||
# Legacy http proxy actor check
|
||||
http_deprecated_args = ["http_host", "http_port", "http_middlewares"]
|
||||
for key in http_deprecated_args:
|
||||
if key in kwargs:
|
||||
raise ValueError(
|
||||
f"{key} is deprecated, please use serve.start(http_options="
|
||||
f'{{"{key}": {kwargs[key]}}}) instead.'
|
||||
)
|
||||
|
||||
if isinstance(http_options, dict):
|
||||
http_options = HTTPOptions.model_validate(http_options)
|
||||
if http_options is None:
|
||||
http_options = HTTPOptions()
|
||||
|
||||
proxy_location = ProxyLocation._normalize(proxy_location)
|
||||
|
||||
if isinstance(grpc_options, dict):
|
||||
grpc_options = gRPCOptions(**grpc_options)
|
||||
|
||||
if global_logging_config is None:
|
||||
global_logging_config = LoggingConfig()
|
||||
elif isinstance(global_logging_config, dict):
|
||||
global_logging_config = LoggingConfig(**global_logging_config)
|
||||
|
||||
controller_impl = get_controller_impl(controller_options=controller_options)
|
||||
controller = controller_impl.remote(
|
||||
http_options=http_options,
|
||||
grpc_options=grpc_options,
|
||||
global_logging_config=global_logging_config,
|
||||
proxy_location=proxy_location,
|
||||
)
|
||||
|
||||
proxy_handles = ray.get(controller.get_proxies.remote())
|
||||
proxy_ready_refs = (
|
||||
[handle.ready.remote() for handle in proxy_handles.values()]
|
||||
if len(proxy_handles) > 0
|
||||
else []
|
||||
)
|
||||
return controller, proxy_ready_refs
|
||||
|
||||
|
||||
async def serve_start_async(
|
||||
http_options: Union[None, dict, HTTPOptions] = None,
|
||||
grpc_options: Union[None, dict, gRPCOptions] = None,
|
||||
global_logging_config: Union[None, dict, LoggingConfig] = None,
|
||||
controller_options: Union[None, dict, ControllerOptions] = None,
|
||||
proxy_location: Union[None, str, ProxyLocation] = None,
|
||||
**kwargs,
|
||||
) -> ServeControllerClient:
|
||||
"""Initialize a serve instance asynchronously.
|
||||
|
||||
This function is not thread-safe. The caller should maintain the async lock in order
|
||||
to start the serve instance asynchronously.
|
||||
|
||||
This function has the same functionality as ray.serve._private.api.serve_start().
|
||||
|
||||
Parameters & Returns are same as ray.serve._private.api.serve_start().
|
||||
"""
|
||||
|
||||
usage_lib.record_library_usage("serve")
|
||||
|
||||
# Validate eagerly in the caller so a bad ``controller_options`` raises
|
||||
# here, at the call site, rather than after a round-trip into the helper.
|
||||
controller_options = _coerce_controller_options(controller_options)
|
||||
|
||||
client, _ = _check_cached_client_alive()
|
||||
if client is None:
|
||||
try:
|
||||
client = _get_global_client()
|
||||
except RayServeException:
|
||||
client = None
|
||||
if client is not None:
|
||||
logger.info(
|
||||
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
|
||||
" New http_options/controller_options will not be applied."
|
||||
)
|
||||
if http_options:
|
||||
_check_http_options(client, http_options)
|
||||
return client
|
||||
|
||||
# Run the blocking controller-creation helper in a worker thread so its
|
||||
# ray.get(controller.get_proxies.remote()) does not stall this event loop.
|
||||
# serve_start_async exists (vs serve_start) precisely to keep long-lived
|
||||
# callers like the Dashboard Agent responsive while Serve starts up.
|
||||
# Safe because the helper only touches ray.init/ray.get/actor.remote, all
|
||||
# thread-safe via the CoreWorker C++ API, and the Dashboard Agent uses sync
|
||||
# ray.init (so the helper's ray.init branch is skipped). If the dashboard
|
||||
# ever switched to async-mode ray.init this would need revisiting.
|
||||
controller, proxy_ready_refs = await asyncio.to_thread(
|
||||
_create_controller_and_proxy_refs,
|
||||
http_options,
|
||||
grpc_options,
|
||||
global_logging_config,
|
||||
controller_options,
|
||||
proxy_location,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if proxy_ready_refs:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*proxy_ready_refs),
|
||||
timeout=HTTP_PROXY_TIMEOUT,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError(
|
||||
f"HTTP proxies not available after {HTTP_PROXY_TIMEOUT}s."
|
||||
)
|
||||
|
||||
client = ServeControllerClient(controller)
|
||||
_set_global_client(client)
|
||||
logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".')
|
||||
return client
|
||||
|
||||
|
||||
def serve_start(
|
||||
http_options: Union[None, dict, HTTPOptions] = None,
|
||||
grpc_options: Union[None, dict, gRPCOptions] = None,
|
||||
global_logging_config: Union[None, dict, LoggingConfig] = None,
|
||||
controller_options: Union[None, dict, ControllerOptions] = None,
|
||||
proxy_location: Union[None, str, ProxyLocation] = None,
|
||||
**kwargs,
|
||||
) -> ServeControllerClient:
|
||||
"""Initialize a serve instance.
|
||||
|
||||
By default, the instance will be scoped to the lifetime of the returned
|
||||
Client object (or when the script exits). This is
|
||||
only relevant if connecting to a long-running Ray cluster (e.g., with
|
||||
ray.init(address="auto") or ray.init("ray://<remote_addr>")).
|
||||
|
||||
Args:
|
||||
http_options: Configuration options for HTTP proxy. You can pass in a
|
||||
dictionary or HTTPOptions object with fields:
|
||||
|
||||
- host(str, None): Host for HTTP servers to listen on. Defaults to
|
||||
localhost. To expose Serve publicly, you probably want to set
|
||||
this to "0.0.0.0" for IPv4 or "::" for IPv6.
|
||||
- port(int): Port for HTTP server. Defaults to 8000.
|
||||
- root_path(str): Root path to mount the serve application
|
||||
(for example, "/serve"). All deployment routes will be prefixed
|
||||
with this path. Defaults to "".
|
||||
- middlewares(list): A list of Starlette middlewares that will be
|
||||
applied to the HTTP servers in the cluster. Defaults to [].
|
||||
- location(str, serve.config.ProxyLocation): The deployment
|
||||
location of HTTP servers:
|
||||
|
||||
- "HeadOnly": start one HTTP server on the head node. Serve
|
||||
assumes the head node is the node you executed serve.start
|
||||
on. This is the default.
|
||||
- "EveryNode": start one HTTP server per node.
|
||||
- "Disabled" (or legacy "NoServer") or None: disable HTTP server.
|
||||
- num_cpus (int): The number of CPU cores to reserve for each
|
||||
internal Serve HTTP proxy actor. Defaults to 0.
|
||||
grpc_options: Configuration options for gRPC proxy.
|
||||
You can pass in a gRPCOptions object with fields:
|
||||
|
||||
- port(int): Port for gRPC server. Defaults to 9000.
|
||||
- grpc_servicer_functions(list): List of import paths for gRPC
|
||||
`add_servicer_to_server` functions to add to Serve's gRPC proxy.
|
||||
Default empty list, meaning not to start the gRPC server.
|
||||
global_logging_config: Optional ``LoggingConfig`` (or dict) applied as
|
||||
the default logging configuration for the Serve controller and all
|
||||
proxies/replicas in this Serve instance.
|
||||
controller_options: Optional ``ControllerOptions`` (or dict) for the
|
||||
Serve controller actor. Currently only ``runtime_env.env_vars``
|
||||
is honored; see ``ray.serve.config.ControllerOptions``. Only
|
||||
applied on first controller creation -- ignored if the controller
|
||||
is already running in this Ray cluster (a log line is emitted).
|
||||
proxy_location: Where to run proxies that handle ingress traffic to
|
||||
the cluster. See ``ProxyLocation`` for supported options. Defaults
|
||||
to ``EveryNode`` when unspecified. An explicit (deprecated)
|
||||
``HTTPOptions.location`` overrides this.
|
||||
**kwargs: Reserved for forwarding to internal controller-start hooks;
|
||||
no public keys are currently supported and unknown keys may raise.
|
||||
|
||||
Returns:
|
||||
A ``ServeControllerClient`` connected to the Serve controller (either
|
||||
newly started or an already-running one in this Ray cluster).
|
||||
"""
|
||||
|
||||
usage_lib.record_library_usage("serve")
|
||||
|
||||
controller_options = _coerce_controller_options(controller_options)
|
||||
|
||||
client, _ = _check_cached_client_alive()
|
||||
if client is None:
|
||||
try:
|
||||
client = _get_global_client()
|
||||
except RayServeException:
|
||||
client = None
|
||||
if client is not None:
|
||||
logger.info(
|
||||
f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".'
|
||||
" New http_options/controller_options will not be applied."
|
||||
)
|
||||
if http_options:
|
||||
_check_http_options(client, http_options)
|
||||
return client
|
||||
|
||||
controller, proxy_ready_refs = _create_controller_and_proxy_refs(
|
||||
http_options,
|
||||
grpc_options,
|
||||
global_logging_config,
|
||||
controller_options,
|
||||
proxy_location,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
if proxy_ready_refs:
|
||||
try:
|
||||
ray.get(proxy_ready_refs, timeout=HTTP_PROXY_TIMEOUT)
|
||||
except ray.exceptions.GetTimeoutError:
|
||||
raise TimeoutError(
|
||||
f"HTTP proxies not available after {HTTP_PROXY_TIMEOUT}s."
|
||||
)
|
||||
|
||||
client = ServeControllerClient(controller)
|
||||
_set_global_client(client)
|
||||
logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".')
|
||||
return client
|
||||
|
||||
|
||||
def call_user_app_builder_with_args_if_necessary(
|
||||
builder: Union[Application, FunctionType],
|
||||
args: Dict[str, Any],
|
||||
) -> Application:
|
||||
"""Calls a user-provided function that returns Serve application.
|
||||
|
||||
If an Application object is passed, this is a no-op.
|
||||
|
||||
Else, we validate the signature of the function, convert the args dictionary to
|
||||
the user-annotated Pydantic model if provided, and call the function.
|
||||
|
||||
The output of the function is returned (must be an Application).
|
||||
"""
|
||||
if isinstance(builder, Application):
|
||||
if len(args) > 0:
|
||||
raise ValueError(
|
||||
"Arguments can only be passed to an application builder function, "
|
||||
"not an already built application."
|
||||
)
|
||||
return builder
|
||||
elif not isinstance(builder, FunctionType):
|
||||
raise TypeError(
|
||||
"Expected a built Serve application or an application builder function "
|
||||
f"but got: {type(builder)}."
|
||||
)
|
||||
|
||||
# Check that the builder only takes a single argument.
|
||||
# TODO(edoakes): we may want to loosen this to allow optional kwargs in the future.
|
||||
signature = inspect.signature(builder)
|
||||
if len(signature.parameters) != 1:
|
||||
raise TypeError(
|
||||
"Application builder functions should take exactly one parameter, "
|
||||
"a dictionary containing the passed arguments."
|
||||
)
|
||||
|
||||
# If the sole argument to the builder is a pydantic model, convert the args dict to
|
||||
# that model. This will perform standard pydantic validation (e.g., raise an
|
||||
# exception if required fields are missing).
|
||||
param = signature.parameters[list(signature.parameters.keys())[0]]
|
||||
if inspect.isclass(param.annotation) and issubclass(param.annotation, BaseModel):
|
||||
args = param.annotation.model_validate(args)
|
||||
|
||||
app = builder(args)
|
||||
if not isinstance(app, Application):
|
||||
raise TypeError(
|
||||
"Application builder functions must return an `Application` returned "
|
||||
f"`from `Deployment.bind()`, but got: {type(app)}."
|
||||
)
|
||||
|
||||
return app
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple
|
||||
|
||||
import aiohttp
|
||||
import aiohttp.client_exceptions
|
||||
import grpc
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from starlette.responses import StreamingResponse
|
||||
from tqdm import tqdm
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray._common.test_utils import SignalActor as _SignalActor
|
||||
from ray.serve._private.common import DeploymentStatus
|
||||
from ray.serve.generated import serve_pb2, serve_pb2_grpc
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
async def run_latency_benchmark(
|
||||
f: Callable, num_requests: int, *, num_warmup_requests: int = 100
|
||||
) -> pd.Series:
|
||||
if inspect.iscoroutinefunction(f):
|
||||
to_call = f
|
||||
else:
|
||||
|
||||
async def to_call():
|
||||
f()
|
||||
|
||||
latencies = []
|
||||
for i in tqdm(range(num_requests + num_warmup_requests)):
|
||||
start = time.perf_counter()
|
||||
await to_call()
|
||||
end = time.perf_counter()
|
||||
|
||||
# Don't include warm-up requests.
|
||||
if i >= num_warmup_requests:
|
||||
latencies.append(1000 * (end - start))
|
||||
|
||||
return pd.Series(latencies)
|
||||
|
||||
|
||||
async def run_throughput_benchmark(
|
||||
fn: Callable[[], List[float]],
|
||||
multiplier: int = 1,
|
||||
num_trials: int = 10,
|
||||
trial_runtime: float = 1,
|
||||
) -> Tuple[float, float, pd.Series]:
|
||||
"""Benchmarks throughput of a function.
|
||||
|
||||
Args:
|
||||
fn: The function to benchmark. If this returns anything, it must
|
||||
return a list of latencies.
|
||||
multiplier: The number of requests or tokens (or whatever unit
|
||||
is appropriate for this throughput benchmark) that is
|
||||
completed in one call to `fn`.
|
||||
num_trials: The number of trials to run.
|
||||
trial_runtime: How long each trial should run for. During the
|
||||
duration of one trial, `fn` will be repeatedly called.
|
||||
|
||||
Returns:
|
||||
A tuple ``(mean, stddev, latencies)`` summarizing per-trial throughput
|
||||
across ``num_trials`` runs.
|
||||
"""
|
||||
# Warmup
|
||||
start = time.time()
|
||||
while time.time() - start < 0.1:
|
||||
await fn()
|
||||
|
||||
# Benchmark
|
||||
stats = []
|
||||
latencies = []
|
||||
for _ in tqdm(range(num_trials)):
|
||||
start = time.perf_counter()
|
||||
count = 0
|
||||
while time.perf_counter() - start < trial_runtime:
|
||||
res = await fn()
|
||||
if res:
|
||||
latencies.extend(res)
|
||||
|
||||
count += 1
|
||||
end = time.perf_counter()
|
||||
stats.append(multiplier * count / (end - start))
|
||||
|
||||
return round(np.mean(stats), 2), round(np.std(stats), 2), pd.Series(latencies)
|
||||
|
||||
|
||||
async def do_single_http_batch(
|
||||
*,
|
||||
batch_size: int = 100,
|
||||
url: str = "http://localhost:8000",
|
||||
stream: bool = False,
|
||||
) -> List[float]:
|
||||
"""Sends a batch of http requests and returns e2e latencies."""
|
||||
|
||||
# By default, aiohttp limits the number of client connections to 100.
|
||||
# We need to use TCPConnector to configure the limit if batch size
|
||||
# is greater than 100.
|
||||
connector = aiohttp.TCPConnector(limit=batch_size)
|
||||
async with aiohttp.ClientSession(
|
||||
connector=connector, raise_for_status=True
|
||||
) as session:
|
||||
|
||||
async def do_query():
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with session.get(url) as r:
|
||||
if stream:
|
||||
async for chunk, _ in r.content.iter_chunks():
|
||||
pass
|
||||
else:
|
||||
# Read the response to ensure it's consumed
|
||||
await r.read()
|
||||
except aiohttp.client_exceptions.ClientConnectionError:
|
||||
pass
|
||||
|
||||
end = time.perf_counter()
|
||||
return 1000 * (end - start)
|
||||
|
||||
return await asyncio.gather(*[do_query() for _ in range(batch_size)])
|
||||
|
||||
|
||||
async def do_single_grpc_batch(
|
||||
*, batch_size: int = 100, target: str = "localhost:9000"
|
||||
):
|
||||
channel = grpc.aio.insecure_channel(target)
|
||||
stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel)
|
||||
payload = serve_pb2.StringData(data="")
|
||||
|
||||
async def do_query():
|
||||
start = time.perf_counter()
|
||||
|
||||
await stub.grpc_call(payload)
|
||||
|
||||
end = time.perf_counter()
|
||||
return 1000 * (end - start)
|
||||
|
||||
return await asyncio.gather(*[do_query() for _ in range(batch_size)])
|
||||
|
||||
|
||||
async def collect_profile_events(coro: Coroutine):
|
||||
"""Collects profiling events using Viztracer"""
|
||||
|
||||
from viztracer import VizTracer
|
||||
|
||||
tracer = VizTracer()
|
||||
tracer.start()
|
||||
|
||||
await coro
|
||||
|
||||
tracer.stop()
|
||||
tracer.save()
|
||||
|
||||
|
||||
def generate_payload(size: int = 100, chars=string.ascii_uppercase + string.digits):
|
||||
return "".join(random.choice(chars) for _ in range(size))
|
||||
|
||||
|
||||
class Blackhole:
|
||||
def sink(self, o):
|
||||
pass
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Noop:
|
||||
def __init__(self):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return b""
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class ModelComp:
|
||||
def __init__(self, child):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._child = child
|
||||
|
||||
async def __call__(self, *args, **kwargs):
|
||||
return await self._child.remote()
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class GrpcDeployment:
|
||||
def __init__(self):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
async def grpc_call(self, user_message):
|
||||
return serve_pb2.ModelOutput(output=9)
|
||||
|
||||
async def call_with_string(self, user_message):
|
||||
return serve_pb2.ModelOutput(output=9)
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class GrpcModelComp:
|
||||
def __init__(self, child):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._child = child
|
||||
|
||||
async def grpc_call(self, user_message):
|
||||
await self._child.remote()
|
||||
return serve_pb2.ModelOutput(output=9)
|
||||
|
||||
async def call_with_string(self, user_message):
|
||||
await self._child.remote()
|
||||
return serve_pb2.ModelOutput(output=9)
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Streamer:
|
||||
def __init__(self, tokens_per_request: int, inter_token_delay_ms: int = 10):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._tokens_per_request = tokens_per_request
|
||||
self._inter_token_delay_s = inter_token_delay_ms / 1000
|
||||
|
||||
async def stream(self):
|
||||
for _ in range(self._tokens_per_request):
|
||||
await asyncio.sleep(self._inter_token_delay_s)
|
||||
yield b"hi"
|
||||
|
||||
async def __call__(self):
|
||||
return StreamingResponse(self.stream())
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class IntermediateRouter:
|
||||
def __init__(self, handle: DeploymentHandle):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._handle = handle.options(stream=True)
|
||||
|
||||
async def stream(self):
|
||||
async for token in self._handle.stream.remote():
|
||||
yield token
|
||||
|
||||
def __call__(self):
|
||||
return StreamingResponse(self.stream())
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class Benchmarker:
|
||||
def __init__(
|
||||
self,
|
||||
handle: DeploymentHandle,
|
||||
stream: bool = False,
|
||||
):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._handle = handle.options(stream=stream)
|
||||
self._stream = stream
|
||||
|
||||
async def do_single_request(self, payload: Any = None) -> float:
|
||||
"""Completes a single unary request. Returns e2e latency in ms."""
|
||||
start = time.perf_counter()
|
||||
|
||||
if payload is None:
|
||||
await self._handle.remote()
|
||||
else:
|
||||
await self._handle.remote(payload)
|
||||
|
||||
end = time.perf_counter()
|
||||
return 1000 * (end - start)
|
||||
|
||||
async def do_single_choose_dispatch(self, payload: Any = None) -> float:
|
||||
"""Completes a single unary request via choose_replica + dispatch.
|
||||
|
||||
Returns e2e latency in ms. With SingletonThreadRouter this involves
|
||||
two run_coroutine_threadsafe round-trips (one for __aenter__, one
|
||||
for _dispatch_to_marked_selection) vs. one for ``remote``.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
|
||||
if payload is None:
|
||||
async with self._handle.choose_replica() as sel:
|
||||
await self._handle.dispatch(sel)
|
||||
else:
|
||||
async with self._handle.choose_replica(payload) as sel:
|
||||
await self._handle.dispatch(sel, payload)
|
||||
|
||||
end = time.perf_counter()
|
||||
return 1000 * (end - start)
|
||||
|
||||
async def _do_single_stream(self) -> float:
|
||||
"""Consumes a single streaming request. Returns e2e latency in ms."""
|
||||
start = time.perf_counter()
|
||||
|
||||
async for r in self._handle.stream.remote():
|
||||
pass
|
||||
|
||||
end = time.perf_counter()
|
||||
return 1000 * (end - start)
|
||||
|
||||
async def _do_single_batch(self, batch_size: int) -> List[float]:
|
||||
if self._stream:
|
||||
return await asyncio.gather(
|
||||
*[self._do_single_stream() for _ in range(batch_size)]
|
||||
)
|
||||
else:
|
||||
return await asyncio.gather(
|
||||
*[self.do_single_request() for _ in range(batch_size)]
|
||||
)
|
||||
|
||||
async def run_latency_benchmark(
|
||||
self,
|
||||
*,
|
||||
num_requests: int,
|
||||
payload: Any = None,
|
||||
mode: str = "remote",
|
||||
) -> pd.Series:
|
||||
if mode == "remote":
|
||||
|
||||
async def f():
|
||||
await self.do_single_request(payload)
|
||||
|
||||
elif mode == "choose_dispatch":
|
||||
|
||||
async def f():
|
||||
await self.do_single_choose_dispatch(payload)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown mode {mode!r}")
|
||||
|
||||
return await run_latency_benchmark(f, num_requests=num_requests)
|
||||
|
||||
async def run_throughput_benchmark(
|
||||
self,
|
||||
*,
|
||||
batch_size: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
tokens_per_request: Optional[float] = None,
|
||||
) -> Tuple[float, float]:
|
||||
if self._stream:
|
||||
assert tokens_per_request
|
||||
multiplier = tokens_per_request * batch_size
|
||||
else:
|
||||
multiplier = batch_size
|
||||
|
||||
return await run_throughput_benchmark(
|
||||
fn=partial(
|
||||
self._do_single_batch,
|
||||
batch_size=batch_size,
|
||||
),
|
||||
multiplier=multiplier,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Controller Benchmark
|
||||
# =============================================================================
|
||||
# See https://github.com/ray-project/ray/issues/60680 for more details.
|
||||
|
||||
CONTROLLER_BENCH_CONFIG = {
|
||||
"checkpoints": [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 3072, 4096],
|
||||
"marination_period_s": 180,
|
||||
"sample_interval_s": 5,
|
||||
}
|
||||
|
||||
_CONTROLLER_AUTOSCALING_CONFIG = {
|
||||
"min_replicas": 1,
|
||||
"max_replicas": 4096,
|
||||
"target_ongoing_requests": 1,
|
||||
"upscale_delay_s": 1,
|
||||
}
|
||||
|
||||
_CONTROLLER_WAITER_TIMEOUT_S = 1200
|
||||
|
||||
# SignalActor from ray._common.test_utils; use high max_concurrency for many
|
||||
# concurrent waiters (up to 4096 in controller benchmark).
|
||||
_SignalActorForController = _SignalActor.options(max_concurrency=100000)
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
graceful_shutdown_timeout_s=1,
|
||||
ray_actor_options={"num_cpus": 0.2},
|
||||
max_ongoing_requests=100000,
|
||||
autoscaling_config={
|
||||
"min_replicas": 5,
|
||||
"max_replicas": 10,
|
||||
"target_ongoing_requests": 100000,
|
||||
"upscale_delay_s": 1,
|
||||
},
|
||||
)
|
||||
class ControllerBenchHelloWorld:
|
||||
def __init__(self, signal_actor):
|
||||
self.signal = signal_actor
|
||||
|
||||
async def __call__(self):
|
||||
await self.signal.wait.remote()
|
||||
return "hello"
|
||||
|
||||
|
||||
@serve.deployment(
|
||||
autoscaling_config=_CONTROLLER_AUTOSCALING_CONFIG,
|
||||
max_ongoing_requests=2,
|
||||
graceful_shutdown_timeout_s=1,
|
||||
ray_actor_options={"num_cpus": 0.4},
|
||||
)
|
||||
class ControllerBenchMetricsGenerator:
|
||||
"""Autoscaling deployment that generates handle metrics to stress the controller."""
|
||||
|
||||
def __init__(self, hello_world: DeploymentHandle):
|
||||
self.hello_world = hello_world
|
||||
|
||||
async def __call__(self):
|
||||
return await self.hello_world.remote()
|
||||
|
||||
|
||||
def _controller_get_active_nodes() -> int:
|
||||
"""Get number of active nodes in the cluster."""
|
||||
return len([n for n in ray.nodes() if n.get("Alive", False)])
|
||||
|
||||
|
||||
async def _controller_get_replica_count(
|
||||
deployment_name: str = "ControllerBenchMetricsGenerator",
|
||||
) -> int:
|
||||
"""Get current number of running replicas for the specified deployment."""
|
||||
status = serve.status()
|
||||
for app in status.applications.values():
|
||||
for name, deployment in app.deployments.items():
|
||||
if name == deployment_name:
|
||||
return deployment.replica_states.get("RUNNING", 0)
|
||||
return 0
|
||||
|
||||
|
||||
async def _controller_get_health_metrics() -> Dict[str, Any]:
|
||||
"""Get controller health metrics. Fails the run if unavailable."""
|
||||
client = serve.context._global_client
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
"Serve is not connected. get_health_metrics requires an active Serve "
|
||||
"controller. Ensure Serve is started before running the controller benchmark."
|
||||
)
|
||||
controller = client._controller
|
||||
if not hasattr(controller, "get_health_metrics"):
|
||||
raise RuntimeError(
|
||||
"Controller does not have get_health_metrics. This API is required for "
|
||||
"the controller benchmark. Please use a Ray version that supports "
|
||||
"controller health metrics."
|
||||
)
|
||||
return await controller.get_health_metrics.remote()
|
||||
|
||||
|
||||
def _controller_extract_metrics_row(
|
||||
health_metrics: Dict[str, Any],
|
||||
checkpoint: int,
|
||||
sample: int,
|
||||
target_replicas: int,
|
||||
actual_replicas: int,
|
||||
num_nodes: int,
|
||||
autoscale_duration_s: float,
|
||||
) -> Dict[str, Any]:
|
||||
"""Extract a flat row from health metrics with all available fields."""
|
||||
|
||||
def get_stat(d: dict, key: str, stat: str, default=0):
|
||||
return d.get(key, {}).get(stat, default)
|
||||
|
||||
return {
|
||||
"checkpoint": checkpoint,
|
||||
"sample": sample,
|
||||
"target_replicas": target_replicas,
|
||||
"actual_replicas": actual_replicas,
|
||||
"num_nodes": num_nodes,
|
||||
"autoscale_duration_s": round(autoscale_duration_s, 3),
|
||||
"loop_duration_mean_s": get_stat(health_metrics, "loop_duration_s", "mean"),
|
||||
"loop_duration_std_s": get_stat(health_metrics, "loop_duration_s", "std"),
|
||||
"loops_per_second": health_metrics.get("loops_per_second", 0),
|
||||
"event_loop_delay_s": health_metrics.get("event_loop_delay_s", 0),
|
||||
"num_asyncio_tasks": health_metrics.get("num_asyncio_tasks", 0),
|
||||
"deployment_state_update_mean_s": get_stat(
|
||||
health_metrics, "deployment_state_update_duration_s", "mean"
|
||||
),
|
||||
"application_state_update_mean_s": get_stat(
|
||||
health_metrics, "application_state_update_duration_s", "mean"
|
||||
),
|
||||
"proxy_state_update_mean_s": get_stat(
|
||||
health_metrics, "proxy_state_update_duration_s", "mean"
|
||||
),
|
||||
"proxy_state_update_std_s": get_stat(
|
||||
health_metrics, "proxy_state_update_duration_s", "std"
|
||||
),
|
||||
"node_update_mean_s": get_stat(
|
||||
health_metrics, "node_update_duration_s", "mean"
|
||||
),
|
||||
"node_update_std_s": get_stat(health_metrics, "node_update_duration_s", "std"),
|
||||
"node_update_min_s": get_stat(health_metrics, "node_update_duration_s", "min"),
|
||||
"handle_metrics_delay_mean_ms": get_stat(
|
||||
health_metrics, "handle_metrics_delay_ms", "mean"
|
||||
),
|
||||
"replica_metrics_delay_mean_ms": get_stat(
|
||||
health_metrics, "replica_metrics_delay_ms", "mean"
|
||||
),
|
||||
"process_memory_mb": health_metrics.get("process_memory_mb", 0),
|
||||
}
|
||||
|
||||
|
||||
async def _controller_wait_for_replicas_up(target: int, timeout: float = 300) -> float:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
actual = await _controller_get_replica_count()
|
||||
if actual >= target:
|
||||
return time.time() - start
|
||||
if int(time.time() - start) % 10 == 0:
|
||||
logging.info(f"Waiting for {target} replicas... {actual}/{target}")
|
||||
await asyncio.sleep(0.5)
|
||||
actual = await _controller_get_replica_count()
|
||||
raise RuntimeError(
|
||||
f"Timeout: Only {actual}/{target} replicas after {timeout}s. Ending experiment."
|
||||
)
|
||||
|
||||
|
||||
async def _controller_wait_for_waiters(
|
||||
signal_actor, expected: int, timeout: float = 300
|
||||
) -> float:
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
num_waiters = await signal_actor.cur_num_waiters.remote()
|
||||
if num_waiters >= expected:
|
||||
return time.time() - start
|
||||
await asyncio.sleep(0.5)
|
||||
if int(time.time() - start) % 10 == 0:
|
||||
logging.info(f"Waiting for {expected} waiters... {num_waiters}/{expected}")
|
||||
num_waiters = await signal_actor.cur_num_waiters.remote()
|
||||
raise RuntimeError(
|
||||
f"Timeout: Only {num_waiters}/{expected} requests reached replicas after "
|
||||
f"{timeout}s. Ending experiment."
|
||||
)
|
||||
|
||||
|
||||
async def _controller_wait_for_deployment_healthy(
|
||||
deployment_name: str = "ControllerBenchMetricsGenerator",
|
||||
app_name: str = "default",
|
||||
timeout: float = 60,
|
||||
) -> None:
|
||||
"""Wait for the deployment to enter HEALTHY status via serve.status()."""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
status = serve.status()
|
||||
app = status.applications.get(app_name)
|
||||
dep_status = None
|
||||
if app and deployment_name in app.deployments:
|
||||
dep = app.deployments[deployment_name]
|
||||
dep_status = dep.status
|
||||
if dep_status == DeploymentStatus.HEALTHY:
|
||||
return
|
||||
if dep_status == DeploymentStatus.UNHEALTHY:
|
||||
raise RuntimeError(
|
||||
f"Deployment {deployment_name} is UNHEALTHY: {getattr(dep, 'message', '')}"
|
||||
)
|
||||
if int(time.time() - start) % 10 == 0:
|
||||
logging.info(
|
||||
f"Waiting for {deployment_name} to be healthy, current: {dep_status}."
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Deployment {deployment_name} did not become HEALTHY after {timeout}s."
|
||||
)
|
||||
|
||||
|
||||
_BATCH_SIZE = 64
|
||||
|
||||
|
||||
async def _controller_run_checkpoint(
|
||||
handle: DeploymentHandle,
|
||||
signal_actor,
|
||||
checkpoint: int,
|
||||
target_replicas: int,
|
||||
marination_period_s: int,
|
||||
sample_interval_s: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Run a single checkpoint and collect metrics."""
|
||||
start_time = time.time()
|
||||
num_requests = int(target_replicas)
|
||||
|
||||
pending_requests: List[Any] = []
|
||||
pending_requests.extend([handle.remote() for _ in range(num_requests)])
|
||||
logging.info(f"Waiting for {num_requests} requests to be up...")
|
||||
await _controller_wait_for_waiters(
|
||||
signal_actor, len(pending_requests), timeout=_CONTROLLER_WAITER_TIMEOUT_S
|
||||
)
|
||||
logging.info(f"Waiting for {target_replicas} replicas to be up...")
|
||||
# TODO: This is a hack to allow for some tolerance in the number of replicas.
|
||||
# This is because the controller may not scale exactly to the target number of replicas.
|
||||
# This is a bug in the controller autoscaling metrics aggregation logic, needs
|
||||
# to be investigated further.
|
||||
# This has the potential to introduce noise in the results from this benchmark.
|
||||
replica_tolerance = 0.8
|
||||
await _controller_wait_for_replicas_up(
|
||||
int(target_replicas * replica_tolerance), timeout=_CONTROLLER_WAITER_TIMEOUT_S
|
||||
)
|
||||
logging.info(f"All {target_replicas} replicas are up.")
|
||||
logging.info("Waiting for deployment to be healthy...")
|
||||
await _controller_wait_for_deployment_healthy(timeout=_CONTROLLER_WAITER_TIMEOUT_S)
|
||||
logging.info("Deployment is healthy.")
|
||||
logging.info(f"Waiting for {marination_period_s} seconds to collect metrics...")
|
||||
|
||||
autoscale_duration_s = time.time() - start_time
|
||||
|
||||
samples = []
|
||||
num_samples = marination_period_s // sample_interval_s
|
||||
for sample_idx in range(num_samples):
|
||||
health_metrics = await _controller_get_health_metrics()
|
||||
actual_replicas = await _controller_get_replica_count()
|
||||
num_nodes = _controller_get_active_nodes()
|
||||
row = _controller_extract_metrics_row(
|
||||
health_metrics=health_metrics,
|
||||
checkpoint=checkpoint,
|
||||
sample=sample_idx,
|
||||
target_replicas=target_replicas,
|
||||
actual_replicas=actual_replicas,
|
||||
num_nodes=num_nodes,
|
||||
autoscale_duration_s=autoscale_duration_s,
|
||||
)
|
||||
samples.append(row)
|
||||
if sample_idx < num_samples - 1:
|
||||
await asyncio.sleep(sample_interval_s)
|
||||
|
||||
await signal_actor.send.remote(clear=True)
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending_requests, return_exceptions=True),
|
||||
timeout=30.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
return samples
|
||||
|
||||
|
||||
async def run_controller_benchmark(
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Run the controller health metrics benchmark and return raw samples.
|
||||
|
||||
Uses MetricsGenerator (autoscaling) -> HelloWorld (fixed) -> SignalActor
|
||||
to stress the controller as replicas scale. Fails if get_health_metrics
|
||||
is unavailable.
|
||||
|
||||
Args:
|
||||
config: Optional benchmark config (checkpoints, marination_period_s,
|
||||
sample_interval_s). Uses CONTROLLER_BENCH_CONFIG if None.
|
||||
|
||||
Returns:
|
||||
List of sample dicts (one per marination sample). Each sample has
|
||||
target_replicas, autoscale_duration_s, loop_duration_mean_s,
|
||||
loops_per_second, event_loop_delay_s, num_asyncio_tasks, etc.
|
||||
Caller converts to perf_metrics via convert_controller_samples_to_perf_metrics.
|
||||
"""
|
||||
cfg = config or CONTROLLER_BENCH_CONFIG
|
||||
checkpoints = cfg["checkpoints"]
|
||||
marination_period_s = cfg["marination_period_s"]
|
||||
sample_interval_s = cfg["sample_interval_s"]
|
||||
|
||||
if not ray.is_initialized():
|
||||
ray.init()
|
||||
|
||||
signal_actor = _SignalActorForController.remote()
|
||||
all_samples: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
for checkpoint_idx, target_replicas in enumerate(checkpoints):
|
||||
hello_world = ControllerBenchHelloWorld.bind(signal_actor)
|
||||
app = ControllerBenchMetricsGenerator.bind(hello_world)
|
||||
handle = serve.run(app, name="default", route_prefix=None)
|
||||
|
||||
samples = await _controller_run_checkpoint(
|
||||
handle=handle,
|
||||
signal_actor=signal_actor,
|
||||
checkpoint=checkpoint_idx,
|
||||
target_replicas=target_replicas,
|
||||
marination_period_s=marination_period_s,
|
||||
sample_interval_s=sample_interval_s,
|
||||
)
|
||||
all_samples.extend(samples)
|
||||
serve.shutdown()
|
||||
finally:
|
||||
serve.shutdown()
|
||||
|
||||
return all_samples
|
||||
@@ -0,0 +1,39 @@
|
||||
import time
|
||||
|
||||
import click
|
||||
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.common import Benchmarker, Noop
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@click.command(help="Benchmark no-op DeploymentHandle latency.")
|
||||
@click.option("--num-replicas", type=int, default=1)
|
||||
@click.option("--num-requests", type=int, default=100)
|
||||
@click.option(
|
||||
"--mode",
|
||||
type=click.Choice(["remote", "choose_dispatch"]),
|
||||
default="remote",
|
||||
help="Which call pattern to benchmark.",
|
||||
)
|
||||
def main(num_replicas: int, num_requests: int, mode: str):
|
||||
h: DeploymentHandle = serve.run(
|
||||
Benchmarker.bind(Noop.options(num_replicas=num_replicas).bind())
|
||||
)
|
||||
|
||||
latencies = h.run_latency_benchmark.remote(
|
||||
num_requests=num_requests, mode=mode
|
||||
).result()
|
||||
|
||||
# Let the logs flush to avoid interwoven output.
|
||||
time.sleep(1)
|
||||
|
||||
print(
|
||||
f"Latency (ms) for noop DeploymentHandle requests via {mode!r} "
|
||||
f"(num_replicas={num_replicas},num_requests={num_requests}):"
|
||||
)
|
||||
print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
import click
|
||||
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.common import Benchmarker, Hello
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@click.command(help="Benchmark deployment handle throughput.")
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of requests to send to downstream deployment in each trial.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-replicas",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of replicas in the downstream deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-trials",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of trials of the benchmark to run.",
|
||||
)
|
||||
@click.option(
|
||||
"--trial-runtime",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Duration to run each trial of the benchmark for (seconds).",
|
||||
)
|
||||
def main(
|
||||
batch_size: int,
|
||||
num_replicas: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
):
|
||||
app = Benchmarker.bind(
|
||||
Hello.options(
|
||||
num_replicas=num_replicas, ray_actor_options={"num_cpus": 0}
|
||||
).bind(),
|
||||
)
|
||||
h: DeploymentHandle = serve.run(app)
|
||||
|
||||
mean, stddev = h.run_throughput_benchmark.remote(
|
||||
batch_size=batch_size,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
).result()
|
||||
|
||||
print(
|
||||
"DeploymentHandle throughput {}: {} +- {} requests/s".format(
|
||||
f"(num_replicas={num_replicas}, batch_size={batch_size})",
|
||||
mean,
|
||||
stddev,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.common import Noop, run_latency_benchmark
|
||||
|
||||
|
||||
@click.command(help="Benchmark no-op HTTP latency.")
|
||||
@click.option("--num-replicas", type=int, default=1)
|
||||
@click.option("--num-requests", type=int, default=100)
|
||||
def main(num_replicas: int, num_requests: int):
|
||||
serve.run(Noop.options(num_replicas=num_replicas).bind())
|
||||
|
||||
latencies: pd.Series = asyncio.new_event_loop().run_until_complete(
|
||||
run_latency_benchmark(
|
||||
lambda: requests.get("http://localhost:8000"),
|
||||
num_requests=num_requests,
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
"Latency (ms) for noop HTTP requests "
|
||||
f"(num_replicas={num_replicas},num_requests={num_requests}):"
|
||||
)
|
||||
print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,374 @@
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
|
||||
from ray.serve._private.utils import generate_request_id
|
||||
|
||||
logger = logging.getLogger(__file__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
MASTER_PORT = 5557
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocustStage:
|
||||
duration_s: int
|
||||
users: int
|
||||
spawn_rate: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceStats:
|
||||
p50_latency: float
|
||||
p90_latency: float
|
||||
p99_latency: float
|
||||
rps: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocustTestResults:
|
||||
history: List[Dict]
|
||||
total_requests: int
|
||||
num_failures: int
|
||||
avg_latency: float
|
||||
p50_latency: float
|
||||
p90_latency: float
|
||||
p99_latency: float
|
||||
avg_rps: float
|
||||
stats_in_stages: List[PerformanceStats]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FailedRequest:
|
||||
request_id: str
|
||||
status_code: int
|
||||
exception: str
|
||||
response_time_ms: float
|
||||
start_time_s: float
|
||||
|
||||
|
||||
class LocustClient:
|
||||
def __init__(
|
||||
self,
|
||||
host_url: str,
|
||||
token: str,
|
||||
data: Dict[str, Any] = None,
|
||||
):
|
||||
from locust import FastHttpUser, constant, events, task
|
||||
from locust.contrib.fasthttp import FastResponse
|
||||
|
||||
self.errors = []
|
||||
self.stats_in_stages: List[PerformanceStats] = []
|
||||
|
||||
class EndpointUser(FastHttpUser):
|
||||
wait_time = constant(0)
|
||||
failed_requests = []
|
||||
host = host_url
|
||||
|
||||
@task
|
||||
def test(self):
|
||||
request_id = generate_request_id()
|
||||
headers = (
|
||||
{"Authorization": f"Bearer {token}", "X-Request-ID": request_id}
|
||||
if token
|
||||
else None
|
||||
)
|
||||
start = time.perf_counter()
|
||||
with self.client.get(
|
||||
"", headers=headers, json=data, catch_response=True
|
||||
) as r:
|
||||
# locust<=2.18 FastHttp truncates response_time to whole ms;
|
||||
# re-measure so the 0.1ms buckets see sub-ms differences.
|
||||
r.request_meta["response_time"] = (
|
||||
time.perf_counter() - start
|
||||
) * 1000
|
||||
r.request_meta["context"]["request_id"] = request_id
|
||||
|
||||
@events.request.add_listener
|
||||
def on_request(
|
||||
response: FastResponse,
|
||||
exception,
|
||||
context,
|
||||
start_time: float,
|
||||
response_time: float,
|
||||
**kwargs,
|
||||
):
|
||||
if exception and response.status_code != 0:
|
||||
request_id = context["request_id"]
|
||||
print(
|
||||
f"Request '{request_id}' failed with exception:\n"
|
||||
f"{exception}\n{response.text}"
|
||||
)
|
||||
|
||||
if response.status_code != 0:
|
||||
response.encoding = "utf-8"
|
||||
err = FailedRequest(
|
||||
request_id=request_id,
|
||||
status_code=response.status_code,
|
||||
exception=response.text,
|
||||
response_time_ms=response_time,
|
||||
start_time_s=start_time,
|
||||
)
|
||||
self.errors.append(err)
|
||||
print(
|
||||
f"Request '{request_id}' failed with exception:\n"
|
||||
f"{exception}\n{response.text}"
|
||||
)
|
||||
|
||||
self.user_class = EndpointUser
|
||||
|
||||
|
||||
class ResponseTimeSnapshot(NamedTuple):
|
||||
# Cumulative {rounded_response_time: count} histogram + request count.
|
||||
response_times: Dict[float, int]
|
||||
num_requests: int
|
||||
|
||||
|
||||
def _fine_bucket_response_time(response_time):
|
||||
"""0.1ms resolution below 100ms (vs locust's 1ms floor), coarser above."""
|
||||
if response_time < 100:
|
||||
return round(response_time, 1)
|
||||
elif response_time < 1000:
|
||||
return round(response_time)
|
||||
else:
|
||||
return int(round(response_time, -1))
|
||||
|
||||
|
||||
def _install_fine_response_time_bucketing():
|
||||
"""Swap in the finer bucketer; must run in every response-logging process.
|
||||
|
||||
Released locust (through at least 2.41) inlines the rounding in
|
||||
StatsEntry._log_response_time, so patching requires overriding the whole
|
||||
method. Unreleased locust factors it into stats.bucket_response_time."""
|
||||
import locust.stats
|
||||
|
||||
if hasattr(locust.stats, "bucket_response_time"):
|
||||
locust.stats.bucket_response_time = _fine_bucket_response_time
|
||||
return
|
||||
|
||||
def _log_response_time(self, response_time):
|
||||
# Copy of locust 2.x StatsEntry._log_response_time with the inline
|
||||
# rounding replaced by the fine bucketer.
|
||||
if response_time is None:
|
||||
self.num_none_requests += 1
|
||||
return
|
||||
|
||||
self.total_response_time += response_time
|
||||
|
||||
if self.min_response_time is None:
|
||||
self.min_response_time = response_time
|
||||
self.min_response_time = min(self.min_response_time, response_time)
|
||||
self.max_response_time = max(self.max_response_time, response_time)
|
||||
|
||||
rounded_response_time = _fine_bucket_response_time(response_time)
|
||||
# setdefault keeps this compatible with both the plain dict (<=2.18)
|
||||
# and defaultdict (>=2.33) versions of response_times.
|
||||
self.response_times.setdefault(rounded_response_time, 0)
|
||||
self.response_times[rounded_response_time] += 1
|
||||
|
||||
locust.stats.StatsEntry._log_response_time = _log_response_time
|
||||
|
||||
|
||||
def on_stage_finished(master_runner, stats_in_stages, stage_duration_s, prev_snapshot):
|
||||
"""Per-stage stats by differencing cumulative snapshots; returns the
|
||||
snapshot to seed the next stage. Percentiles use locust's own
|
||||
calculate_response_time_percentile so they match its end-of-test report."""
|
||||
from locust.stats import (
|
||||
calculate_response_time_percentile,
|
||||
diff_response_time_dicts,
|
||||
)
|
||||
|
||||
stats_entry = master_runner.stats.entries.get(("", "GET"))
|
||||
snapshot = ResponseTimeSnapshot(
|
||||
dict(stats_entry.response_times), stats_entry.num_requests
|
||||
)
|
||||
|
||||
stage_hist = diff_response_time_dicts(
|
||||
snapshot.response_times, prev_snapshot.response_times
|
||||
)
|
||||
stage_requests = snapshot.num_requests - prev_snapshot.num_requests
|
||||
|
||||
stats_in_stages.append(
|
||||
PerformanceStats(
|
||||
p50_latency=calculate_response_time_percentile(
|
||||
stage_hist, stage_requests, 0.5
|
||||
),
|
||||
p90_latency=calculate_response_time_percentile(
|
||||
stage_hist, stage_requests, 0.9
|
||||
),
|
||||
p99_latency=calculate_response_time_percentile(
|
||||
stage_hist, stage_requests, 0.99
|
||||
),
|
||||
rps=stage_requests / stage_duration_s if stage_duration_s else 0.0,
|
||||
)
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def run_locust_worker(
|
||||
master_address: str, host_url: str, token: str, data: Dict[str, Any]
|
||||
):
|
||||
import locust
|
||||
from locust.env import Environment
|
||||
from locust.log import setup_logging
|
||||
|
||||
setup_logging("INFO")
|
||||
# Workers log response times, so the finer bucketer must be installed here.
|
||||
_install_fine_response_time_bucketing()
|
||||
client = LocustClient(host_url=host_url, token=token, data=data)
|
||||
env = Environment(user_classes=[client.user_class], events=locust.events)
|
||||
|
||||
runner = env.create_worker_runner(
|
||||
master_host=master_address, master_port=MASTER_PORT
|
||||
)
|
||||
runner.greenlet.join()
|
||||
|
||||
if client.errors:
|
||||
raise RuntimeError(f"There were {len(client.errors)} errors: {client.errors}")
|
||||
|
||||
|
||||
def run_locust_master(
|
||||
host_url: str,
|
||||
token: str,
|
||||
expected_num_workers: int,
|
||||
stages: List[LocustStage],
|
||||
wait_for_workers_timeout_s: float,
|
||||
):
|
||||
import gevent
|
||||
import locust
|
||||
from locust import LoadTestShape
|
||||
from locust.env import Environment
|
||||
from locust.stats import (
|
||||
get_error_report_summary,
|
||||
get_percentile_stats_summary,
|
||||
get_stats_summary,
|
||||
stats_history,
|
||||
stats_printer,
|
||||
)
|
||||
|
||||
_install_fine_response_time_bucketing()
|
||||
client = LocustClient(host_url, token)
|
||||
|
||||
class StagesShape(LoadTestShape):
|
||||
curr_stage_ix = 0
|
||||
# Cumulative response-time snapshot at the start of the current stage;
|
||||
# on_stage_finished diffs against it to get per-stage stats.
|
||||
prev_snapshot = ResponseTimeSnapshot({}, 0)
|
||||
|
||||
def tick(cls):
|
||||
run_time = cls.get_run_time()
|
||||
prefix_time = 0
|
||||
for i, stage in enumerate(stages):
|
||||
prefix_time += stage.duration_s
|
||||
|
||||
if run_time < prefix_time:
|
||||
if i != cls.curr_stage_ix:
|
||||
cls.prev_snapshot = on_stage_finished(
|
||||
master_runner,
|
||||
client.stats_in_stages,
|
||||
stages[cls.curr_stage_ix].duration_s,
|
||||
cls.prev_snapshot,
|
||||
)
|
||||
cls.curr_stage_ix = i
|
||||
|
||||
current_stage = stages[cls.curr_stage_ix]
|
||||
return current_stage.users, current_stage.spawn_rate
|
||||
|
||||
# End of stage test
|
||||
cls.prev_snapshot = on_stage_finished(
|
||||
master_runner,
|
||||
client.stats_in_stages,
|
||||
stages[cls.curr_stage_ix].duration_s,
|
||||
cls.prev_snapshot,
|
||||
)
|
||||
|
||||
master_env = Environment(
|
||||
user_classes=[client.user_class],
|
||||
shape_class=StagesShape(),
|
||||
events=locust.events,
|
||||
)
|
||||
master_runner = master_env.create_master_runner("*", MASTER_PORT)
|
||||
|
||||
start = time.time()
|
||||
while len(master_runner.clients.ready) < expected_num_workers:
|
||||
if time.time() - start > wait_for_workers_timeout_s:
|
||||
raise RuntimeError(
|
||||
f"Timed out waiting for {expected_num_workers} workers to "
|
||||
"connect to Locust master."
|
||||
)
|
||||
|
||||
print(
|
||||
f"Waiting for workers to be ready, "
|
||||
f"{len(master_runner.clients.ready)} "
|
||||
f"of {expected_num_workers} ready."
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
# Periodically output current stats (each entry is aggregated
|
||||
# stats over the past 10 seconds, by default)
|
||||
gevent.spawn(stats_printer(master_env.stats))
|
||||
gevent.spawn(stats_history, master_runner)
|
||||
|
||||
# Start test & wait for the shape test to finish
|
||||
master_runner.start_shape()
|
||||
master_runner.shape_greenlet.join()
|
||||
# Send quit signal to all locust workers
|
||||
master_runner.quit()
|
||||
|
||||
# Print stats
|
||||
for line in get_stats_summary(master_runner.stats, current=False):
|
||||
print(line)
|
||||
# Print percentile stats
|
||||
for line in get_percentile_stats_summary(master_runner.stats):
|
||||
print(line)
|
||||
# Print error report
|
||||
if master_runner.stats.errors:
|
||||
for line in get_error_report_summary(master_runner.stats):
|
||||
print(line)
|
||||
|
||||
stats_entry_key = ("", "GET")
|
||||
stats_entry = master_runner.stats.entries.get(stats_entry_key)
|
||||
results = LocustTestResults(
|
||||
history=master_runner.stats.history,
|
||||
total_requests=master_runner.stats.num_requests,
|
||||
num_failures=master_runner.stats.num_failures,
|
||||
avg_latency=stats_entry.avg_response_time,
|
||||
p50_latency=stats_entry.get_response_time_percentile(0.5),
|
||||
p90_latency=stats_entry.get_response_time_percentile(0.9),
|
||||
p99_latency=stats_entry.get_response_time_percentile(0.99),
|
||||
avg_rps=stats_entry.total_rps,
|
||||
stats_in_stages=client.stats_in_stages,
|
||||
)
|
||||
return asdict(results)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--worker-type", type=str, required=True)
|
||||
parser.add_argument("--host-url", type=str, required=True)
|
||||
parser.add_argument("--token", type=str, required=True)
|
||||
parser.add_argument("--master-address", type=str, required=False)
|
||||
parser.add_argument("--expected-num-workers", type=int, required=False)
|
||||
parser.add_argument("--stages", type=str, required=False)
|
||||
parser.add_argument("--wait-for-workers-timeout-s", type=float, required=False)
|
||||
args = parser.parse_args()
|
||||
host_url = args.host_url
|
||||
token = args.token
|
||||
if args.worker_type == "master":
|
||||
results = run_locust_master(
|
||||
host_url,
|
||||
token,
|
||||
args.expected_num_workers,
|
||||
args.stages,
|
||||
args.wait_for_workers_timeout_s,
|
||||
)
|
||||
else:
|
||||
results = run_locust_worker(args.master_address, host_url, token, args.data)
|
||||
|
||||
print(results)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,182 @@
|
||||
# Runs several scenarios with varying max batch size, max concurrent queries,
|
||||
# number of replicas, and with intermediate serve handles (to simulate ensemble
|
||||
# models) either on or off.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pprint import pprint
|
||||
from typing import Dict, Union
|
||||
|
||||
import aiohttp
|
||||
from starlette.requests import Request
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.common import run_throughput_benchmark
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
NUM_CLIENTS = 8
|
||||
CALLS_PER_BATCH = 100
|
||||
|
||||
|
||||
async def fetch(session, data):
|
||||
async with session.get("http://localhost:8000/", data=data) as response:
|
||||
response = await response.text()
|
||||
assert response == "ok", response
|
||||
|
||||
|
||||
@ray.remote
|
||||
class Client:
|
||||
def ready(self):
|
||||
return "ok"
|
||||
|
||||
async def do_queries(self, num, data):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for _ in range(num):
|
||||
await fetch(session, data)
|
||||
|
||||
|
||||
def build_app(
|
||||
intermediate_handles: bool,
|
||||
num_replicas: int,
|
||||
max_batch_size: int,
|
||||
max_ongoing_requests: int,
|
||||
):
|
||||
@serve.deployment(max_ongoing_requests=1000)
|
||||
class Upstream:
|
||||
def __init__(self, handle: DeploymentHandle):
|
||||
self._handle = handle
|
||||
|
||||
# Turn off access log.
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
async def __call__(self, req: Request):
|
||||
return await self._handle.remote(await req.body())
|
||||
|
||||
@serve.deployment(
|
||||
num_replicas=num_replicas,
|
||||
max_ongoing_requests=max_ongoing_requests,
|
||||
)
|
||||
class Downstream:
|
||||
def __init__(self):
|
||||
# Turn off access log.
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
@serve.batch(max_batch_size=max_batch_size)
|
||||
async def batch(self, reqs):
|
||||
return [b"ok"] * len(reqs)
|
||||
|
||||
async def __call__(self, req: Union[bytes, Request]):
|
||||
if max_batch_size > 1:
|
||||
return await self.batch(req)
|
||||
else:
|
||||
return b"ok"
|
||||
|
||||
if intermediate_handles:
|
||||
return Upstream.bind(Downstream.bind())
|
||||
else:
|
||||
return Downstream.bind()
|
||||
|
||||
|
||||
async def trial(
|
||||
intermediate_handles: bool,
|
||||
num_replicas: int,
|
||||
max_batch_size: int,
|
||||
max_ongoing_requests: int,
|
||||
data_size: str,
|
||||
) -> Dict[str, float]:
|
||||
results = {}
|
||||
|
||||
trial_key_base = (
|
||||
f"replica:{num_replicas}/batch_size:{max_batch_size}/"
|
||||
f"concurrent_queries:{max_ongoing_requests}/"
|
||||
f"data_size:{data_size}/intermediate_handle:{intermediate_handles}"
|
||||
)
|
||||
|
||||
print(
|
||||
f"intermediate_handles={intermediate_handles},"
|
||||
f"num_replicas={num_replicas},"
|
||||
f"max_batch_size={max_batch_size},"
|
||||
f"max_ongoing_requests={max_ongoing_requests},"
|
||||
f"data_size={data_size}"
|
||||
)
|
||||
|
||||
app = build_app(
|
||||
intermediate_handles, num_replicas, max_batch_size, max_ongoing_requests
|
||||
)
|
||||
serve.run(app)
|
||||
|
||||
if data_size == "small":
|
||||
data = None
|
||||
elif data_size == "large":
|
||||
data = b"a" * 1024 * 1024
|
||||
else:
|
||||
raise ValueError("data_size should be 'small' or 'large'.")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
|
||||
async def single_client():
|
||||
for _ in range(CALLS_PER_BATCH):
|
||||
await fetch(session, data)
|
||||
|
||||
single_client_avg_tps, single_client_std_tps = await run_throughput_benchmark(
|
||||
single_client,
|
||||
multiplier=CALLS_PER_BATCH,
|
||||
)
|
||||
print(
|
||||
"\t{} {} +- {} requests/s".format(
|
||||
"single client {} data".format(data_size),
|
||||
single_client_avg_tps,
|
||||
single_client_std_tps,
|
||||
)
|
||||
)
|
||||
key = f"num_client:1/{trial_key_base}"
|
||||
results[key] = single_client_avg_tps
|
||||
|
||||
clients = [Client.remote() for _ in range(NUM_CLIENTS)]
|
||||
ray.get([client.ready.remote() for client in clients])
|
||||
|
||||
async def many_clients():
|
||||
ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients])
|
||||
|
||||
multi_client_avg_tps, _ = await run_throughput_benchmark(
|
||||
many_clients,
|
||||
multiplier=CALLS_PER_BATCH * len(clients),
|
||||
)
|
||||
|
||||
results[f"num_client:{len(clients)}/{trial_key_base}"] = multi_client_avg_tps
|
||||
return results
|
||||
|
||||
|
||||
async def main():
|
||||
results = {}
|
||||
for intermediate_handles in [False, True]:
|
||||
for num_replicas in [1, 8]:
|
||||
for max_batch_size, max_ongoing_requests in [
|
||||
(1, 1),
|
||||
(1, 10000),
|
||||
(10000, 10000),
|
||||
]:
|
||||
# TODO(edoakes): large data causes broken pipe errors.
|
||||
for data_size in ["small"]:
|
||||
results.update(
|
||||
await trial(
|
||||
intermediate_handles,
|
||||
num_replicas,
|
||||
max_batch_size,
|
||||
max_ongoing_requests,
|
||||
data_size,
|
||||
)
|
||||
)
|
||||
|
||||
print("Results from all conditions:")
|
||||
pprint(results)
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init()
|
||||
serve.start()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(main())
|
||||
@@ -0,0 +1,294 @@
|
||||
# Runs some request ping to compare HTTP and gRPC performances in TPS and latency.
|
||||
# Note: this takes around 1 hour to run.
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from random import random
|
||||
from typing import Callable, Dict
|
||||
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from grpc import aio
|
||||
from starlette.requests import Request
|
||||
|
||||
import ray
|
||||
from ray import serve
|
||||
from ray.serve._private.common import RequestProtocol
|
||||
from ray.serve.config import gRPCOptions
|
||||
from ray.serve.generated import serve_pb2, serve_pb2_grpc
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
CALLS_PER_BATCH = 100
|
||||
DELTA = 10**-7
|
||||
|
||||
|
||||
async def get_query_tps(name: str, fn: Callable, multiplier: int = CALLS_PER_BATCH):
|
||||
"""Get query TPS.
|
||||
|
||||
Run the function for 0.5 seconds 10 times to calculate how many requests can
|
||||
be completed. And use those stats to calculate the mean and std of TPS.
|
||||
"""
|
||||
# warmup
|
||||
start = time.time()
|
||||
while time.time() - start < 0.1:
|
||||
await fn()
|
||||
# real run
|
||||
stats = []
|
||||
for _ in range(10):
|
||||
count = 0
|
||||
start = time.time()
|
||||
while time.time() - start < 0.5:
|
||||
await fn()
|
||||
count += 1
|
||||
end = time.time()
|
||||
stats.append(multiplier * count / (end - start))
|
||||
tps_mean = round(np.mean(stats), 2)
|
||||
tps_std = round(np.std(stats), 2)
|
||||
print(f"\t{name} {tps_mean} +- {tps_std} requests/s")
|
||||
|
||||
return tps_mean, tps_std
|
||||
|
||||
|
||||
async def get_query_latencies(name: str, fn: Callable):
|
||||
"""Get query latencies.
|
||||
|
||||
Take all the latencies from the function and calculate the mean and std.
|
||||
"""
|
||||
many_client_results = np.asarray(await fn())
|
||||
many_client_results.flatten()
|
||||
latency_ms_mean = round(np.mean(many_client_results) * 1000, 2)
|
||||
latency_ms_std = round(np.std(many_client_results) * 1000, 2)
|
||||
print(f"\t{name} {latency_ms_mean} +- {latency_ms_std} ms")
|
||||
|
||||
return latency_ms_mean, latency_ms_std
|
||||
|
||||
|
||||
async def fetch_http(session, data):
|
||||
data_json = {"nums": data}
|
||||
response = await session.get("http://localhost:8000/", json=data_json)
|
||||
response_text = await response.read()
|
||||
float(response_text.decode())
|
||||
|
||||
|
||||
async def fetch_grpc(stub, data):
|
||||
result = await stub.grpc_call(serve_pb2.RawData(nums=data))
|
||||
_ = result.output
|
||||
|
||||
|
||||
@ray.remote
|
||||
class HTTPClient:
|
||||
def ready(self):
|
||||
return "ok"
|
||||
|
||||
async def do_queries(self, num, data):
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for _ in range(num):
|
||||
await fetch_http(session, data)
|
||||
|
||||
async def time_queries(self, num, data):
|
||||
stats = []
|
||||
async with aiohttp.ClientSession() as session:
|
||||
for _ in range(num):
|
||||
start = time.time()
|
||||
await fetch_http(session, data)
|
||||
end = time.time()
|
||||
stats.append(end - start)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@ray.remote
|
||||
class gRPCClient:
|
||||
def __init__(self):
|
||||
channel = aio.insecure_channel("localhost:9000")
|
||||
self.stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel)
|
||||
|
||||
def ready(self):
|
||||
return "ok"
|
||||
|
||||
async def do_queries(self, num, data):
|
||||
for _ in range(num):
|
||||
await fetch_grpc(self.stub, data)
|
||||
|
||||
async def time_queries(self, num, data):
|
||||
stats = []
|
||||
for _ in range(num):
|
||||
start = time.time()
|
||||
await fetch_grpc(self.stub, data)
|
||||
end = time.time()
|
||||
stats.append(end - start)
|
||||
return stats
|
||||
|
||||
|
||||
def build_app(
|
||||
num_replicas: int,
|
||||
max_ongoing_requests: int,
|
||||
data_size: int,
|
||||
):
|
||||
@serve.deployment(max_ongoing_requests=1000)
|
||||
class DataPreprocessing:
|
||||
def __init__(self, handle: DeploymentHandle):
|
||||
self._handle = handle
|
||||
|
||||
# Turn off access log.
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
def normalize(self, raw: np.ndarray) -> np.ndarray:
|
||||
return (raw - np.min(raw)) / (np.max(raw) - np.min(raw) + DELTA)
|
||||
|
||||
async def __call__(self, req: Request):
|
||||
"""HTTP entrypoint.
|
||||
|
||||
It parses the request, normalize the data, and send to model for inference.
|
||||
"""
|
||||
body = json.loads(await req.body())
|
||||
raw = np.asarray(body["nums"])
|
||||
processed = self.normalize(raw)
|
||||
return await self._handle.remote(processed)
|
||||
|
||||
async def grpc_call(self, raq_data):
|
||||
"""gRPC entrypoint.
|
||||
|
||||
It parses the request, normalize the data, and send to model for inference.
|
||||
"""
|
||||
raw = np.asarray(raq_data.nums)
|
||||
processed = self.normalize(raw)
|
||||
output = await self._handle.remote(processed)
|
||||
return serve_pb2.ModelOutput(output=output)
|
||||
|
||||
async def call_with_string(self, raq_data):
|
||||
"""gRPC entrypoint."""
|
||||
return serve_pb2.ModelOutput(output=0)
|
||||
|
||||
@serve.deployment(
|
||||
num_replicas=num_replicas,
|
||||
max_ongoing_requests=max_ongoing_requests,
|
||||
)
|
||||
class ModelInference:
|
||||
def __init__(self):
|
||||
# Turn off access log.
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
self._model = np.random.randn(data_size, data_size)
|
||||
|
||||
async def __call__(self, processed: np.ndarray) -> float:
|
||||
# Run a dot product with a random matrix to simulate a model inference.
|
||||
model_output = np.dot(processed, self._model)
|
||||
return sum(model_output)
|
||||
|
||||
return DataPreprocessing.bind(ModelInference.bind())
|
||||
|
||||
|
||||
async def trial(
|
||||
num_replicas: int,
|
||||
max_ongoing_requests: int,
|
||||
data_size: int,
|
||||
num_clients: int,
|
||||
proxy: RequestProtocol,
|
||||
) -> Dict[str, float]:
|
||||
# Generate input data as array of random floats.
|
||||
data = [random() for _ in range(data_size)]
|
||||
|
||||
# Build and deploy the app.
|
||||
app = build_app(
|
||||
num_replicas=num_replicas,
|
||||
max_ongoing_requests=max_ongoing_requests,
|
||||
data_size=data_size,
|
||||
)
|
||||
serve.run(app)
|
||||
|
||||
# Start clients.
|
||||
if proxy == RequestProtocol.GRPC:
|
||||
clients = [gRPCClient.remote() for _ in range(num_clients)]
|
||||
elif proxy == RequestProtocol.HTTP:
|
||||
clients = [HTTPClient.remote() for _ in range(num_clients)]
|
||||
ray.get([client.ready.remote() for client in clients])
|
||||
|
||||
async def client_time_queries():
|
||||
return ray.get([a.time_queries.remote(CALLS_PER_BATCH, data) for a in clients])
|
||||
|
||||
async def client_do_queries():
|
||||
ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients])
|
||||
|
||||
trial_key_base = (
|
||||
f"proxy:{proxy}/"
|
||||
f"num_client:{num_clients}/"
|
||||
f"replica:{num_replicas}/"
|
||||
f"concurrent_queries:{max_ongoing_requests}/"
|
||||
f"data_size:{data_size}"
|
||||
)
|
||||
tps_mean, tps_sdt = await get_query_tps(
|
||||
trial_key_base,
|
||||
client_do_queries,
|
||||
)
|
||||
latency_ms_mean, latency_ms_std = await get_query_latencies(
|
||||
trial_key_base,
|
||||
client_time_queries,
|
||||
)
|
||||
|
||||
results = {
|
||||
"proxy": proxy.value,
|
||||
"num_client": num_clients,
|
||||
"replica": num_replicas,
|
||||
"concurrent_queries": max_ongoing_requests,
|
||||
"data_size": data_size,
|
||||
"tps_mean": tps_mean,
|
||||
"tps_sdt": tps_sdt,
|
||||
"latency_ms_mean": latency_ms_mean,
|
||||
"latency_ms_std": latency_ms_std,
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def main():
|
||||
start_time = time.time()
|
||||
results = []
|
||||
for num_replicas in [1, 8]:
|
||||
for max_ongoing_requests in [1, 10_000]:
|
||||
for data_size in [1, 100, 10_000]:
|
||||
for num_clients in [1, 8]:
|
||||
for proxy in [RequestProtocol.GRPC, RequestProtocol.HTTP]:
|
||||
results.append(
|
||||
await trial(
|
||||
num_replicas=num_replicas,
|
||||
max_ongoing_requests=max_ongoing_requests,
|
||||
data_size=data_size,
|
||||
num_clients=num_clients,
|
||||
proxy=proxy,
|
||||
)
|
||||
)
|
||||
|
||||
print(f"Total time: {time.time() - start_time}s")
|
||||
print("results", results)
|
||||
|
||||
df = pd.DataFrame.from_dict(results)
|
||||
df = df.sort_values(
|
||||
by=["proxy", "num_client", "replica", "concurrent_queries", "data_size"]
|
||||
)
|
||||
print("Results from all conditions:")
|
||||
# Print the results in with tab separated so we can copy into google sheets.
|
||||
for i in range(len(df.index)):
|
||||
row = list(df.iloc[i])
|
||||
print("\t".join(map(str, row)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init()
|
||||
|
||||
grpc_port = 9000
|
||||
grpc_servicer_functions = [
|
||||
"ray.serve.generated.serve_pb2_grpc."
|
||||
"add_RayServeBenchmarkServiceServicer_to_server",
|
||||
]
|
||||
serve.start(
|
||||
grpc_options=gRPCOptions(
|
||||
port=grpc_port,
|
||||
grpc_servicer_functions=grpc_servicer_functions,
|
||||
)
|
||||
)
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(main())
|
||||
@@ -0,0 +1,29 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
#
|
||||
# NOTE: PLEASE READ CAREFULLY BEFORE CHANGING
|
||||
#
|
||||
# Payloads in this module are purposefully extracted from benchmark file to force
|
||||
# Ray's cloudpickle behavior when it does NOT serialize the class definition itself
|
||||
# along with its payload (instead relying on it being imported)
|
||||
#
|
||||
|
||||
|
||||
class PayloadPydantic(BaseModel):
|
||||
text: Optional[str] = None
|
||||
floats: Optional[List[float]] = None
|
||||
ints: Optional[List[int]] = None
|
||||
ts: Optional[float] = None
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PayloadDataclass:
|
||||
text: Optional[str] = None
|
||||
floats: Optional[List[float]] = None
|
||||
ints: Optional[List[int]] = None
|
||||
ts: Optional[float] = None
|
||||
reason: Optional[str] = None
|
||||
@@ -0,0 +1,60 @@
|
||||
import grpc
|
||||
|
||||
from ray.serve._private.benchmarks.streaming._grpc import (
|
||||
test_server_pb2,
|
||||
test_server_pb2_grpc,
|
||||
)
|
||||
|
||||
|
||||
async def _async_list(async_iterator):
|
||||
items = []
|
||||
async for item in async_iterator:
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class TestGRPCServer(test_server_pb2_grpc.GRPCTestServerServicer):
|
||||
def __init__(self, tokens_per_request):
|
||||
self._tokens_per_request = tokens_per_request
|
||||
|
||||
async def Unary(self, request, context):
|
||||
if request.request_data == "error":
|
||||
await context.abort(
|
||||
code=grpc.StatusCode.INTERNAL,
|
||||
details="unary rpc error",
|
||||
)
|
||||
|
||||
return test_server_pb2.Response(response_data="OK")
|
||||
|
||||
async def ClientStreaming(self, request_iterator, context):
|
||||
data = await _async_list(request_iterator)
|
||||
|
||||
if data and data[0].request_data == "error":
|
||||
await context.abort(
|
||||
code=grpc.StatusCode.INTERNAL,
|
||||
details="client streaming rpc error",
|
||||
)
|
||||
|
||||
return test_server_pb2.Response(response_data="OK")
|
||||
|
||||
async def ServerStreaming(self, request, context):
|
||||
if request.request_data == "error":
|
||||
await context.abort(
|
||||
code=grpc.StatusCode.INTERNAL,
|
||||
details="OK",
|
||||
)
|
||||
|
||||
for i in range(self._tokens_per_request):
|
||||
yield test_server_pb2.Response(response_data="OK")
|
||||
|
||||
async def BidiStreaming(self, request_iterator, context):
|
||||
data = await _async_list(request_iterator)
|
||||
if data and data[0].request_data == "error":
|
||||
await context.abort(
|
||||
code=grpc.StatusCode.INTERNAL,
|
||||
details="bidi-streaming rpc error",
|
||||
)
|
||||
|
||||
for i in range(self._tokens_per_request):
|
||||
yield test_server_pb2.Response(response_data="OK")
|
||||
@@ -0,0 +1,16 @@
|
||||
syntax = "proto3";
|
||||
|
||||
message Request {
|
||||
string request_data = 2;
|
||||
}
|
||||
|
||||
message Response {
|
||||
string response_data = 2;
|
||||
}
|
||||
|
||||
service GRPCTestServer {
|
||||
rpc Unary(Request) returns (Response);
|
||||
rpc ClientStreaming(stream Request) returns (Response);
|
||||
rpc ServerStreaming(Request) returns (stream Response);
|
||||
rpc BidiStreaming(stream Request) returns (stream Response);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
||||
"""Client and server classes corresponding to protobuf-defined services."""
|
||||
import grpc
|
||||
|
||||
from ray.serve._private.benchmarks.streaming._grpc import (
|
||||
test_server_pb2 as backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2,
|
||||
)
|
||||
|
||||
|
||||
class GRPCTestServerStub(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def __init__(self, channel: grpc.Channel):
|
||||
"""Constructor.
|
||||
|
||||
Args:
|
||||
channel: A grpc.Channel.
|
||||
"""
|
||||
self.Unary = channel.unary_unary(
|
||||
"/GRPCTestServer/Unary",
|
||||
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
)
|
||||
self.ClientStreaming = channel.stream_unary(
|
||||
"/GRPCTestServer/ClientStreaming",
|
||||
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
)
|
||||
self.ServerStreaming = channel.unary_stream(
|
||||
"/GRPCTestServer/ServerStreaming",
|
||||
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
)
|
||||
self.BidiStreaming = channel.stream_stream(
|
||||
"/GRPCTestServer/BidiStreaming",
|
||||
request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
)
|
||||
|
||||
|
||||
class GRPCTestServerServicer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
def Unary(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def ClientStreaming(self, request_iterator, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def ServerStreaming(self, request, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
def BidiStreaming(self, request_iterator, context):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details("Method not implemented!")
|
||||
raise NotImplementedError("Method not implemented!")
|
||||
|
||||
|
||||
def add_GRPCTestServerServicer_to_server(servicer, server):
|
||||
rpc_method_handlers = {
|
||||
"Unary": grpc.unary_unary_rpc_method_handler(
|
||||
servicer.Unary,
|
||||
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
|
||||
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
|
||||
),
|
||||
"ClientStreaming": grpc.stream_unary_rpc_method_handler(
|
||||
servicer.ClientStreaming,
|
||||
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
|
||||
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
|
||||
),
|
||||
"ServerStreaming": grpc.unary_stream_rpc_method_handler(
|
||||
servicer.ServerStreaming,
|
||||
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
|
||||
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
|
||||
),
|
||||
"BidiStreaming": grpc.stream_stream_rpc_method_handler(
|
||||
servicer.BidiStreaming,
|
||||
request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString,
|
||||
response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString,
|
||||
),
|
||||
}
|
||||
generic_handler = grpc.method_handlers_generic_handler(
|
||||
"GRPCTestServer", rpc_method_handlers
|
||||
)
|
||||
server.add_generic_rpc_handlers((generic_handler,))
|
||||
|
||||
|
||||
# This class is part of an EXPERIMENTAL API.
|
||||
class GRPCTestServer(object):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
|
||||
@staticmethod
|
||||
def Unary(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_unary(
|
||||
request,
|
||||
target,
|
||||
"/GRPCTestServer/Unary",
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ClientStreaming(
|
||||
request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.stream_unary(
|
||||
request_iterator,
|
||||
target,
|
||||
"/GRPCTestServer/ClientStreaming",
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def ServerStreaming(
|
||||
request,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.unary_stream(
|
||||
request,
|
||||
target,
|
||||
"/GRPCTestServer/ServerStreaming",
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def BidiStreaming(
|
||||
request_iterator,
|
||||
target,
|
||||
options=(),
|
||||
channel_credentials=None,
|
||||
call_credentials=None,
|
||||
insecure=False,
|
||||
compression=None,
|
||||
wait_for_ready=None,
|
||||
timeout=None,
|
||||
metadata=None,
|
||||
):
|
||||
return grpc.experimental.stream_stream(
|
||||
request_iterator,
|
||||
target,
|
||||
"/GRPCTestServer/BidiStreaming",
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString,
|
||||
backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString,
|
||||
options,
|
||||
channel_credentials,
|
||||
insecure,
|
||||
call_credentials,
|
||||
compression,
|
||||
wait_for_ready,
|
||||
timeout,
|
||||
metadata,
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
import abc
|
||||
import asyncio
|
||||
import enum
|
||||
import logging
|
||||
import time
|
||||
from typing import Tuple, Union
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ray.actor import ActorHandle
|
||||
from ray.runtime_env import RuntimeEnv
|
||||
from ray.serve._private.benchmarks.common import Blackhole, run_throughput_benchmark
|
||||
from ray.serve._private.benchmarks.serialization.common import PayloadPydantic
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
GRPC_DEBUG_RUNTIME_ENV = RuntimeEnv(
|
||||
env_vars={"GRPC_TRACE": "http", "GRPC_VERBOSITY": "debug"},
|
||||
)
|
||||
|
||||
|
||||
class IOMode(enum.Enum):
|
||||
SYNC = "SYNC"
|
||||
ASYNC = "ASYNC"
|
||||
|
||||
|
||||
class Endpoint:
|
||||
def __init__(self, tokens_per_request: int):
|
||||
self._tokens_per_request = tokens_per_request
|
||||
# Switch off logging to minimize its impact
|
||||
logging.getLogger("ray").setLevel(logging.WARNING)
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
def stream(self):
|
||||
payload = PayloadPydantic(
|
||||
text="Test output",
|
||||
floats=[float(f) for f in range(1, 100)],
|
||||
ints=list(range(1, 100)),
|
||||
ts=time.time(),
|
||||
reason="Success!",
|
||||
)
|
||||
|
||||
for i in range(self._tokens_per_request):
|
||||
yield payload
|
||||
|
||||
async def aio_stream(self):
|
||||
payload = PayloadPydantic(
|
||||
text="Test output",
|
||||
floats=[float(f) for f in range(1, 100)],
|
||||
ints=list(range(1, 100)),
|
||||
ts=time.time(),
|
||||
reason="Success!",
|
||||
)
|
||||
|
||||
for i in range(self._tokens_per_request):
|
||||
yield payload
|
||||
|
||||
|
||||
class Caller(Blackhole):
|
||||
def __init__(
|
||||
self,
|
||||
downstream: Union[ActorHandle, DeploymentHandle],
|
||||
*,
|
||||
mode: IOMode,
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
):
|
||||
self._h = downstream
|
||||
self._mode = mode
|
||||
self._tokens_per_request = tokens_per_request
|
||||
self._batch_size = batch_size
|
||||
self._num_trials = num_trials
|
||||
self._trial_runtime = trial_runtime
|
||||
self._durations = []
|
||||
|
||||
# Switch off logging to minimize its impact
|
||||
logging.getLogger("ray").setLevel(logging.WARNING)
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
def _get_remote_method(self):
|
||||
if self._mode == IOMode.SYNC:
|
||||
return self._h.stream
|
||||
elif self._mode == IOMode.ASYNC:
|
||||
return self._h.aio_stream
|
||||
else:
|
||||
raise NotImplementedError(f"Streaming mode not supported ({self._mode})")
|
||||
|
||||
@abc.abstractmethod
|
||||
async def _consume_single_stream(self):
|
||||
pass
|
||||
|
||||
async def _do_single_batch(self):
|
||||
durations = await asyncio.gather(
|
||||
*[
|
||||
self._execute(self._consume_single_stream)
|
||||
for _ in range(self._batch_size)
|
||||
]
|
||||
)
|
||||
|
||||
self._durations.extend(durations)
|
||||
|
||||
async def _execute(self, fn):
|
||||
start = time.monotonic()
|
||||
await fn()
|
||||
dur_s = time.monotonic() - start
|
||||
return dur_s * 1000 # ms
|
||||
|
||||
async def run_benchmark(self) -> Tuple[float, float]:
|
||||
coro = run_throughput_benchmark(
|
||||
fn=self._do_single_batch,
|
||||
multiplier=self._batch_size * self._tokens_per_request,
|
||||
num_trials=self._num_trials,
|
||||
trial_runtime=self._trial_runtime,
|
||||
)
|
||||
# total_runtime = await collect_profile_events(coro)
|
||||
total_runtime = await coro
|
||||
|
||||
p50, p75, p99 = np.percentile(self._durations, [50, 75, 99])
|
||||
|
||||
print(f"Individual request quantiles:\n\tP50={p50}\n\tP75={p75}\n\tP99={p99}")
|
||||
|
||||
return total_runtime
|
||||
@@ -0,0 +1,95 @@
|
||||
import click
|
||||
|
||||
import ray
|
||||
from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode
|
||||
|
||||
|
||||
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
|
||||
@ray.remote
|
||||
class EndpointActor(Endpoint):
|
||||
pass
|
||||
|
||||
|
||||
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
|
||||
@ray.remote
|
||||
class CallerActor(Caller):
|
||||
async def _consume_single_stream(self):
|
||||
method = self._get_remote_method()
|
||||
async for ref in method.options(num_returns="streaming").remote():
|
||||
r = ray.get(ref)
|
||||
|
||||
# self.sink(str(r, 'utf-8'))
|
||||
self.sink(r)
|
||||
|
||||
|
||||
@click.command(help="Benchmark streaming deployment handle throughput.")
|
||||
@click.option(
|
||||
"--tokens-per-request",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of tokens (per request) to stream from downstream deployment",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of requests to send to downstream deployment in each batch.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-replicas",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of replicas in the downstream deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-trials",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of trials of the benchmark to run.",
|
||||
)
|
||||
@click.option(
|
||||
"--trial-runtime",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Duration to run each trial of the benchmark for (seconds).",
|
||||
)
|
||||
@click.option(
|
||||
"--io-mode",
|
||||
type=str,
|
||||
default="async",
|
||||
help="Controls mode of the streaming generation (either 'sync' or 'async')",
|
||||
)
|
||||
def main(
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_replicas: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
io_mode: str,
|
||||
):
|
||||
h = CallerActor.remote(
|
||||
EndpointActor.remote(
|
||||
tokens_per_request=tokens_per_request,
|
||||
),
|
||||
mode=IOMode(io_mode.upper()),
|
||||
tokens_per_request=tokens_per_request,
|
||||
batch_size=batch_size,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
|
||||
mean, stddev = ray.get(h.run_benchmark.remote())
|
||||
print(
|
||||
"Core Actors streaming throughput ({}) {}: {} +- {} tokens/s".format(
|
||||
io_mode.upper(),
|
||||
f"(num_replicas={num_replicas}, "
|
||||
f"tokens_per_request={tokens_per_request}, "
|
||||
f"batch_size={batch_size})",
|
||||
mean,
|
||||
stddev,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,218 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from concurrent import futures
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import click
|
||||
import grpc
|
||||
|
||||
import ray
|
||||
from ray.serve._private.benchmarks.streaming._grpc import (
|
||||
test_server_pb2,
|
||||
test_server_pb2_grpc,
|
||||
)
|
||||
from ray.serve._private.benchmarks.streaming._grpc.grpc_server import TestGRPCServer
|
||||
from ray.serve._private.benchmarks.streaming.common import Caller, IOMode
|
||||
|
||||
|
||||
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
|
||||
@ray.remote
|
||||
class EndpointActor:
|
||||
async def __init__(self, tokens_per_request, socket_type, tempdir):
|
||||
# Switch off logging to minimize its impact
|
||||
logging.getLogger("ray").setLevel(logging.WARNING)
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
self.server = await self.start_server(tokens_per_request, socket_type, tempdir)
|
||||
|
||||
print("gRPC server started!")
|
||||
|
||||
@staticmethod
|
||||
async def start_server(tokens_per_request, socket_type, tempdir):
|
||||
server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=1))
|
||||
|
||||
addr, server_creds, _ = _gen_addr_creds(socket_type, tempdir)
|
||||
|
||||
server.add_secure_port(addr, server_creds)
|
||||
|
||||
await server.start()
|
||||
|
||||
test_server_pb2_grpc.add_GRPCTestServerServicer_to_server(
|
||||
TestGRPCServer(tokens_per_request), server
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV)
|
||||
@ray.remote
|
||||
class GrpcCallerActor(Caller):
|
||||
def __init__(
|
||||
self,
|
||||
tempdir,
|
||||
socket_type,
|
||||
*,
|
||||
mode: IOMode,
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
):
|
||||
super().__init__(
|
||||
self.create_downstream(socket_type, tempdir),
|
||||
mode=mode,
|
||||
tokens_per_request=tokens_per_request,
|
||||
batch_size=batch_size,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_downstream(socket_type, tempdir):
|
||||
addr, _, channel_creds = _gen_addr_creds(socket_type, tempdir)
|
||||
|
||||
channel = grpc.aio.secure_channel(
|
||||
addr, credentials=channel_creds, interceptors=[]
|
||||
)
|
||||
|
||||
return test_server_pb2_grpc.GRPCTestServerStub(channel)
|
||||
|
||||
async def _consume_single_stream(self):
|
||||
try:
|
||||
async for r in self._h.ServerStreaming(test_server_pb2.Request()):
|
||||
self.sink(r)
|
||||
except Exception as e:
|
||||
print(str(e))
|
||||
|
||||
|
||||
def _gen_addr_creds(socket_type, tempdir):
|
||||
if socket_type == "uds":
|
||||
addr = f"unix://{tempdir}/server.sock"
|
||||
server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.UDS)
|
||||
channel_creds = grpc.local_channel_credentials(grpc.LocalConnectionType.UDS)
|
||||
elif socket_type == "local_tcp":
|
||||
addr = "127.0.0.1:5432"
|
||||
server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.LOCAL_TCP)
|
||||
channel_creds = grpc.local_channel_credentials(
|
||||
grpc.LocalConnectionType.LOCAL_TCP
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"Not supported socket type ({socket_type})")
|
||||
|
||||
return addr, server_creds, channel_creds
|
||||
|
||||
|
||||
async def run_grpc_benchmark(
|
||||
batch_size,
|
||||
io_mode,
|
||||
socket_type,
|
||||
num_replicas,
|
||||
num_trials,
|
||||
tokens_per_request,
|
||||
trial_runtime,
|
||||
):
|
||||
with TemporaryDirectory() as tempdir:
|
||||
_ = EndpointActor.remote(
|
||||
tokens_per_request=tokens_per_request,
|
||||
socket_type=socket_type,
|
||||
tempdir=tempdir,
|
||||
)
|
||||
|
||||
ca = GrpcCallerActor.remote(
|
||||
tempdir,
|
||||
socket_type,
|
||||
mode=IOMode(io_mode.upper()),
|
||||
tokens_per_request=tokens_per_request,
|
||||
batch_size=batch_size,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
|
||||
# TODO make starting server a method (to make synchronization explicit)
|
||||
time.sleep(5)
|
||||
|
||||
mean, stddev = await ca.run_benchmark.remote()
|
||||
|
||||
print(
|
||||
"gRPC streaming throughput ({}) {}: {} +- {} tokens/s".format(
|
||||
io_mode.upper(),
|
||||
f"(num_replicas={num_replicas}, "
|
||||
f"tokens_per_request={tokens_per_request}, "
|
||||
f"batch_size={batch_size})",
|
||||
mean,
|
||||
stddev,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command(help="Benchmark streaming deployment handle throughput.")
|
||||
@click.option(
|
||||
"--tokens-per-request",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of tokens (per request) to stream from downstream deployment",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of requests to send to downstream deployment in each batch.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-replicas",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of replicas in the downstream deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-trials",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of trials of the benchmark to run.",
|
||||
)
|
||||
@click.option(
|
||||
"--trial-runtime",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Duration to run each trial of the benchmark for (seconds).",
|
||||
)
|
||||
@click.option(
|
||||
"--io-mode",
|
||||
type=str,
|
||||
default="async",
|
||||
help="Controls mode of the streaming generation (either 'sync' or 'async')",
|
||||
)
|
||||
@click.option(
|
||||
"--socket-type",
|
||||
type=str,
|
||||
default="local_tcp",
|
||||
help="Controls type of socket used as underlying transport (either 'uds' or "
|
||||
"'local_tcp')",
|
||||
)
|
||||
def main(
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_replicas: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
io_mode: str,
|
||||
socket_type: grpc.LocalConnectionType,
|
||||
):
|
||||
"""Reference benchmark for vanilla Python (w/ C-based core) gRPC implementation"""
|
||||
|
||||
asyncio.run(
|
||||
run_grpc_benchmark(
|
||||
batch_size,
|
||||
io_mode,
|
||||
socket_type,
|
||||
num_replicas,
|
||||
num_trials,
|
||||
tokens_per_request,
|
||||
trial_runtime,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
import click
|
||||
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode
|
||||
|
||||
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
||||
class EndpointDeployment(Endpoint):
|
||||
pass
|
||||
|
||||
|
||||
@serve.deployment
|
||||
class CallerDeployment(Caller):
|
||||
async def _consume_single_stream(self):
|
||||
method = self._get_remote_method().options(
|
||||
stream=True,
|
||||
)
|
||||
|
||||
async for r in method.remote():
|
||||
# Blackhole the response
|
||||
# self.sink(str(r, 'utf-8'))
|
||||
self.sink(r)
|
||||
|
||||
|
||||
@click.command(help="Benchmark streaming deployment handle throughput.")
|
||||
@click.option(
|
||||
"--tokens-per-request",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of requests to send to downstream deployment in each trial.",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of requests to send to downstream deployment in each trial.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-replicas",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of replicas in the downstream deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-trials",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of trials of the benchmark to run.",
|
||||
)
|
||||
@click.option(
|
||||
"--trial-runtime",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Duration to run each trial of the benchmark for (seconds).",
|
||||
)
|
||||
@click.option(
|
||||
"--io-mode",
|
||||
type=str,
|
||||
default="async",
|
||||
help="Controls mode of the streaming generation (either 'sync' or 'async')",
|
||||
)
|
||||
def main(
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_replicas: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
io_mode: str,
|
||||
):
|
||||
app = CallerDeployment.bind(
|
||||
EndpointDeployment.options(num_replicas=num_replicas).bind(tokens_per_request),
|
||||
mode=IOMode(io_mode.upper()),
|
||||
tokens_per_request=tokens_per_request,
|
||||
batch_size=batch_size,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
h = serve.run(app)
|
||||
|
||||
mean, stddev = h.run_benchmark.remote().result()
|
||||
print(
|
||||
"DeploymentHandle streaming throughput ({}) {}: {} +- {} tokens/s".format(
|
||||
io_mode.upper(),
|
||||
f"(num_replicas={num_replicas}, "
|
||||
f"tokens_per_request={tokens_per_request}, "
|
||||
f"batch_size={batch_size})",
|
||||
mean,
|
||||
stddev,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
import aiohttp
|
||||
import click
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from ray import serve
|
||||
from ray.serve._private.benchmarks.common import run_throughput_benchmark
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
||||
class Downstream:
|
||||
def __init__(self, tokens_per_request: int):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
self._tokens_per_request = tokens_per_request
|
||||
|
||||
async def stream(self):
|
||||
for i in range(self._tokens_per_request):
|
||||
yield "hi"
|
||||
|
||||
def __call__(self, *args):
|
||||
return StreamingResponse(self.stream())
|
||||
|
||||
|
||||
@serve.deployment(ray_actor_options={"num_cpus": 0})
|
||||
class Intermediate:
|
||||
def __init__(self, downstream: DeploymentHandle):
|
||||
logging.getLogger("ray.serve").setLevel(logging.WARNING)
|
||||
|
||||
self._h = downstream.options(stream=True)
|
||||
|
||||
async def stream(self):
|
||||
async for token in self._h.stream.remote():
|
||||
yield token
|
||||
|
||||
def __call__(self, *args):
|
||||
return StreamingResponse(self.stream())
|
||||
|
||||
|
||||
async def _consume_single_stream():
|
||||
async with aiohttp.ClientSession(raise_for_status=True) as session:
|
||||
async with session.get("http://localhost:8000") as r:
|
||||
async for line in r.content:
|
||||
pass
|
||||
|
||||
|
||||
async def run_benchmark(
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
) -> Tuple[float, float]:
|
||||
async def _do_single_batch():
|
||||
await asyncio.gather(*[_consume_single_stream() for _ in range(batch_size)])
|
||||
|
||||
return await run_throughput_benchmark(
|
||||
fn=_do_single_batch,
|
||||
multiplier=batch_size * tokens_per_request,
|
||||
num_trials=num_trials,
|
||||
trial_runtime=trial_runtime,
|
||||
)
|
||||
|
||||
|
||||
@click.command(help="Benchmark streaming HTTP throughput.")
|
||||
@click.option(
|
||||
"--tokens-per-request",
|
||||
type=int,
|
||||
default=1000,
|
||||
help="Number of requests to send to downstream deployment in each trial.",
|
||||
)
|
||||
@click.option(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of requests to send to downstream deployment in each trial.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-replicas",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of replicas in the downstream deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--num-trials",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of trials of the benchmark to run.",
|
||||
)
|
||||
@click.option(
|
||||
"--trial-runtime",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Duration to run each trial of the benchmark for (seconds).",
|
||||
)
|
||||
@click.option(
|
||||
"--use-intermediate-deployment",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Whether to run an intermediate deployment proxying the requests.",
|
||||
)
|
||||
def main(
|
||||
tokens_per_request: int,
|
||||
batch_size: int,
|
||||
num_replicas: int,
|
||||
num_trials: int,
|
||||
trial_runtime: float,
|
||||
use_intermediate_deployment: bool,
|
||||
):
|
||||
app = Downstream.options(num_replicas=num_replicas).bind(tokens_per_request)
|
||||
if use_intermediate_deployment:
|
||||
app = Intermediate.bind(app)
|
||||
|
||||
serve.run(app)
|
||||
|
||||
mean, stddev = asyncio.new_event_loop().run_until_complete(
|
||||
run_benchmark(
|
||||
tokens_per_request,
|
||||
batch_size,
|
||||
num_trials,
|
||||
trial_runtime,
|
||||
)
|
||||
)
|
||||
print(
|
||||
"HTTP streaming throughput {}: {} +- {} tokens/s".format(
|
||||
f"(num_replicas={num_replicas}, "
|
||||
f"tokens_per_request={tokens_per_request}, "
|
||||
f"batch_size={batch_size}, "
|
||||
f"use_intermediate_deployment={use_intermediate_deployment})",
|
||||
mean,
|
||||
stddev,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,285 @@
|
||||
# This module provides broker clients for querying queue lengths from message brokers.
|
||||
# Adapted from Flower's broker.py (https://github.com/mher/flower/blob/master/flower/utils/broker.py)
|
||||
# with the following modification:
|
||||
# - Added close() method to BrokerBase and RedisBase for resource cleanup
|
||||
|
||||
import json
|
||||
import logging
|
||||
import numbers
|
||||
import socket
|
||||
from urllib.parse import quote, unquote, urljoin, urlparse
|
||||
|
||||
from tornado import httpclient, ioloop
|
||||
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
|
||||
try:
|
||||
import redis
|
||||
except ImportError:
|
||||
redis = None
|
||||
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class BrokerBase:
|
||||
def __init__(self, broker_url, *_, **__):
|
||||
purl = urlparse(broker_url)
|
||||
self.host = purl.hostname
|
||||
self.port = purl.port
|
||||
self.vhost = purl.path[1:]
|
||||
|
||||
username = purl.username
|
||||
password = purl.password
|
||||
|
||||
self.username = unquote(username) if username else username
|
||||
self.password = unquote(password) if password else password
|
||||
|
||||
async def queues(self, names):
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self):
|
||||
"""Close any open connections. Override in subclasses as needed."""
|
||||
pass
|
||||
|
||||
|
||||
class RabbitMQ(BrokerBase):
|
||||
def __init__(self, broker_url, http_api, io_loop=None, **__):
|
||||
super().__init__(broker_url)
|
||||
self.io_loop = io_loop or ioloop.IOLoop.instance()
|
||||
|
||||
self.host = self.host or "localhost"
|
||||
self.port = self.port or 15672
|
||||
self.vhost = quote(self.vhost, "") or "/" if self.vhost != "/" else self.vhost
|
||||
self.username = self.username or "guest"
|
||||
self.password = self.password or "guest"
|
||||
|
||||
if not http_api:
|
||||
http_api = f"http://{self.username}:{self.password}@{self.host}:{self.port}/api/{self.vhost}"
|
||||
|
||||
try:
|
||||
self.validate_http_api(http_api)
|
||||
except ValueError:
|
||||
logger.error("Invalid broker api url: %s", http_api)
|
||||
|
||||
self.http_api = http_api
|
||||
|
||||
async def queues(self, names):
|
||||
url = urljoin(self.http_api, "queues/" + self.vhost)
|
||||
api_url = urlparse(self.http_api)
|
||||
username = unquote(api_url.username or "") or self.username
|
||||
password = unquote(api_url.password or "") or self.password
|
||||
|
||||
http_client = httpclient.AsyncHTTPClient()
|
||||
try:
|
||||
response = await http_client.fetch(
|
||||
url,
|
||||
auth_username=username,
|
||||
auth_password=password,
|
||||
connect_timeout=1.0,
|
||||
request_timeout=2.0,
|
||||
validate_cert=False,
|
||||
)
|
||||
except (socket.error, httpclient.HTTPError) as e:
|
||||
logger.error("RabbitMQ management API call failed: %s", e)
|
||||
return []
|
||||
finally:
|
||||
http_client.close()
|
||||
|
||||
if response.code == 200:
|
||||
info = json.loads(response.body.decode())
|
||||
return [x for x in info if x["name"] in names]
|
||||
response.rethrow()
|
||||
|
||||
@classmethod
|
||||
def validate_http_api(cls, http_api):
|
||||
url = urlparse(http_api)
|
||||
if url.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Invalid http api schema: {url.scheme}")
|
||||
|
||||
|
||||
class RedisBase(BrokerBase):
|
||||
DEFAULT_SEP = "\x06\x16"
|
||||
DEFAULT_PRIORITY_STEPS = [0, 3, 6, 9]
|
||||
|
||||
def __init__(self, broker_url, *_, **kwargs):
|
||||
super().__init__(broker_url)
|
||||
self.redis = None
|
||||
|
||||
if not redis:
|
||||
raise ImportError("redis library is required")
|
||||
|
||||
broker_options = kwargs.get("broker_options", {})
|
||||
self.priority_steps = broker_options.get(
|
||||
"priority_steps", self.DEFAULT_PRIORITY_STEPS
|
||||
)
|
||||
self.sep = broker_options.get("sep", self.DEFAULT_SEP)
|
||||
self.broker_prefix = broker_options.get("global_keyprefix", "")
|
||||
|
||||
def _q_for_pri(self, queue, pri):
|
||||
if pri not in self.priority_steps:
|
||||
raise ValueError("Priority not in priority steps")
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "{0}{1}{2}".format(*((queue, self.sep, pri) if pri else (queue, "", "")))
|
||||
|
||||
async def queues(self, names):
|
||||
queue_stats = []
|
||||
for name in names:
|
||||
priority_names = [
|
||||
self.broker_prefix + self._q_for_pri(name, pri)
|
||||
for pri in self.priority_steps
|
||||
]
|
||||
queue_stats.append(
|
||||
{
|
||||
"name": name,
|
||||
"messages": sum((self.redis.llen(x) for x in priority_names)),
|
||||
}
|
||||
)
|
||||
return queue_stats
|
||||
|
||||
def close(self):
|
||||
"""Close the Redis connection."""
|
||||
if self.redis is not None:
|
||||
self.redis.close()
|
||||
self.redis = None
|
||||
|
||||
|
||||
class Redis(RedisBase):
|
||||
def __init__(self, broker_url, *args, **kwargs):
|
||||
super().__init__(broker_url, *args, **kwargs)
|
||||
self.host = self.host or "localhost"
|
||||
self.port = self.port or 6379
|
||||
self.vhost = self._prepare_virtual_host(self.vhost)
|
||||
self.redis = self._get_redis_client()
|
||||
|
||||
def _prepare_virtual_host(self, vhost):
|
||||
if not isinstance(vhost, numbers.Integral):
|
||||
if not vhost or vhost == "/":
|
||||
vhost = 0
|
||||
elif vhost.startswith("/"):
|
||||
vhost = vhost[1:]
|
||||
try:
|
||||
vhost = int(vhost)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Database is int between 0 and limit - 1, not {vhost}"
|
||||
) from exc
|
||||
return vhost
|
||||
|
||||
def _get_redis_client_args(self):
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"db": self.vhost,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
def _get_redis_client(self):
|
||||
return redis.Redis(**self._get_redis_client_args())
|
||||
|
||||
|
||||
class RedisSentinel(RedisBase):
|
||||
def __init__(self, broker_url, *args, **kwargs):
|
||||
super().__init__(broker_url, *args, **kwargs)
|
||||
broker_options = kwargs.get("broker_options", {})
|
||||
broker_use_ssl = kwargs.get("broker_use_ssl", None)
|
||||
self.host = self.host or "localhost"
|
||||
self.port = self.port or 26379
|
||||
self.vhost = self._prepare_virtual_host(self.vhost)
|
||||
self.master_name = self._prepare_master_name(broker_options)
|
||||
self.redis = self._get_redis_client(broker_options, broker_use_ssl)
|
||||
|
||||
def _prepare_virtual_host(self, vhost):
|
||||
if not isinstance(vhost, numbers.Integral):
|
||||
if not vhost or vhost == "/":
|
||||
vhost = 0
|
||||
elif vhost.startswith("/"):
|
||||
vhost = vhost[1:]
|
||||
try:
|
||||
vhost = int(vhost)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Database is int between 0 and limit - 1, not {vhost}"
|
||||
) from exc
|
||||
return vhost
|
||||
|
||||
def _prepare_master_name(self, broker_options):
|
||||
try:
|
||||
master_name = broker_options["master_name"]
|
||||
except KeyError as exc:
|
||||
raise ValueError("master_name is required for Sentinel broker") from exc
|
||||
return master_name
|
||||
|
||||
def _get_redis_client(self, broker_options, broker_use_ssl):
|
||||
connection_kwargs = {
|
||||
"password": self.password,
|
||||
"sentinel_kwargs": broker_options.get("sentinel_kwargs"),
|
||||
}
|
||||
if isinstance(broker_use_ssl, dict):
|
||||
connection_kwargs["ssl"] = True
|
||||
connection_kwargs.update(broker_use_ssl)
|
||||
# get all sentinel hosts from Celery App config and use them to initialize Sentinel
|
||||
sentinel = redis.sentinel.Sentinel(
|
||||
[(self.host, self.port)], **connection_kwargs
|
||||
)
|
||||
redis_client = sentinel.master_for(self.master_name)
|
||||
return redis_client
|
||||
|
||||
|
||||
class RedisSocket(RedisBase):
|
||||
def __init__(self, broker_url, *args, **kwargs):
|
||||
super().__init__(broker_url, *args, **kwargs)
|
||||
self.redis = redis.Redis(
|
||||
unix_socket_path="/" + self.vhost, password=self.password
|
||||
)
|
||||
|
||||
|
||||
class RedisSsl(Redis):
|
||||
"""
|
||||
Redis SSL class offering connection to the broker over SSL.
|
||||
This does not currently support SSL settings through the url, only through
|
||||
the broker_use_ssl celery configuration.
|
||||
"""
|
||||
|
||||
def __init__(self, broker_url, *args, **kwargs):
|
||||
if "broker_use_ssl" not in kwargs:
|
||||
raise ValueError("rediss broker requires broker_use_ssl")
|
||||
self.broker_use_ssl = kwargs.get("broker_use_ssl", {})
|
||||
super().__init__(broker_url, *args, **kwargs)
|
||||
|
||||
def _get_redis_client_args(self):
|
||||
client_args = super()._get_redis_client_args()
|
||||
client_args["ssl"] = True
|
||||
if isinstance(self.broker_use_ssl, dict):
|
||||
client_args.update(self.broker_use_ssl)
|
||||
return client_args
|
||||
|
||||
|
||||
class Broker:
|
||||
"""Factory returning the appropriate broker client based on URL scheme.
|
||||
|
||||
Supported schemes:
|
||||
``amqp`` or ``amqps`` -> :class:`RabbitMQ`
|
||||
``redis`` -> :class:`Redis`
|
||||
``rediss`` -> :class:`RedisSsl`
|
||||
``redis+socket`` -> :class:`RedisSocket`
|
||||
``sentinel`` -> :class:`RedisSentinel`
|
||||
"""
|
||||
|
||||
def __new__(cls, broker_url, *args, **kwargs):
|
||||
scheme = urlparse(broker_url).scheme
|
||||
if scheme in ("amqp", "amqps"):
|
||||
return RabbitMQ(broker_url, *args, **kwargs)
|
||||
if scheme == "redis":
|
||||
return Redis(broker_url, *args, **kwargs)
|
||||
if scheme == "rediss":
|
||||
return RedisSsl(broker_url, *args, **kwargs)
|
||||
if scheme == "redis+socket":
|
||||
return RedisSocket(broker_url, *args, **kwargs)
|
||||
if scheme == "sentinel":
|
||||
return RedisSentinel(broker_url, *args, **kwargs)
|
||||
raise NotImplementedError
|
||||
|
||||
async def queues(self, names):
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,340 @@
|
||||
import inspect
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union
|
||||
|
||||
from ray.dag.py_obj_scanner import _PyObjScanner
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_ENABLE_HA_PROXY,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.http_util import ASGIAppReplicaWrapper
|
||||
from ray.serve.deployment import Application, Deployment
|
||||
from ray.serve.exceptions import RayServeException
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
from ray.serve.schema import LoggingConfig
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
INGRESS_REQUEST_ROUTER_REQUIRES_HAPROXY_ERROR = (
|
||||
"`ingress_request_router` requires HAProxy. "
|
||||
"Set `RAY_SERVE_ENABLE_HA_PROXY=1` in the Ray controller's environment."
|
||||
)
|
||||
|
||||
CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR = (
|
||||
"A custom `request_router_config.request_router_class` is not supported on "
|
||||
"the ingress deployment when HAProxy is enabled. HAProxy load-balances "
|
||||
"ingress traffic with its own algorithm and bypasses the Serve request "
|
||||
"router, so the custom router would be silently ignored. Remove the custom "
|
||||
"`request_router_class` from the ingress deployment, or configure HAProxy's "
|
||||
"load-balancing algorithm instead."
|
||||
)
|
||||
|
||||
|
||||
class IDDict(dict, Generic[K, V]):
|
||||
"""Dictionary that uses id() for keys instead of hash().
|
||||
|
||||
This is necessary because Application objects aren't hashable and we want each
|
||||
instance to map to a unique key.
|
||||
"""
|
||||
|
||||
def __getitem__(self, key: K) -> V:
|
||||
if not isinstance(key, int):
|
||||
key = id(key)
|
||||
return super().__getitem__(key)
|
||||
|
||||
def __setitem__(self, key: K, value: V):
|
||||
if not isinstance(key, int):
|
||||
key = id(key)
|
||||
return super().__setitem__(key, value)
|
||||
|
||||
def __delitem__(self, key: K):
|
||||
if not isinstance(key, int):
|
||||
key = id(key)
|
||||
return super().__delitem__(key)
|
||||
|
||||
def __contains__(self, key: object):
|
||||
if not isinstance(key, int):
|
||||
key = id(key)
|
||||
return super().__contains__(key)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuiltApplication:
|
||||
# Name of the application.
|
||||
name: str
|
||||
route_prefix: Optional[str]
|
||||
logging_config: Optional[LoggingConfig]
|
||||
# Name of the application's 'ingress' deployment
|
||||
# (the one exposed over gRPC/HTTP/handle).
|
||||
ingress_deployment_name: str
|
||||
# List of unique deployments comprising the app.
|
||||
deployments: List[Deployment]
|
||||
# Dict[name, DeploymentHandle] mapping deployment names to the handles that replaced
|
||||
# them in other deployments' init args/kwargs.
|
||||
deployment_handles: Dict[str, DeploymentHandle]
|
||||
external_scaler_enabled: bool
|
||||
# Optional ingress request router deployment for ingress bypass mode.
|
||||
# When set, this deployment serves /internal/route for HAProxy Lua routing.
|
||||
ingress_request_router_deployment: Optional[Deployment] = None
|
||||
|
||||
def validate_single_fastapi_ingress(self) -> None:
|
||||
"""Validate that the application has at most one FastAPI ingress."""
|
||||
num_ingress_deployments = sum(
|
||||
inspect.isclass(deployment.func_or_class)
|
||||
and issubclass(deployment.func_or_class, ASGIAppReplicaWrapper)
|
||||
for deployment in self.deployments
|
||||
)
|
||||
if num_ingress_deployments > 1:
|
||||
raise RayServeException(
|
||||
f'Found multiple FastAPI deployments in application "{self.name}". '
|
||||
"Please only include one deployment with @serve.ingress "
|
||||
"in your application to avoid this issue."
|
||||
)
|
||||
|
||||
|
||||
def _has_custom_request_router(deployment: Deployment) -> bool:
|
||||
"""Whether the deployment configures a non-default request router class."""
|
||||
request_router_config = deployment._deployment_config.request_router_config
|
||||
return not request_router_config.is_default_request_router()
|
||||
|
||||
|
||||
def _make_deployment_handle_default(
|
||||
deployment: Deployment, app_name: str
|
||||
) -> DeploymentHandle:
|
||||
return DeploymentHandle(
|
||||
deployment.name,
|
||||
app_name=app_name,
|
||||
)
|
||||
|
||||
|
||||
def build_app(
|
||||
app: Application,
|
||||
*,
|
||||
name: str,
|
||||
route_prefix: Optional[str] = None,
|
||||
logging_config: Optional[Union[Dict, LoggingConfig]] = None,
|
||||
default_runtime_env: Optional[Dict[str, Any]] = None,
|
||||
make_deployment_handle: Optional[
|
||||
Callable[[Deployment, str], DeploymentHandle]
|
||||
] = None,
|
||||
external_scaler_enabled: bool = False,
|
||||
) -> BuiltApplication:
|
||||
"""Builds the application into a list of finalized deployments.
|
||||
|
||||
The following transformations are made:
|
||||
- Application objects in constructor args/kwargs are converted to
|
||||
DeploymentHandles for injection at runtime.
|
||||
- Name conflicts from deployments that use the same class are handled
|
||||
by appending a monotonically increasing suffix (e.g., SomeClass_1).
|
||||
|
||||
Returns: BuiltApplication
|
||||
"""
|
||||
if make_deployment_handle is None:
|
||||
make_deployment_handle = _make_deployment_handle_default
|
||||
|
||||
ingress_request_router = app._ingress_request_router
|
||||
if ingress_request_router is not None and not isinstance(
|
||||
ingress_request_router, Application
|
||||
):
|
||||
raise TypeError(
|
||||
"`ingress_request_router` must be an `Application` returned by "
|
||||
"`Deployment.bind()`."
|
||||
)
|
||||
if ingress_request_router is not None and not RAY_SERVE_ENABLE_HA_PROXY:
|
||||
raise RayServeException(INGRESS_REQUEST_ROUTER_REQUIRES_HAPROXY_ERROR)
|
||||
|
||||
# Under HAProxy, ingress traffic is load-balanced by HAProxy and bypasses
|
||||
# the ingress deployment's Serve request router, so a custom router there is
|
||||
# silently ignored. Reject it unless an `ingress_request_router` is attached
|
||||
# (the Serve LLM direct-streaming path), where HAProxy delegates replica
|
||||
# selection back to that router.
|
||||
if (
|
||||
RAY_SERVE_ENABLE_HA_PROXY
|
||||
and ingress_request_router is None
|
||||
and _has_custom_request_router(app._bound_deployment)
|
||||
):
|
||||
raise RayServeException(CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR)
|
||||
|
||||
handles = IDDict()
|
||||
deployment_names = IDDict()
|
||||
deployments = _build_app_recursive(
|
||||
app,
|
||||
app_name=name,
|
||||
handles=handles,
|
||||
deployment_names=deployment_names,
|
||||
default_runtime_env=default_runtime_env,
|
||||
make_deployment_handle=make_deployment_handle,
|
||||
)
|
||||
ingress_request_router_deployment = None
|
||||
if ingress_request_router is not None:
|
||||
ingress_request_router_deployments = _build_app_recursive(
|
||||
ingress_request_router,
|
||||
app_name=name,
|
||||
handles=handles,
|
||||
deployment_names=deployment_names,
|
||||
default_runtime_env=default_runtime_env,
|
||||
make_deployment_handle=make_deployment_handle,
|
||||
)
|
||||
# TODO(eicherseiji): The current ingress-bypass design only supports a
|
||||
# standalone single-deployment router. Revisit this once routers can
|
||||
# compose helper deployments.
|
||||
if len(ingress_request_router_deployments) == 0:
|
||||
raise ValueError(
|
||||
"Expected `ingress_request_router` to build into one standalone "
|
||||
"deployment, but it did not produce any new deployments. This "
|
||||
"usually means the same bound router deployment is also reachable "
|
||||
"from the main application graph; attach it only as "
|
||||
"`ingress_request_router`."
|
||||
)
|
||||
if len(ingress_request_router_deployments) > 1:
|
||||
raise ValueError(
|
||||
"Expected `ingress_request_router` to build into exactly one "
|
||||
"standalone deployment, got "
|
||||
f"{len(ingress_request_router_deployments)}."
|
||||
)
|
||||
ingress_request_router_deployment = ingress_request_router_deployments[0]
|
||||
|
||||
main_deployment_names = {deployment.name for deployment in deployments}
|
||||
|
||||
return BuiltApplication(
|
||||
name=name,
|
||||
route_prefix=route_prefix,
|
||||
logging_config=logging_config,
|
||||
ingress_deployment_name=deployment_names[app],
|
||||
deployments=deployments,
|
||||
deployment_handles={
|
||||
deployment_names[app]: handle
|
||||
for app, handle in handles.items()
|
||||
if deployment_names[app] in main_deployment_names
|
||||
},
|
||||
external_scaler_enabled=external_scaler_enabled,
|
||||
ingress_request_router_deployment=ingress_request_router_deployment,
|
||||
)
|
||||
|
||||
|
||||
def _build_app_recursive(
|
||||
app: Application,
|
||||
*,
|
||||
app_name: str,
|
||||
deployment_names: IDDict[Application, str],
|
||||
handles: IDDict[Application, DeploymentHandle],
|
||||
default_runtime_env: Optional[Dict[str, Any]] = None,
|
||||
make_deployment_handle: Callable[[Deployment, str], DeploymentHandle],
|
||||
) -> List[Deployment]:
|
||||
"""Recursively traverses the graph of Application objects.
|
||||
|
||||
Each Application will have an associated DeploymentHandle created that will replace
|
||||
it in any occurrences in other Applications' args or kwargs.
|
||||
|
||||
Also collects a list of the unique Applications encountered and returns them as
|
||||
deployable Deployment objects.
|
||||
"""
|
||||
# This application has already been encountered.
|
||||
# There's no need to recurse into its child args and we don't want to create
|
||||
# a duplicate entry for it in the list of deployments.
|
||||
if app in handles:
|
||||
return []
|
||||
|
||||
deployments = []
|
||||
scanner = _PyObjScanner(source_type=Application)
|
||||
try:
|
||||
# Recursively traverse any Application objects bound to init args/kwargs.
|
||||
child_apps = scanner.find_nodes(
|
||||
(app._bound_deployment.init_args, app._bound_deployment.init_kwargs)
|
||||
)
|
||||
for child_app in child_apps:
|
||||
deployments.extend(
|
||||
_build_app_recursive(
|
||||
child_app,
|
||||
app_name=app_name,
|
||||
handles=handles,
|
||||
deployment_names=deployment_names,
|
||||
make_deployment_handle=make_deployment_handle,
|
||||
default_runtime_env=default_runtime_env,
|
||||
)
|
||||
)
|
||||
|
||||
# Replace Application objects with their corresponding DeploymentHandles.
|
||||
new_init_args, new_init_kwargs = scanner.replace_nodes(handles)
|
||||
final_deployment = app._bound_deployment.options(
|
||||
name=_get_unique_deployment_name_memoized(app, deployment_names),
|
||||
_init_args=new_init_args,
|
||||
_init_kwargs=new_init_kwargs,
|
||||
)
|
||||
final_deployment = _set_default_runtime_env(
|
||||
final_deployment, default_runtime_env
|
||||
)
|
||||
|
||||
# Create the DeploymentHandle that will be used to replace this application
|
||||
# in the arguments of its parent(s).
|
||||
handles[app] = make_deployment_handle(
|
||||
final_deployment,
|
||||
app_name,
|
||||
)
|
||||
|
||||
return deployments + [final_deployment]
|
||||
finally:
|
||||
scanner.clear()
|
||||
|
||||
|
||||
def _set_default_runtime_env(
|
||||
d: Deployment, default_runtime_env: Optional[Dict[str, Any]]
|
||||
) -> Deployment:
|
||||
"""Configures the deployment with the provided default runtime_env.
|
||||
|
||||
If the deployment does not have a runtime_env configured, the default will be set.
|
||||
|
||||
If it does have a runtime_env configured but that runtime_env does not have a
|
||||
working_dir, only the working_dir field will be set.
|
||||
|
||||
Else the deployment's runtime_env will be left untouched.
|
||||
"""
|
||||
if not default_runtime_env:
|
||||
return d
|
||||
|
||||
ray_actor_options = deepcopy(d.ray_actor_options or {})
|
||||
default_working_dir = default_runtime_env.get("working_dir", None)
|
||||
if "runtime_env" not in ray_actor_options:
|
||||
ray_actor_options["runtime_env"] = default_runtime_env
|
||||
elif default_working_dir is not None:
|
||||
ray_actor_options["runtime_env"].setdefault("working_dir", default_working_dir)
|
||||
|
||||
return d.options(ray_actor_options=ray_actor_options)
|
||||
|
||||
|
||||
def _get_unique_deployment_name_memoized(
|
||||
app: Application, deployment_names: IDDict[Application, str]
|
||||
) -> str:
|
||||
"""Generates a name for the deployment.
|
||||
|
||||
This is used to handle collisions when the user does not specify a name
|
||||
explicitly, so typically we'd use the class name as the default.
|
||||
|
||||
In that case, we append a monotonically increasing suffix to the name, e.g.,
|
||||
Deployment, then Deployment_1, then Deployment_2, ...
|
||||
|
||||
Names are memoized in the `deployment_names` dict, which should be passed to
|
||||
subsequent calls to this function.
|
||||
"""
|
||||
if app in deployment_names:
|
||||
return deployment_names[app]
|
||||
|
||||
idx = 1
|
||||
name = app._bound_deployment.name
|
||||
while name in deployment_names.values():
|
||||
name = f"{app._bound_deployment.name}_{idx}"
|
||||
idx += 1
|
||||
|
||||
if idx != 1:
|
||||
logger.warning(
|
||||
"There are multiple deployments with the same name "
|
||||
f"'{app._bound_deployment.name}'. Renaming one to '{name}'."
|
||||
)
|
||||
|
||||
deployment_names[app] = name
|
||||
return name
|
||||
@@ -0,0 +1,650 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from functools import wraps
|
||||
from typing import Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.serve._private.application_state import StatusOverview
|
||||
from ray.serve._private.build_app import BuiltApplication
|
||||
from ray.serve._private.common import (
|
||||
DeploymentID,
|
||||
DeploymentStatus,
|
||||
DeploymentStatusInfo,
|
||||
RequestRoutingInfo,
|
||||
)
|
||||
from ray.serve._private.constants import (
|
||||
CLIENT_CHECK_CREATION_POLLING_INTERVAL_S,
|
||||
CLIENT_POLLING_INTERVAL_S,
|
||||
HTTP_PROXY_TIMEOUT,
|
||||
MAX_CACHED_HANDLES,
|
||||
SERVE_DEFAULT_APP_NAME,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.controller import ServeController
|
||||
from ray.serve._private.deploy_utils import get_deploy_args
|
||||
from ray.serve._private.deployment_info import DeploymentInfo
|
||||
from ray.serve._private.utils import _callable_uses_multiplexing, get_random_string
|
||||
from ray.serve.config import HTTPOptions
|
||||
from ray.serve.exceptions import RayServeException
|
||||
from ray.serve.generated.serve_pb2 import (
|
||||
ApplicationArgs,
|
||||
DeploymentArgs,
|
||||
DeploymentRoute,
|
||||
DeploymentStatusInfo as DeploymentStatusInfoProto,
|
||||
StatusOverview as StatusOverviewProto,
|
||||
)
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
from ray.serve.schema import (
|
||||
ApplicationStatus,
|
||||
LoggingConfig,
|
||||
ServeApplicationSchema,
|
||||
ServeDeploySchema,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def _ensure_connected(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def check(self, *args, **kwargs):
|
||||
if self._shutdown:
|
||||
raise RayServeException("Client has already been shut down.")
|
||||
return f(self, *args, **kwargs)
|
||||
|
||||
return check
|
||||
|
||||
|
||||
class ServeControllerClient:
|
||||
def __init__(
|
||||
self,
|
||||
controller: ActorHandle,
|
||||
):
|
||||
self._controller: ServeController = controller
|
||||
self._shutdown = False
|
||||
self._http_config: HTTPOptions = ray.get(controller.get_http_config.remote())
|
||||
self._root_url = ray.get(controller.get_root_url.remote())
|
||||
|
||||
# Each handle has the overhead of long poll client, therefore cached.
|
||||
self.handle_cache = dict()
|
||||
self._evicted_handle_keys = set()
|
||||
|
||||
@property
|
||||
def root_url(self):
|
||||
return self._root_url
|
||||
|
||||
@property
|
||||
def http_config(self):
|
||||
return self._http_config
|
||||
|
||||
def __reduce__(self):
|
||||
raise RayServeException(("Ray Serve client cannot be serialized."))
|
||||
|
||||
def shutdown_cached_handles(self):
|
||||
"""Shuts down all cached handles.
|
||||
|
||||
Remove the reference to the cached handles so that they can be
|
||||
garbage collected.
|
||||
"""
|
||||
for cache_key in list(self.handle_cache):
|
||||
self.handle_cache[cache_key].shutdown()
|
||||
del self.handle_cache[cache_key]
|
||||
|
||||
async def shutdown_cached_handles_async(self):
|
||||
"""Shuts down all cached handles asynchronously.
|
||||
|
||||
Remove the reference to the cached handles so that they can be
|
||||
garbage collected.
|
||||
"""
|
||||
|
||||
async def shutdown_task(cache_key):
|
||||
await self.handle_cache[cache_key].shutdown_async()
|
||||
del self.handle_cache[cache_key]
|
||||
|
||||
await asyncio.gather(
|
||||
*[shutdown_task(cache_key) for cache_key in list(self.handle_cache)]
|
||||
)
|
||||
|
||||
def shutdown(self, timeout_s: float = 30.0) -> None:
|
||||
"""Completely shut down the connected Serve instance.
|
||||
|
||||
Shuts down all processes and deletes all state associated with the
|
||||
instance.
|
||||
"""
|
||||
self.shutdown_cached_handles()
|
||||
|
||||
if ray.is_initialized() and not self._shutdown:
|
||||
try:
|
||||
ray.get(self._controller.graceful_shutdown.remote(), timeout=timeout_s)
|
||||
except ray.exceptions.RayActorError:
|
||||
# Controller has been shut down.
|
||||
pass
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Controller failed to shut down within {timeout_s}s. "
|
||||
"Check controller logs for more details."
|
||||
)
|
||||
self._shutdown = True
|
||||
|
||||
async def shutdown_async(self, timeout_s: float = 30.0) -> None:
|
||||
"""Completely shut down the connected Serve instance.
|
||||
|
||||
Shuts down all processes and deletes all state associated with the
|
||||
instance.
|
||||
"""
|
||||
await self.shutdown_cached_handles_async()
|
||||
|
||||
if ray.is_initialized() and not self._shutdown:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._controller.graceful_shutdown.remote(), timeout=timeout_s
|
||||
)
|
||||
except ray.exceptions.RayActorError:
|
||||
# Controller has been shut down.
|
||||
pass
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"Controller failed to shut down within {timeout_s}s. "
|
||||
"Check controller logs for more details."
|
||||
)
|
||||
self._shutdown = True
|
||||
|
||||
def _wait_for_deployment_healthy(self, name: str, timeout_s: int = -1):
|
||||
"""Waits for the named deployment to enter "HEALTHY" status.
|
||||
|
||||
Raises RuntimeError if the deployment enters the "UNHEALTHY" status
|
||||
instead.
|
||||
|
||||
Raises TimeoutError if this doesn't happen before timeout_s.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s or timeout_s < 0:
|
||||
status_bytes = ray.get(self._controller.get_deployment_status.remote(name))
|
||||
|
||||
if status_bytes is None:
|
||||
raise RuntimeError(
|
||||
f"Waiting for deployment {name} to be HEALTHY, "
|
||||
"but deployment doesn't exist."
|
||||
)
|
||||
|
||||
status = DeploymentStatusInfo.from_proto(
|
||||
DeploymentStatusInfoProto.FromString(status_bytes)
|
||||
)
|
||||
|
||||
if status.status == DeploymentStatus.HEALTHY:
|
||||
break
|
||||
elif status.status == DeploymentStatus.UNHEALTHY:
|
||||
raise RuntimeError(
|
||||
f"Deployment {name} is UNHEALTHY: " f"{status.message}"
|
||||
)
|
||||
else:
|
||||
# Guard against new unhandled statuses being added.
|
||||
assert status.status == DeploymentStatus.UPDATING
|
||||
|
||||
logger.debug(
|
||||
f"Waiting for {name} to be healthy, current status: "
|
||||
f"{status.status}."
|
||||
)
|
||||
time.sleep(CLIENT_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Deployment {name} did not become HEALTHY after {timeout_s}s."
|
||||
)
|
||||
|
||||
def _wait_for_deployment_deleted(
|
||||
self, name: str, app_name: str, timeout_s: int = 60
|
||||
):
|
||||
"""Waits for the named deployment to be shut down and deleted.
|
||||
|
||||
Raises TimeoutError if this doesn't happen before timeout_s.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
curr_status_bytes = ray.get(
|
||||
self._controller.get_deployment_status.remote(name)
|
||||
)
|
||||
if curr_status_bytes is None:
|
||||
break
|
||||
curr_status = DeploymentStatusInfo.from_proto(
|
||||
DeploymentStatusInfoProto.FromString(curr_status_bytes)
|
||||
)
|
||||
logger.debug(
|
||||
f"Waiting for {name} to be deleted, current status: {curr_status}."
|
||||
)
|
||||
time.sleep(CLIENT_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(f"Deployment {name} wasn't deleted after {timeout_s}s.")
|
||||
|
||||
def _wait_for_deployment_created(
|
||||
self, deployment_name: str, app_name: str, timeout_s: int = -1
|
||||
):
|
||||
"""Waits for the named deployment to be created.
|
||||
|
||||
A deployment being created simply means that its been registered
|
||||
with the deployment state manager. The deployment state manager
|
||||
will then continue to reconcile the deployment towards its
|
||||
target state.
|
||||
|
||||
Raises TimeoutError if this doesn't happen before timeout_s.
|
||||
"""
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s or timeout_s < 0:
|
||||
status_bytes = ray.get(
|
||||
self._controller.get_deployment_status.remote(deployment_name, app_name)
|
||||
)
|
||||
|
||||
if status_bytes is not None:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"Waiting for deployment '{deployment_name}' in application "
|
||||
f"'{app_name}' to be created."
|
||||
)
|
||||
time.sleep(CLIENT_CHECK_CREATION_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Deployment '{deployment_name}' in application '{app_name}' "
|
||||
f"did not become HEALTHY after {timeout_s}s."
|
||||
)
|
||||
|
||||
def _wait_for_application_running(self, name: str, timeout_s: int = -1):
|
||||
"""Waits for the named application to enter "RUNNING" status.
|
||||
|
||||
Args:
|
||||
name: the application name to wait on.
|
||||
timeout_s: maximum time to wait, in seconds. A negative value waits
|
||||
indefinitely.
|
||||
|
||||
Raises:
|
||||
RuntimeError: if the application enters the "DEPLOY_FAILED" status instead.
|
||||
TimeoutError: if this doesn't happen before timeout_s.
|
||||
"""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s or timeout_s < 0:
|
||||
status_bytes = ray.get(self._controller.get_serve_status.remote(name))
|
||||
if status_bytes is None:
|
||||
raise RuntimeError(
|
||||
f"Waiting for application {name} to be RUNNING, "
|
||||
"but application doesn't exist."
|
||||
)
|
||||
|
||||
status = StatusOverview.from_proto(
|
||||
StatusOverviewProto.FromString(status_bytes)
|
||||
)
|
||||
|
||||
if status.app_status.status == ApplicationStatus.RUNNING:
|
||||
break
|
||||
elif status.app_status.status == ApplicationStatus.DEPLOY_FAILED:
|
||||
raise RuntimeError(
|
||||
f"Deploying application {name} failed: {status.app_status.message}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Waiting for {name} to be RUNNING, current status: "
|
||||
f"{status.app_status.status}."
|
||||
)
|
||||
time.sleep(CLIENT_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Application {name} did not become RUNNING after {timeout_s}s."
|
||||
)
|
||||
|
||||
@_ensure_connected
|
||||
def wait_for_proxies_serving(
|
||||
self, wait_for_applications_running: bool = True
|
||||
) -> None:
|
||||
"""Wait for the proxies to be ready to serve requests."""
|
||||
proxy_handles = ray.get(self._controller.get_proxies.remote())
|
||||
|
||||
if not proxy_handles:
|
||||
return
|
||||
|
||||
serving_refs = [
|
||||
handle.serving.remote(
|
||||
wait_for_applications_running=wait_for_applications_running
|
||||
)
|
||||
for handle in proxy_handles.values()
|
||||
]
|
||||
|
||||
done, pending = ray.wait(
|
||||
serving_refs,
|
||||
timeout=HTTP_PROXY_TIMEOUT,
|
||||
num_returns=len(serving_refs),
|
||||
)
|
||||
|
||||
if len(pending) > 0:
|
||||
raise TimeoutError(f"Proxies not available after {HTTP_PROXY_TIMEOUT}s.")
|
||||
|
||||
# Ensure the proxies are either serving or dead.
|
||||
for ref in done:
|
||||
try:
|
||||
ray.get(ref, timeout=1)
|
||||
except ray.exceptions.RayActorError:
|
||||
pass
|
||||
except Exception:
|
||||
raise TimeoutError(
|
||||
f"Proxies not available after {HTTP_PROXY_TIMEOUT}s."
|
||||
)
|
||||
|
||||
@_ensure_connected
|
||||
def deploy_applications(
|
||||
self,
|
||||
built_apps: Sequence[BuiltApplication],
|
||||
*,
|
||||
wait_for_ingress_deployment_creation: bool = True,
|
||||
wait_for_applications_running: bool = True,
|
||||
) -> List[DeploymentHandle]:
|
||||
name_to_deployment_args_list = {}
|
||||
name_to_application_args = {}
|
||||
for app in built_apps:
|
||||
deployment_args_list = []
|
||||
deployments_to_deploy = list(app.deployments)
|
||||
if app.ingress_request_router_deployment is not None:
|
||||
deployments_to_deploy.append(app.ingress_request_router_deployment)
|
||||
|
||||
for deployment in deployments_to_deploy:
|
||||
if deployment.logging_config is None and app.logging_config:
|
||||
deployment = deployment.options(logging_config=app.logging_config)
|
||||
|
||||
is_ingress = deployment.name == app.ingress_deployment_name
|
||||
is_ingress_request_router = (
|
||||
app.ingress_request_router_deployment is not None
|
||||
and deployment.name == app.ingress_request_router_deployment.name
|
||||
)
|
||||
deployment_args = get_deploy_args(
|
||||
deployment.name,
|
||||
ingress=is_ingress,
|
||||
ingress_request_router=is_ingress_request_router,
|
||||
replica_config=deployment._replica_config,
|
||||
deployment_config=deployment._deployment_config,
|
||||
version=deployment._version or get_random_string(),
|
||||
route_prefix=app.route_prefix if is_ingress else None,
|
||||
uses_multiplexing=_callable_uses_multiplexing(
|
||||
deployment.func_or_class
|
||||
),
|
||||
)
|
||||
|
||||
deployment_args_proto = DeploymentArgs()
|
||||
deployment_args_proto.deployment_name = deployment_args[
|
||||
"deployment_name"
|
||||
]
|
||||
deployment_args_proto.deployment_config = deployment_args[
|
||||
"deployment_config_proto_bytes"
|
||||
]
|
||||
deployment_args_proto.replica_config = deployment_args[
|
||||
"replica_config_proto_bytes"
|
||||
]
|
||||
deployment_args_proto.deployer_job_id = deployment_args[
|
||||
"deployer_job_id"
|
||||
]
|
||||
if deployment_args["route_prefix"]:
|
||||
deployment_args_proto.route_prefix = deployment_args["route_prefix"]
|
||||
deployment_args_proto.ingress = deployment_args["ingress"]
|
||||
deployment_args_proto.ingress_request_router = deployment_args[
|
||||
"ingress_request_router"
|
||||
]
|
||||
deployment_args_proto.uses_multiplexing = deployment_args[
|
||||
"uses_multiplexing"
|
||||
]
|
||||
|
||||
deployment_args_list.append(deployment_args_proto.SerializeToString())
|
||||
|
||||
application_args_proto = ApplicationArgs()
|
||||
application_args_proto.external_scaler_enabled = app.external_scaler_enabled
|
||||
|
||||
name_to_deployment_args_list[app.name] = deployment_args_list
|
||||
name_to_application_args[
|
||||
app.name
|
||||
] = application_args_proto.SerializeToString()
|
||||
|
||||
# Validate applications before sending to controller.
|
||||
self._check_ingress_deployments(built_apps)
|
||||
|
||||
ray.get(
|
||||
self._controller.deploy_applications.remote(
|
||||
name_to_deployment_args_list, name_to_application_args
|
||||
)
|
||||
)
|
||||
|
||||
handles = []
|
||||
ready_apps = []
|
||||
for app in built_apps:
|
||||
# The deployment state is not guaranteed to be created after
|
||||
# deploy_application returns; the application state manager will
|
||||
# need another reconcile iteration to create it.
|
||||
if wait_for_ingress_deployment_creation:
|
||||
self._wait_for_deployment_created(app.ingress_deployment_name, app.name)
|
||||
|
||||
if wait_for_applications_running:
|
||||
self._wait_for_application_running(app.name)
|
||||
ready_apps.append(app)
|
||||
|
||||
handles.append(
|
||||
self.get_handle(
|
||||
app.ingress_deployment_name, app.name, check_exists=False
|
||||
)
|
||||
)
|
||||
|
||||
# Wait for the proxies to be serving before declaring the applications
|
||||
# ready, so the "is ready" log line only prints once requests can
|
||||
# actually be routed to the applications.
|
||||
self.wait_for_proxies_serving(
|
||||
wait_for_applications_running=wait_for_applications_running
|
||||
)
|
||||
|
||||
for app in ready_apps:
|
||||
if app.route_prefix is not None:
|
||||
url_part = " at " + self._root_url + app.route_prefix
|
||||
else:
|
||||
url_part = ""
|
||||
logger.info(f"Application '{app.name}' is ready{url_part}.")
|
||||
|
||||
return handles
|
||||
|
||||
@_ensure_connected
|
||||
def deploy_apps(
|
||||
self,
|
||||
config: Union[ServeApplicationSchema, ServeDeploySchema],
|
||||
_blocking: bool = False,
|
||||
) -> None:
|
||||
"""Starts a task on the controller that deploys application(s) from a config.
|
||||
|
||||
Args:
|
||||
config: A single-application config (ServeApplicationSchema) or a
|
||||
multi-application config (ServeDeploySchema)
|
||||
_blocking: Whether to block until the application is running.
|
||||
|
||||
Raises:
|
||||
RayTaskError: If the deploy task on the controller fails. This can be
|
||||
because a single-app config was deployed after deploying a multi-app
|
||||
config, or vice versa.
|
||||
"""
|
||||
ray.get(self._controller.apply_config.remote(config))
|
||||
|
||||
if _blocking:
|
||||
timeout_s = 60
|
||||
|
||||
if isinstance(config, ServeDeploySchema):
|
||||
app_names = {app.name for app in config.applications}
|
||||
else:
|
||||
app_names = {config.name}
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < timeout_s:
|
||||
statuses = self.list_serve_statuses()
|
||||
app_to_status = {
|
||||
status.name: status.app_status.status
|
||||
for status in statuses
|
||||
if status.name in app_names
|
||||
}
|
||||
if len(app_names) == len(app_to_status) and set(
|
||||
app_to_status.values()
|
||||
) == {ApplicationStatus.RUNNING}:
|
||||
break
|
||||
|
||||
time.sleep(CLIENT_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Serve application isn't running after {timeout_s}s."
|
||||
)
|
||||
|
||||
self.wait_for_proxies_serving(wait_for_applications_running=True)
|
||||
|
||||
def _check_ingress_deployments(
|
||||
self, built_apps: Sequence[BuiltApplication]
|
||||
) -> None:
|
||||
"""Check @serve.ingress of deployments across applications.
|
||||
|
||||
Raises: RayServeException if more than one @serve.ingress
|
||||
is found among deployments in any single application.
|
||||
"""
|
||||
for app in built_apps:
|
||||
app.validate_single_fastapi_ingress()
|
||||
|
||||
@_ensure_connected
|
||||
def delete_apps(self, names: List[str], blocking: bool = True):
|
||||
if not names:
|
||||
return
|
||||
|
||||
logger.info(f"Deleting app {names}")
|
||||
self._controller.delete_apps.remote(names)
|
||||
if blocking:
|
||||
start = time.time()
|
||||
while time.time() - start < 60:
|
||||
curr_statuses_bytes = ray.get(
|
||||
self._controller.get_serve_statuses.remote(names)
|
||||
)
|
||||
all_deleted = True
|
||||
for cur_status_bytes in curr_statuses_bytes:
|
||||
cur_status = StatusOverview.from_proto(
|
||||
StatusOverviewProto.FromString(cur_status_bytes)
|
||||
)
|
||||
if cur_status.app_status.status != ApplicationStatus.NOT_STARTED:
|
||||
all_deleted = False
|
||||
if all_deleted:
|
||||
return
|
||||
time.sleep(CLIENT_POLLING_INTERVAL_S)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Some of these applications weren't deleted after 60s: {names}"
|
||||
)
|
||||
|
||||
@_ensure_connected
|
||||
def delete_all_apps(self, blocking: bool = True):
|
||||
"""Delete all applications"""
|
||||
all_apps = []
|
||||
for status_bytes in ray.get(self._controller.list_serve_statuses.remote()):
|
||||
proto = StatusOverviewProto.FromString(status_bytes)
|
||||
status = StatusOverview.from_proto(proto)
|
||||
all_apps.append(status.name)
|
||||
self.delete_apps(all_apps, blocking)
|
||||
|
||||
@_ensure_connected
|
||||
def get_deployment_info(
|
||||
self, name: str, app_name: str
|
||||
) -> Tuple[DeploymentInfo, str]:
|
||||
deployment_route = DeploymentRoute.FromString(
|
||||
ray.get(self._controller.get_deployment_info.remote(name, app_name))
|
||||
)
|
||||
return (
|
||||
DeploymentInfo.from_proto(deployment_route.deployment_info),
|
||||
deployment_route.route if deployment_route.route != "" else None,
|
||||
)
|
||||
|
||||
@_ensure_connected
|
||||
def get_serve_status(self, name: str = SERVE_DEFAULT_APP_NAME) -> StatusOverview:
|
||||
proto = StatusOverviewProto.FromString(
|
||||
ray.get(self._controller.get_serve_status.remote(name))
|
||||
)
|
||||
return StatusOverview.from_proto(proto)
|
||||
|
||||
@_ensure_connected
|
||||
def list_serve_statuses(self) -> List[StatusOverview]:
|
||||
statuses_bytes = ray.get(self._controller.list_serve_statuses.remote())
|
||||
return [
|
||||
StatusOverview.from_proto(StatusOverviewProto.FromString(status_bytes))
|
||||
for status_bytes in statuses_bytes
|
||||
]
|
||||
|
||||
@_ensure_connected
|
||||
def get_all_deployment_statuses(self) -> List[DeploymentStatusInfo]:
|
||||
statuses_bytes = ray.get(self._controller.get_all_deployment_statuses.remote())
|
||||
return [
|
||||
DeploymentStatusInfo.from_proto(
|
||||
DeploymentStatusInfoProto.FromString(status_bytes)
|
||||
)
|
||||
for status_bytes in statuses_bytes
|
||||
]
|
||||
|
||||
@_ensure_connected
|
||||
def get_serve_details(self) -> Dict:
|
||||
return ray.get(self._controller.get_serve_instance_details.remote())
|
||||
|
||||
@_ensure_connected
|
||||
def get_handle(
|
||||
self,
|
||||
deployment_name: str,
|
||||
app_name: Optional[str] = SERVE_DEFAULT_APP_NAME,
|
||||
check_exists: bool = True,
|
||||
) -> DeploymentHandle:
|
||||
"""Construct a handle for the specified deployment.
|
||||
|
||||
Args:
|
||||
deployment_name: Deployment name.
|
||||
app_name: Application name.
|
||||
check_exists: If False, then Serve won't check the deployment
|
||||
is registered. True by default.
|
||||
|
||||
Returns:
|
||||
DeploymentHandle
|
||||
"""
|
||||
deployment_id = DeploymentID(name=deployment_name, app_name=app_name)
|
||||
cache_key = (deployment_name, app_name, check_exists)
|
||||
if cache_key in self.handle_cache:
|
||||
return self.handle_cache[cache_key]
|
||||
|
||||
if check_exists:
|
||||
all_deployments = ray.get(self._controller.list_deployment_ids.remote())
|
||||
if deployment_id not in all_deployments:
|
||||
raise KeyError(f"{deployment_id} does not exist.")
|
||||
|
||||
handle = DeploymentHandle(deployment_name, app_name)
|
||||
self.handle_cache[cache_key] = handle
|
||||
if cache_key in self._evicted_handle_keys:
|
||||
logger.warning(
|
||||
"You just got a ServeHandle that was evicted from internal "
|
||||
"cache. This means you are getting too many ServeHandles in "
|
||||
"the same process, this will bring down Serve's performance. "
|
||||
"Please post a github issue at "
|
||||
"https://github.com/ray-project/ray/issues to let the Serve "
|
||||
"team to find workaround for your use case."
|
||||
)
|
||||
|
||||
if len(self.handle_cache) > MAX_CACHED_HANDLES:
|
||||
# Perform random eviction to keep the handle cache from growing
|
||||
# infinitely. We used use WeakValueDictionary but hit
|
||||
# https://github.com/ray-project/ray/issues/18980.
|
||||
evict_key = random.choice(list(self.handle_cache.keys()))
|
||||
self._evicted_handle_keys.add(evict_key)
|
||||
self.handle_cache.pop(evict_key)
|
||||
|
||||
return handle
|
||||
|
||||
@_ensure_connected
|
||||
def record_request_routing_info(self, info: RequestRoutingInfo):
|
||||
"""Record replica routing information for a replica.
|
||||
|
||||
Args:
|
||||
info: RequestRoutingInfo including deployment name, replica tag,
|
||||
multiplex model ids, and routing stats.
|
||||
"""
|
||||
self._controller.record_request_routing_info.remote(info)
|
||||
|
||||
@_ensure_connected
|
||||
def update_global_logging_config(self, logging_config: LoggingConfig):
|
||||
"""Reconfigure the logging config for the controller & proxies."""
|
||||
self._controller.reconfigure_global_logging_config.remote(logging_config)
|
||||
@@ -0,0 +1,145 @@
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, FrozenSet, List, Optional, Set, Tuple, Union
|
||||
|
||||
import ray
|
||||
from ray._common.utils import binary_to_hex
|
||||
from ray._raylet import GcsClient
|
||||
from ray.serve._private.constants import RAY_GCS_RPC_TIMEOUT_S, SERVE_LOGGER_NAME
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class ClusterNodeInfoCache(ABC):
|
||||
"""Provide access to cached node information in the cluster."""
|
||||
|
||||
def __init__(self, gcs_client: GcsClient):
|
||||
self._gcs_client = gcs_client
|
||||
self._cached_alive_nodes = None
|
||||
self._cached_node_labels = dict()
|
||||
self._cached_total_resources_per_node = dict()
|
||||
self._cached_available_resources_per_node = dict()
|
||||
# Track alive node IDs to detect cluster membership changes and skip
|
||||
# rebuilding labels / total resources when nothing changed.
|
||||
self._alive_node_id_set: FrozenSet[str] = frozenset()
|
||||
|
||||
def update(self):
|
||||
"""Update the cache by fetching latest node information from GCS.
|
||||
|
||||
This should be called once in each update cycle.
|
||||
Within an update cycle, everyone will see the same
|
||||
cached node info avoiding any potential issues
|
||||
caused by inconsistent node info seen by different components.
|
||||
"""
|
||||
nodes = self._gcs_client.get_all_node_info(timeout=RAY_GCS_RPC_TIMEOUT_S)
|
||||
alive_nodes = [
|
||||
(node_id.hex(), node.node_name, node.instance_id)
|
||||
for (node_id, node) in nodes.items()
|
||||
if node.state == ray.core.generated.gcs_pb2.GcsNodeInfo.ALIVE
|
||||
]
|
||||
|
||||
# Sort on NodeID to ensure the ordering is deterministic across the cluster.
|
||||
alive_nodes.sort()
|
||||
self._cached_alive_nodes = alive_nodes
|
||||
|
||||
# Detect whether the set of alive nodes has changed. Rebuild labels
|
||||
# and total resources only when it has, since they are static per-node
|
||||
# properties that don't change while a node stays alive.
|
||||
current_alive_ids = frozenset(node_id for node_id, _, _ in alive_nodes)
|
||||
if current_alive_ids != self._alive_node_id_set:
|
||||
self._alive_node_id_set = current_alive_ids
|
||||
self._cached_node_labels = {
|
||||
node_id.hex(): dict(node.labels)
|
||||
for (node_id, node) in nodes.items()
|
||||
if node_id.hex() in current_alive_ids
|
||||
}
|
||||
self._cached_total_resources_per_node = {
|
||||
node_id.hex(): dict(node.resources_total)
|
||||
for (node_id, node) in nodes.items()
|
||||
if node_id.hex() in current_alive_ids
|
||||
}
|
||||
|
||||
# Fetch available resources using the existing GCS client rather than
|
||||
# the legacy GlobalStateAccessor path (which opens a second connection
|
||||
# and performs redundant protobuf deserialization).
|
||||
self._cached_available_resources_per_node = (
|
||||
self._fetch_available_resources_per_node()
|
||||
)
|
||||
|
||||
def _fetch_available_resources_per_node(self) -> Dict[str, Dict[str, float]]:
|
||||
"""Fetch available resources per alive node via get_all_resource_usage()."""
|
||||
try:
|
||||
reply = self._gcs_client.get_all_resource_usage(
|
||||
timeout=RAY_GCS_RPC_TIMEOUT_S
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to fetch resource usage from GCS. "
|
||||
"Available resources cache will be stale.",
|
||||
exc_info=True,
|
||||
)
|
||||
return self._cached_available_resources_per_node
|
||||
|
||||
return {
|
||||
node_id: dict(resource_data.resources_available)
|
||||
for resource_data in reply.resource_usage_data.batch
|
||||
if (node_id := binary_to_hex(resource_data.node_id))
|
||||
in self._alive_node_id_set
|
||||
}
|
||||
|
||||
def get_alive_nodes(self) -> List[Tuple[str, str, str]]:
|
||||
"""Get IDs, IPs, and Instance IDs for all live nodes in the cluster.
|
||||
|
||||
Returns a list of (node_id: str, node_ip: str, instance_id: str).
|
||||
The node_id can be passed into the Ray SchedulingPolicy API.
|
||||
"""
|
||||
return self._cached_alive_nodes
|
||||
|
||||
def get_total_resources_per_node(self) -> Dict[str, Dict]:
|
||||
"""Get total resources for alive nodes."""
|
||||
return self._cached_total_resources_per_node
|
||||
|
||||
def get_alive_node_ids(self) -> Set[str]:
|
||||
"""Get IDs of all live nodes in the cluster."""
|
||||
return {node_id for node_id, _, _ in self.get_alive_nodes()}
|
||||
|
||||
@abstractmethod
|
||||
def get_draining_nodes(self) -> Dict[str, int]:
|
||||
"""Get draining nodes in the cluster and their deadlines."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_node_az(self, node_id: str) -> Optional[str]:
|
||||
"""Get availability zone of a node."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_active_node_ids(self) -> Set[str]:
|
||||
"""Get IDs of all active nodes in the cluster.
|
||||
|
||||
A node is active if it's schedulable for new tasks and actors.
|
||||
"""
|
||||
return self.get_alive_node_ids() - set(self.get_draining_nodes())
|
||||
|
||||
def get_available_resources_per_node(self) -> Dict[str, Union[float, Dict]]:
|
||||
"""Get available resources per node.
|
||||
|
||||
Returns a map from (node_id -> Dict of resources).
|
||||
"""
|
||||
|
||||
return self._cached_available_resources_per_node
|
||||
|
||||
def get_node_labels(self, node_id: str) -> Dict[str, str]:
|
||||
"""Get the labels for a specific node from the cache."""
|
||||
return self._cached_node_labels.get(node_id, {})
|
||||
|
||||
|
||||
class DefaultClusterNodeInfoCache(ClusterNodeInfoCache):
|
||||
def __init__(self, gcs_client: GcsClient):
|
||||
super().__init__(gcs_client)
|
||||
|
||||
def get_draining_nodes(self) -> Dict[str, int]:
|
||||
return dict()
|
||||
|
||||
def get_node_az(self, node_id: str) -> Optional[str]:
|
||||
"""Get availability zone of a node."""
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,314 @@
|
||||
import os
|
||||
import warnings
|
||||
from typing import Callable, List, Optional, Type, TypeVar
|
||||
|
||||
|
||||
def str_to_list(s: str) -> List[str]:
|
||||
"""Return a list from a comma-separated string.
|
||||
|
||||
Trims whitespace and skips empty entries.
|
||||
"""
|
||||
return [part for part in (part.strip() for part in s.split(",")) if part]
|
||||
|
||||
|
||||
def parse_latency_buckets(bucket_str: str, default_buckets: List[float]) -> List[float]:
|
||||
"""Parse a comma-separated string of latency bucket values.
|
||||
|
||||
Args:
|
||||
bucket_str: A comma-separated string of positive numbers in ascending order.
|
||||
default_buckets: Default bucket values to use if bucket_str is empty.
|
||||
|
||||
Returns:
|
||||
A list of parsed float values.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is invalid or values don't meet requirements.
|
||||
"""
|
||||
if bucket_str.strip() == "":
|
||||
return default_buckets
|
||||
try:
|
||||
# Convert string to list of floats
|
||||
buckets = [float(x.strip()) for x in bucket_str.split(",")]
|
||||
|
||||
if not buckets:
|
||||
raise ValueError("Empty bucket list")
|
||||
if any(x <= 0 for x in buckets):
|
||||
raise ValueError("Bucket values must be positive")
|
||||
if sorted(set(buckets)) != buckets:
|
||||
raise ValueError("Bucket values must be in strictly ascending order")
|
||||
|
||||
return buckets
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Invalid format for `{bucket_str}`. "
|
||||
f"Expected comma-separated positive numbers in ascending order. Error: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# todo: remove for the '3.0.0' release.
|
||||
_wrong_names_white_list = {
|
||||
"REQUEST_LATENCY_BUCKETS_MS",
|
||||
"MODEL_LOAD_LATENCY_BUCKETS_MS",
|
||||
"MAX_CACHED_HANDLES",
|
||||
"SERVE_REQUEST_PROCESSING_TIMEOUT_S",
|
||||
}
|
||||
|
||||
|
||||
def _validate_name(name: str) -> None:
|
||||
"""Validate Ray Serve environment variable name."""
|
||||
required_prefix = "RAY_SERVE_"
|
||||
|
||||
if not name.startswith(required_prefix):
|
||||
if name in _wrong_names_white_list:
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Got unexpected environment variable name `{name}`! "
|
||||
f"Ray Serve environment variables require prefix `{required_prefix}`. "
|
||||
)
|
||||
|
||||
|
||||
def _get_env_value(
|
||||
name: str,
|
||||
default: Optional[T],
|
||||
value_type: Type[T],
|
||||
validation_func: Optional[Callable[[T], bool]] = None,
|
||||
expected_value_description: Optional[str] = None,
|
||||
) -> Optional[T]:
|
||||
"""Get environment variable with type conversion and validation.
|
||||
|
||||
This function retrieves an environment variable, converts it to the specified type,
|
||||
and optionally validates the converted value.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
If None, the function will return None without validation.
|
||||
value_type: Type to convert the environment variable value to (e.g., int, float, str).
|
||||
validation_func: Optional function that takes the converted value and returns
|
||||
a boolean indicating whether the value is valid.
|
||||
expected_value_description: Description of the expected value characteristics
|
||||
(e.g., "positive", "non-negative") used in error messages.
|
||||
Optional, expected only if validation_func is provided.
|
||||
|
||||
Returns:
|
||||
The environment variable value converted to the specified type and validated,
|
||||
or the default value if the environment variable is not set.
|
||||
|
||||
Raises:
|
||||
ValueError: If the environment variable value cannot be converted to the specified
|
||||
type, or if it fails the optional validation check. Also, if name validation fails.
|
||||
"""
|
||||
_validate_name(name)
|
||||
|
||||
explicitly_defined_value = os.environ.get(name)
|
||||
if explicitly_defined_value is None:
|
||||
if default is None:
|
||||
return None
|
||||
else:
|
||||
raw = default
|
||||
else:
|
||||
_deprecation_warning(name)
|
||||
raw = explicitly_defined_value
|
||||
|
||||
try:
|
||||
value = value_type(raw)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Environment variable `{name}` value `{raw}` cannot be converted to `{value_type.__name__}`!"
|
||||
) from e
|
||||
|
||||
if validation_func and not validation_func(value):
|
||||
raise ValueError(
|
||||
f"Got unexpected value `{value}` for `{name}` environment variable! "
|
||||
f"Expected {expected_value_description} `{value_type.__name__}`."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def get_env_int(name: str, default: Optional[int]) -> Optional[int]:
|
||||
"""Get environment variable as an integer.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as an integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to an integer.
|
||||
"""
|
||||
return _get_env_value(name, default, int)
|
||||
|
||||
|
||||
def get_env_int_positive(name: str, default: Optional[int]) -> Optional[int]:
|
||||
"""Get environment variable as a positive integer.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a positive integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to an integer or is not positive.
|
||||
"""
|
||||
return _get_env_value(name, default, int, lambda x: x > 0, "positive")
|
||||
|
||||
|
||||
def get_env_int_non_negative(name: str, default: Optional[int]) -> Optional[int]:
|
||||
"""Get environment variable as a non-negative integer.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a non-negative integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to an integer or is negative.
|
||||
"""
|
||||
return _get_env_value(name, default, int, lambda x: x >= 0, "non negative")
|
||||
|
||||
|
||||
def get_env_float(name: str, default: Optional[float]) -> Optional[float]:
|
||||
"""Get environment variable as a float.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a float.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to a float.
|
||||
"""
|
||||
return _get_env_value(name, default, float)
|
||||
|
||||
|
||||
def get_env_float_positive(name: str, default: Optional[float]) -> Optional[float]:
|
||||
"""Get environment variable as a positive float.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a positive float.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to a float or is not positive.
|
||||
"""
|
||||
return _get_env_value(name, default, float, lambda x: x > 0, "positive")
|
||||
|
||||
|
||||
def get_env_float_non_negative(name: str, default: Optional[float]) -> Optional[float]:
|
||||
"""Get environment variable as a non-negative float.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a non-negative float.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value cannot be converted to a float or is negative.
|
||||
"""
|
||||
return _get_env_value(name, default, float, lambda x: x >= 0, "non negative")
|
||||
|
||||
|
||||
def get_env_str(name: str, default: Optional[str]) -> Optional[str]:
|
||||
"""Get environment variable as a string.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
|
||||
Returns:
|
||||
The environment variable value as a string.
|
||||
Returns `None` if default is `None` and value not found.
|
||||
"""
|
||||
return _get_env_value(name, default, str)
|
||||
|
||||
|
||||
def get_env_bool(name: str, default: str) -> bool:
|
||||
"""Get environment variable as a boolean.
|
||||
|
||||
Environment variable values of "1" are interpreted as True, all others as False.
|
||||
|
||||
Args:
|
||||
name: The name of the environment variable.
|
||||
default: Default value to use if the environment variable is not set.
|
||||
Expects "0" or "1".
|
||||
|
||||
Returns:
|
||||
True if the environment variable value is "1", False otherwise.
|
||||
"""
|
||||
env_value_str = _get_env_value(name, default, str)
|
||||
return env_value_str == "1"
|
||||
|
||||
|
||||
# Environment variables that are fully deprecated and will be ignored.
|
||||
_fully_deprecated_env_vars = {
|
||||
"RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S": "http_options.keep_alive_timeout_s",
|
||||
"RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S": "request_router_config.initial_backoff_s",
|
||||
"RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER": "request_router_config.backoff_multiplier",
|
||||
"RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S": "request_router_config.max_backoff_s",
|
||||
}
|
||||
|
||||
|
||||
def _deprecation_warning(name: str) -> None:
|
||||
"""Log replacement warning for wrong or legacy environment variables.
|
||||
|
||||
TODO: remove this function for the '3.0.0' release.
|
||||
|
||||
Args:
|
||||
name: Environment variable name.
|
||||
"""
|
||||
|
||||
def get_new_name(name: str) -> str:
|
||||
if name == "RAY_SERVE_HANDLE_METRIC_PUSH_INTERVAL_S":
|
||||
return "RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S"
|
||||
elif name == "SERVE_REQUEST_PROCESSING_TIMEOUT_S":
|
||||
return "RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S"
|
||||
else:
|
||||
return f"{required_prefix}{name}"
|
||||
|
||||
change_version = "3.0.0"
|
||||
required_prefix = "RAY_SERVE_"
|
||||
|
||||
if (
|
||||
name in _wrong_names_white_list
|
||||
or name == "RAY_SERVE_HANDLE_METRIC_PUSH_INTERVAL_S"
|
||||
):
|
||||
new_name = get_new_name(name)
|
||||
warnings.warn(
|
||||
f"Starting from version `{change_version}` environment variable "
|
||||
f"`{name}` will be deprecated. Please use `{new_name}` instead.",
|
||||
FutureWarning,
|
||||
stacklevel=4,
|
||||
)
|
||||
|
||||
|
||||
def warn_if_deprecated_env_var_set(name: str) -> None:
|
||||
"""Warn if a fully deprecated environment variable is set.
|
||||
|
||||
Args:
|
||||
name: Environment variable name.
|
||||
"""
|
||||
if name in _fully_deprecated_env_vars and os.environ.get(name):
|
||||
config_option = _fully_deprecated_env_vars[name]
|
||||
warnings.warn(
|
||||
f"`{name}` environment variable will be deprecated in the future. "
|
||||
f"Use `{config_option}` in the Serve config instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
import ray
|
||||
from ray.serve._private.constants import SERVE_CONTROLLER_NAME, SERVE_NAMESPACE
|
||||
from ray.serve._private.default_impl import get_controller_impl
|
||||
from ray.serve.config import HTTPOptions, ProxyLocation
|
||||
from ray.serve.schema import LoggingConfig
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class ServeControllerAvatar:
|
||||
"""A hack that proxy the creation of async actors from Java.
|
||||
|
||||
To be removed after https://github.com/ray-project/ray/pull/26037
|
||||
|
||||
Java api can not support python async actor. If we use java api create
|
||||
python async actor. The async init method won't be executed. The async
|
||||
method will fail with pickle error. And the run_control_loop of controller
|
||||
actor can't be executed too. We use this proxy actor create python async
|
||||
actor to avoid the above problem.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
http_proxy_port: int = 8000,
|
||||
):
|
||||
try:
|
||||
self._controller = ray.get_actor(
|
||||
SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE
|
||||
)
|
||||
except ValueError:
|
||||
self._controller = None
|
||||
if self._controller is None:
|
||||
controller_impl = get_controller_impl()
|
||||
# This Java bootstrap builds HTTPOptions directly and previously
|
||||
# relied on the (now removed) HeadOnly default of
|
||||
# HTTPOptions.location. Pass proxy_location explicitly to preserve
|
||||
# head-only proxy placement on multi-node clusters.
|
||||
self._controller = controller_impl.remote(
|
||||
http_options=HTTPOptions(port=http_proxy_port),
|
||||
proxy_location=ProxyLocation.HeadOnly,
|
||||
global_logging_config=LoggingConfig(),
|
||||
)
|
||||
|
||||
def check_alive(self) -> None:
|
||||
"""No-op to check if this actor is alive."""
|
||||
return
|
||||
@@ -0,0 +1,147 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Deque
|
||||
|
||||
from ray.serve._private.constants import CONTROL_LOOP_INTERVAL_S
|
||||
from ray.serve.schema import ControllerHealthMetrics, DurationStats
|
||||
|
||||
# Number of recent loop iterations to track for rolling averages
|
||||
_HEALTH_METRICS_HISTORY_SIZE = 100
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControllerHealthMetricsTracker:
|
||||
"""Tracker for collecting controller health metrics over time."""
|
||||
|
||||
controller_start_time: float = field(default_factory=time.time)
|
||||
|
||||
# Rolling history of loop durations
|
||||
loop_durations: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
|
||||
# Rolling history of metrics delays
|
||||
handle_metrics_delays: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
replica_metrics_delays: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
|
||||
# Rolling history of component update durations
|
||||
dsm_update_durations: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
asm_update_durations: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
proxy_update_durations: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
node_update_durations: Deque[float] = field(
|
||||
default_factory=lambda: deque(maxlen=_HEALTH_METRICS_HISTORY_SIZE)
|
||||
)
|
||||
|
||||
# Latest values (used in collect_metrics)
|
||||
last_sleep_duration_s: float = 0.0
|
||||
num_control_loops: int = 0
|
||||
last_control_loop_time: float = 0.0
|
||||
|
||||
def record_loop_duration(self, duration: float):
|
||||
self.loop_durations.append(duration)
|
||||
|
||||
def record_handle_metrics_delay(self, delay_ms: float):
|
||||
self.handle_metrics_delays.append(delay_ms)
|
||||
|
||||
def record_replica_metrics_delay(self, delay_ms: float):
|
||||
self.replica_metrics_delays.append(delay_ms)
|
||||
|
||||
def record_dsm_update_duration(self, duration: float):
|
||||
self.dsm_update_durations.append(duration)
|
||||
|
||||
def record_asm_update_duration(self, duration: float):
|
||||
self.asm_update_durations.append(duration)
|
||||
|
||||
def record_proxy_update_duration(self, duration: float):
|
||||
self.proxy_update_durations.append(duration)
|
||||
|
||||
def record_node_update_duration(self, duration: float):
|
||||
self.node_update_durations.append(duration)
|
||||
|
||||
def collect_metrics(self) -> ControllerHealthMetrics:
|
||||
"""Collect and return current health metrics."""
|
||||
now = time.time()
|
||||
|
||||
# Calculate loop statistics from rolling history
|
||||
loop_duration_stats = DurationStats.from_values(list(self.loop_durations))
|
||||
|
||||
# Calculate loops per second based on uptime and total loops
|
||||
uptime = now - self.controller_start_time
|
||||
loops_per_second = self.num_control_loops / uptime if uptime > 0 else 0.0
|
||||
|
||||
# Calculate event loop delay (actual sleep - expected sleep)
|
||||
# Positive values indicate the event loop is overloaded
|
||||
event_loop_delay = max(
|
||||
0.0, self.last_sleep_duration_s - CONTROL_LOOP_INTERVAL_S
|
||||
)
|
||||
|
||||
# Get asyncio task count
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
num_asyncio_tasks = len(asyncio.all_tasks(loop))
|
||||
except RuntimeError:
|
||||
num_asyncio_tasks = 0
|
||||
|
||||
# Calculate metrics delay statistics
|
||||
handle_delay_stats = DurationStats.from_values(list(self.handle_metrics_delays))
|
||||
replica_delay_stats = DurationStats.from_values(
|
||||
list(self.replica_metrics_delays)
|
||||
)
|
||||
|
||||
# Calculate component update duration statistics
|
||||
dsm_update_stats = DurationStats.from_values(list(self.dsm_update_durations))
|
||||
asm_update_stats = DurationStats.from_values(list(self.asm_update_durations))
|
||||
proxy_update_stats = DurationStats.from_values(
|
||||
list(self.proxy_update_durations)
|
||||
)
|
||||
node_update_stats = DurationStats.from_values(list(self.node_update_durations))
|
||||
|
||||
# Get memory usage in MB
|
||||
# Note: ru_maxrss is in bytes on macOS but kilobytes on Linux
|
||||
# The resource module is Unix-only, so we handle Windows gracefully
|
||||
try:
|
||||
import resource
|
||||
|
||||
rusage = resource.getrusage(resource.RUSAGE_SELF)
|
||||
process_memory_mb = (
|
||||
rusage.ru_maxrss / (1024 * 1024) # Convert bytes to MB on macOS
|
||||
if sys.platform == "darwin"
|
||||
else rusage.ru_maxrss / 1024 # Convert KB to MB on Linux
|
||||
)
|
||||
except ImportError:
|
||||
# resource module not available on Windows
|
||||
process_memory_mb = 0.0
|
||||
|
||||
return ControllerHealthMetrics(
|
||||
timestamp=now,
|
||||
controller_start_time=self.controller_start_time,
|
||||
uptime_s=uptime,
|
||||
last_control_loop_time=self.last_control_loop_time,
|
||||
num_control_loops=self.num_control_loops,
|
||||
loop_duration_s=loop_duration_stats,
|
||||
loops_per_second=loops_per_second,
|
||||
last_sleep_duration_s=self.last_sleep_duration_s,
|
||||
expected_sleep_duration_s=CONTROL_LOOP_INTERVAL_S,
|
||||
event_loop_delay_s=event_loop_delay,
|
||||
num_asyncio_tasks=num_asyncio_tasks,
|
||||
deployment_state_update_duration_s=dsm_update_stats,
|
||||
application_state_update_duration_s=asm_update_stats,
|
||||
proxy_state_update_duration_s=proxy_update_stats,
|
||||
node_update_duration_s=node_update_stats,
|
||||
handle_metrics_delay_ms=handle_delay_stats,
|
||||
replica_metrics_delay_ms=replica_delay_stats,
|
||||
process_memory_mb=process_memory_mb,
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
import asyncio
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
import ray
|
||||
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
|
||||
from ray._raylet import GcsClient
|
||||
from ray.serve._private.cluster_node_info_cache import (
|
||||
ClusterNodeInfoCache,
|
||||
DefaultClusterNodeInfoCache,
|
||||
)
|
||||
from ray.serve._private.common import (
|
||||
CreatePlacementGroupRequest,
|
||||
DeploymentHandleSource,
|
||||
DeploymentID,
|
||||
EndpointInfo,
|
||||
RequestMetadata,
|
||||
RequestProtocol,
|
||||
)
|
||||
from ray.serve._private.constants import (
|
||||
CONTROLLER_MAX_CONCURRENCY,
|
||||
RAY_SERVE_ENABLE_TASK_EVENTS,
|
||||
RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING,
|
||||
RAY_SERVE_PROXY_USE_GRPC,
|
||||
RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP,
|
||||
SERVE_CONTROLLER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.deployment_scheduler import (
|
||||
DefaultDeploymentScheduler,
|
||||
DeploymentScheduler,
|
||||
)
|
||||
from ray.serve._private.event_loop_monitoring import EventLoopMonitor
|
||||
from ray.serve._private.grpc_util import gRPCGenericServer
|
||||
from ray.serve._private.handle_options import DynamicHandleOptions, InitHandleOptions
|
||||
from ray.serve._private.router import CurrentLoopRouter, Router, SingletonThreadRouter
|
||||
from ray.serve._private.utils import (
|
||||
asyncio_grpc_exception_handler,
|
||||
generate_request_id,
|
||||
get_current_actor_id,
|
||||
get_head_node_id,
|
||||
inside_ray_client_context,
|
||||
resolve_deployment_response,
|
||||
)
|
||||
from ray.serve.config import ControllerOptions
|
||||
from ray.util.placement_group import PlacementGroup
|
||||
|
||||
# NOTE: Please read carefully before changing!
|
||||
#
|
||||
# These methods are common extension points, therefore these should be
|
||||
# changed as a Developer API, ie methods should not be renamed, have their
|
||||
# API modified w/o substantial enough justification
|
||||
|
||||
|
||||
def create_cluster_node_info_cache(gcs_client: GcsClient) -> ClusterNodeInfoCache:
|
||||
return DefaultClusterNodeInfoCache(gcs_client)
|
||||
|
||||
|
||||
CreatePlacementGroupFn = Callable[[CreatePlacementGroupRequest], PlacementGroup]
|
||||
|
||||
|
||||
def _default_create_placement_group(
|
||||
request: CreatePlacementGroupRequest,
|
||||
) -> PlacementGroup:
|
||||
return ray.util.placement_group(
|
||||
request.bundles,
|
||||
request.strategy,
|
||||
_soft_target_node_id=request.target_node_id,
|
||||
name=request.name,
|
||||
lifetime="detached",
|
||||
bundle_label_selector=request.bundle_label_selector,
|
||||
)
|
||||
|
||||
|
||||
def create_deployment_scheduler(
|
||||
cluster_node_info_cache: ClusterNodeInfoCache,
|
||||
head_node_id_override: Optional[str] = None,
|
||||
create_placement_group_fn_override: Optional[CreatePlacementGroupFn] = None,
|
||||
) -> DeploymentScheduler:
|
||||
head_node_id = head_node_id_override or get_head_node_id()
|
||||
return DefaultDeploymentScheduler(
|
||||
cluster_node_info_cache,
|
||||
head_node_id,
|
||||
create_placement_group_fn=create_placement_group_fn_override
|
||||
or _default_create_placement_group,
|
||||
)
|
||||
|
||||
|
||||
def create_replica_impl(**kwargs):
|
||||
from ray.serve._private.replica import Replica
|
||||
|
||||
return Replica(**kwargs)
|
||||
|
||||
|
||||
def create_replica_metrics_manager(**kwargs):
|
||||
from ray.serve._private.replica import ReplicaMetricsManager
|
||||
|
||||
return ReplicaMetricsManager(**kwargs)
|
||||
|
||||
|
||||
def create_dynamic_handle_options(**kwargs):
|
||||
return DynamicHandleOptions(**kwargs)
|
||||
|
||||
|
||||
def create_init_handle_options(**kwargs):
|
||||
return InitHandleOptions.create(**kwargs)
|
||||
|
||||
|
||||
def get_request_metadata(init_options, handle_options):
|
||||
_request_context = ray.serve.context._get_serve_request_context()
|
||||
|
||||
request_protocol = RequestProtocol.UNDEFINED
|
||||
if init_options and init_options._source == DeploymentHandleSource.PROXY:
|
||||
if _request_context.is_http_request:
|
||||
request_protocol = RequestProtocol.HTTP
|
||||
elif _request_context.grpc_context:
|
||||
request_protocol = RequestProtocol.GRPC
|
||||
|
||||
return RequestMetadata(
|
||||
request_id=_request_context.request_id
|
||||
if _request_context.request_id
|
||||
else generate_request_id(),
|
||||
internal_request_id=_request_context._internal_request_id
|
||||
if _request_context._internal_request_id
|
||||
else generate_request_id(),
|
||||
call_method=handle_options.method_name,
|
||||
route=_request_context.route,
|
||||
app_name=_request_context.app_name,
|
||||
multiplexed_model_id=handle_options.multiplexed_model_id,
|
||||
session_id=handle_options.session_id,
|
||||
is_streaming=handle_options.stream,
|
||||
_request_protocol=request_protocol,
|
||||
grpc_context=_request_context.grpc_context,
|
||||
_client=_request_context._client,
|
||||
_by_reference=handle_options._by_reference,
|
||||
_on_separate_loop=init_options._run_router_in_separate_loop,
|
||||
request_serialization=handle_options.request_serialization,
|
||||
response_serialization=handle_options.response_serialization,
|
||||
)
|
||||
|
||||
|
||||
def _get_node_id_and_az() -> Tuple[str, Optional[str]]:
|
||||
node_id = ray.get_runtime_context().get_node_id()
|
||||
try:
|
||||
cluster_node_info_cache = create_cluster_node_info_cache(
|
||||
GcsClient(address=ray.get_runtime_context().gcs_address)
|
||||
)
|
||||
cluster_node_info_cache.update()
|
||||
az = cluster_node_info_cache.get_node_az(node_id)
|
||||
except Exception:
|
||||
az = None
|
||||
|
||||
return node_id, az
|
||||
|
||||
|
||||
# Interface definition for create_router.
|
||||
CreateRouterCallable = Callable[[str, DeploymentID, InitHandleOptions], Router]
|
||||
|
||||
|
||||
def create_router(
|
||||
handle_id: str,
|
||||
deployment_id: DeploymentID,
|
||||
handle_options: InitHandleOptions,
|
||||
request_router_class: Optional[Callable] = None,
|
||||
) -> Router:
|
||||
# NOTE(edoakes): this is lazy due to a nasty circular import that should be fixed.
|
||||
from ray.serve.context import _get_global_client
|
||||
|
||||
actor_id = get_current_actor_id()
|
||||
node_id, availability_zone = _get_node_id_and_az()
|
||||
controller_handle = _get_global_client()._controller
|
||||
is_inside_ray_client_context = inside_ray_client_context()
|
||||
|
||||
if handle_options._run_router_in_separate_loop:
|
||||
router_wrapper_cls = SingletonThreadRouter
|
||||
# Determine the component for the event loop monitor
|
||||
if handle_options._source == DeploymentHandleSource.REPLICA:
|
||||
component = EventLoopMonitor.COMPONENT_REPLICA
|
||||
elif handle_options._source == DeploymentHandleSource.PROXY:
|
||||
component = EventLoopMonitor.COMPONENT_PROXY
|
||||
else:
|
||||
component = EventLoopMonitor.COMPONENT_UNKNOWN
|
||||
SingletonThreadRouter._get_singleton_asyncio_loop(
|
||||
component
|
||||
).set_exception_handler(asyncio_grpc_exception_handler)
|
||||
else:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
raise RuntimeError(
|
||||
"No event loop running. You cannot use a handle initialized with "
|
||||
"`_run_router_in_separate_loop=False` when not inside an asyncio event "
|
||||
"loop."
|
||||
)
|
||||
|
||||
router_wrapper_cls = CurrentLoopRouter
|
||||
|
||||
return router_wrapper_cls(
|
||||
controller_handle=controller_handle,
|
||||
deployment_id=deployment_id,
|
||||
handle_id=handle_id,
|
||||
self_actor_id=actor_id,
|
||||
handle_source=handle_options._source,
|
||||
request_router_class=request_router_class,
|
||||
# Streaming ObjectRefGenerators are not supported in Ray Client
|
||||
enable_strict_max_ongoing_requests=not is_inside_ray_client_context,
|
||||
resolve_request_arg_func=resolve_deployment_response,
|
||||
node_id=node_id,
|
||||
availability_zone=availability_zone,
|
||||
prefer_local_node_routing=handle_options._prefer_local_routing,
|
||||
)
|
||||
|
||||
|
||||
def add_grpc_address(grpc_server: gRPCGenericServer, server_address: str):
|
||||
"""Helper function to add an address to a gRPC server."""
|
||||
grpc_server.add_insecure_port(server_address)
|
||||
|
||||
|
||||
def get_proxy_handle(endpoint: DeploymentID, info: EndpointInfo):
|
||||
# NOTE(zcin): needs to be lazy import due to a circular dependency.
|
||||
# We should not be importing from application_state in context.
|
||||
from ray.serve.context import _get_global_client
|
||||
|
||||
client = _get_global_client()
|
||||
handle = client.get_handle(endpoint.name, endpoint.app_name, check_exists=True)
|
||||
|
||||
# NOTE(zcin): It's possible that a handle is already initialized
|
||||
# if a deployment with the same name and application name was
|
||||
# deleted, then redeployed later. However this is not an issue since
|
||||
# we initialize all handles with the same init options.
|
||||
if not handle.is_initialized:
|
||||
# NOTE(zcin): since the router is eagerly initialized here, the
|
||||
# proxy will receive the replica set from the controller early.
|
||||
handle._init(
|
||||
_prefer_local_routing=RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING,
|
||||
_source=DeploymentHandleSource.PROXY,
|
||||
_run_router_in_separate_loop=RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP,
|
||||
)
|
||||
|
||||
return handle.options(
|
||||
stream=not info.app_is_cross_language,
|
||||
_by_reference=not RAY_SERVE_PROXY_USE_GRPC,
|
||||
)
|
||||
|
||||
|
||||
def get_controller_impl(controller_options: Optional[ControllerOptions] = None):
|
||||
"""Build the Ray actor class for the Serve controller.
|
||||
|
||||
``controller_options`` is the validated ``ControllerOptions`` model from
|
||||
``serve.start`` / ``serve.run`` / the YAML schema. Today only its
|
||||
``runtime_env`` field is consumed; future fields (num_cpus, resources,
|
||||
max_concurrency overrides) slot in here.
|
||||
"""
|
||||
from ray.serve._private.controller import ServeController
|
||||
|
||||
actor_options = dict(
|
||||
name=SERVE_CONTROLLER_NAME,
|
||||
namespace=SERVE_NAMESPACE,
|
||||
num_cpus=0,
|
||||
lifetime="detached",
|
||||
max_restarts=-1,
|
||||
max_task_retries=-1,
|
||||
resources={HEAD_NODE_RESOURCE_NAME: 0.001},
|
||||
max_concurrency=CONTROLLER_MAX_CONCURRENCY,
|
||||
enable_task_events=RAY_SERVE_ENABLE_TASK_EVENTS,
|
||||
)
|
||||
if controller_options is not None and controller_options.runtime_env:
|
||||
# The validator on ControllerOptions guarantees this is a dict
|
||||
# containing only the ``env_vars`` key with str->str entries.
|
||||
actor_options["runtime_env"] = controller_options.runtime_env
|
||||
|
||||
return ray.remote(**actor_options)(ServeController)
|
||||
@@ -0,0 +1,180 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import ray
|
||||
import ray.util.serialization_addons
|
||||
from ray.serve._private.common import DeploymentID
|
||||
from ray.serve._private.config import DeploymentConfig, ReplicaConfig
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S,
|
||||
RAY_SERVE_DIRECT_INGRESS_SHUTDOWN_BUFFER_S,
|
||||
RAY_SERVE_ENABLE_DIRECT_INGRESS,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.deployment_info import DeploymentInfo
|
||||
from ray.serve.exceptions import RayServeException
|
||||
from ray.serve.schema import ServeApplicationSchema
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def get_deploy_args(
|
||||
name: str,
|
||||
replica_config: ReplicaConfig,
|
||||
ingress: bool = False,
|
||||
ingress_request_router: bool = False,
|
||||
deployment_config: Optional[Union[DeploymentConfig, Dict[str, Any]]] = None,
|
||||
version: Optional[str] = None,
|
||||
route_prefix: Optional[str] = None,
|
||||
serialized_autoscaling_policy_def: Optional[bytes] = None,
|
||||
serialized_request_router_cls: Optional[bytes] = None,
|
||||
serialized_deployment_actors: Optional[Dict[str, bytes]] = None,
|
||||
uses_multiplexing: bool = False,
|
||||
) -> Dict:
|
||||
"""
|
||||
Takes a deployment's configuration, and returns the arguments needed
|
||||
for the controller to deploy it.
|
||||
"""
|
||||
if deployment_config is None:
|
||||
deployment_config = {}
|
||||
|
||||
if isinstance(deployment_config, dict):
|
||||
deployment_config = DeploymentConfig.model_validate(deployment_config)
|
||||
elif not isinstance(deployment_config, DeploymentConfig):
|
||||
raise TypeError("config must be a DeploymentConfig or a dictionary.")
|
||||
|
||||
deployment_config.version = version
|
||||
|
||||
controller_deploy_args = {
|
||||
"deployment_name": name,
|
||||
"deployment_config_proto_bytes": deployment_config.to_proto_bytes(),
|
||||
"replica_config_proto_bytes": replica_config.to_proto_bytes(),
|
||||
"route_prefix": route_prefix,
|
||||
"deployer_job_id": ray.get_runtime_context().get_job_id(),
|
||||
"ingress": ingress,
|
||||
"ingress_request_router": ingress_request_router,
|
||||
"serialized_autoscaling_policy_def": serialized_autoscaling_policy_def,
|
||||
"serialized_request_router_cls": serialized_request_router_cls,
|
||||
"serialized_deployment_actors": serialized_deployment_actors,
|
||||
"uses_multiplexing": uses_multiplexing,
|
||||
}
|
||||
|
||||
return controller_deploy_args
|
||||
|
||||
|
||||
def deploy_args_to_deployment_info(
|
||||
deployment_name: str,
|
||||
deployment_config_proto_bytes: bytes,
|
||||
replica_config_proto_bytes: bytes,
|
||||
deployer_job_id: Union[str, bytes],
|
||||
app_name: Optional[str] = None,
|
||||
ingress: bool = False,
|
||||
ingress_request_router: bool = False,
|
||||
route_prefix: Optional[str] = None,
|
||||
uses_multiplexing: bool = False,
|
||||
**kwargs,
|
||||
) -> DeploymentInfo:
|
||||
"""Takes deployment args passed to the controller after building an application and
|
||||
constructs a DeploymentInfo object.
|
||||
"""
|
||||
|
||||
deployment_config = DeploymentConfig.from_proto_bytes(deployment_config_proto_bytes)
|
||||
|
||||
if ingress and RAY_SERVE_ENABLE_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).
|
||||
# Only the *statically* detectable case is caught here; dynamically-initialized
|
||||
# multiplexing is caught at replica initialization.
|
||||
if uses_multiplexing:
|
||||
raise RayServeException(
|
||||
f'Ingress deployment "{deployment_name}" in application "{app_name}" uses '
|
||||
"model multiplexing (`@serve.multiplexed`), which is not supported on the "
|
||||
"ingress deployment when direct ingress or HAProxy is enabled."
|
||||
)
|
||||
|
||||
# Floor the timeout so the controller's force-kill can't cut the
|
||||
# direct-ingress drain (min draining period) short.
|
||||
floor_s = (
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S
|
||||
+ RAY_SERVE_DIRECT_INGRESS_SHUTDOWN_BUFFER_S
|
||||
)
|
||||
if deployment_config.graceful_shutdown_timeout_s < floor_s:
|
||||
logger.info(
|
||||
f"Raising graceful_shutdown_timeout_s for ingress deployment "
|
||||
f"'{deployment_name}' from "
|
||||
f"{deployment_config.graceful_shutdown_timeout_s}s to {floor_s}s so "
|
||||
f"the force-kill deadline covers the direct-ingress drain period."
|
||||
)
|
||||
deployment_config.graceful_shutdown_timeout_s = floor_s
|
||||
|
||||
version = deployment_config.version
|
||||
replica_config = ReplicaConfig.from_proto_bytes(
|
||||
replica_config_proto_bytes, deployment_config.needs_pickle()
|
||||
)
|
||||
|
||||
# Java API passes in JobID as bytes
|
||||
if isinstance(deployer_job_id, bytes):
|
||||
deployer_job_id = ray.JobID.from_int(
|
||||
int.from_bytes(deployer_job_id, "little")
|
||||
).hex()
|
||||
|
||||
return DeploymentInfo(
|
||||
actor_name=DeploymentID(
|
||||
name=deployment_name, app_name=app_name
|
||||
).to_replica_actor_class_name(),
|
||||
version=version,
|
||||
deployment_config=deployment_config,
|
||||
replica_config=replica_config,
|
||||
deployer_job_id=deployer_job_id,
|
||||
start_time_ms=int(time.time() * 1000),
|
||||
route_prefix=route_prefix,
|
||||
ingress=ingress,
|
||||
ingress_request_router=ingress_request_router,
|
||||
)
|
||||
|
||||
|
||||
def get_app_code_version(app_config: ServeApplicationSchema) -> str:
|
||||
"""Returns the code version of an application.
|
||||
|
||||
Args:
|
||||
app_config: The application config.
|
||||
|
||||
Returns:
|
||||
str: A hash of the import path and (application level) runtime env
|
||||
representing the code version of the application.
|
||||
"""
|
||||
request_router_configs = [
|
||||
deployment.request_router_config
|
||||
for deployment in app_config.deployments
|
||||
if isinstance(deployment.request_router_config, dict)
|
||||
]
|
||||
deployment_autoscaling_policies = [
|
||||
deployment_config.autoscaling_config.get("policy", None)
|
||||
for deployment_config in app_config.deployments
|
||||
if isinstance(deployment_config.autoscaling_config, dict)
|
||||
]
|
||||
deployment_actors_configs = [
|
||||
deployment.deployment_actors
|
||||
for deployment in app_config.deployments
|
||||
if isinstance(deployment.deployment_actors, list)
|
||||
]
|
||||
|
||||
encoded = json.dumps(
|
||||
{
|
||||
"import_path": app_config.import_path,
|
||||
"runtime_env": app_config.runtime_env,
|
||||
"args": app_config.args,
|
||||
# NOTE: trigger a change in the code version when
|
||||
# application level autoscaling policy is changed or
|
||||
# any one of the deployment level autoscaling policy is changed
|
||||
"autoscaling_policy": app_config.autoscaling_policy,
|
||||
"deployment_autoscaling_policies": deployment_autoscaling_policies,
|
||||
"request_router_configs": request_router_configs,
|
||||
"deployment_actors": deployment_actors_configs,
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
@@ -0,0 +1,189 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import ray
|
||||
from ray.serve._private.common import TargetCapacityDirection
|
||||
from ray.serve._private.config import DeploymentConfig, ReplicaConfig
|
||||
from ray.serve.generated.serve_pb2 import (
|
||||
DeploymentInfo as DeploymentInfoProto,
|
||||
TargetCapacityDirection as TargetCapacityDirectionProto,
|
||||
)
|
||||
|
||||
|
||||
class DeploymentInfo:
|
||||
def __init__(
|
||||
self,
|
||||
deployment_config: DeploymentConfig,
|
||||
replica_config: ReplicaConfig,
|
||||
start_time_ms: int,
|
||||
deployer_job_id: str,
|
||||
actor_name: Optional[str] = None,
|
||||
version: Optional[str] = None,
|
||||
end_time_ms: Optional[int] = None,
|
||||
route_prefix: str = None,
|
||||
ingress: bool = False,
|
||||
ingress_request_router: bool = False,
|
||||
target_capacity: Optional[float] = None,
|
||||
target_capacity_direction: Optional[TargetCapacityDirection] = None,
|
||||
):
|
||||
self.deployment_config = deployment_config
|
||||
self.replica_config = replica_config
|
||||
# The time when .deploy() was first called for this deployment.
|
||||
self.start_time_ms = start_time_ms
|
||||
self.actor_name = actor_name
|
||||
self.version = version
|
||||
self.deployer_job_id = deployer_job_id
|
||||
# The time when this deployment was deleted.
|
||||
self.end_time_ms = end_time_ms
|
||||
|
||||
# ephermal state
|
||||
self._cached_actor_def = None
|
||||
|
||||
self.route_prefix = route_prefix
|
||||
self.ingress = ingress
|
||||
self.ingress_request_router = ingress_request_router
|
||||
|
||||
self.target_capacity = target_capacity
|
||||
self.target_capacity_direction = target_capacity_direction
|
||||
|
||||
def __getstate__(self) -> Dict[Any, Any]:
|
||||
clean_dict = self.__dict__.copy()
|
||||
del clean_dict["_cached_actor_def"]
|
||||
return clean_dict
|
||||
|
||||
def __setstate__(self, d: Dict[Any, Any]) -> None:
|
||||
self.__dict__ = d
|
||||
self._cached_actor_def = None
|
||||
|
||||
def update(
|
||||
self,
|
||||
deployment_config: DeploymentConfig = None,
|
||||
replica_config: ReplicaConfig = None,
|
||||
version: str = None,
|
||||
route_prefix: str = None,
|
||||
) -> "DeploymentInfo":
|
||||
return DeploymentInfo(
|
||||
deployment_config=deployment_config or self.deployment_config,
|
||||
replica_config=replica_config or self.replica_config,
|
||||
start_time_ms=self.start_time_ms,
|
||||
deployer_job_id=self.deployer_job_id,
|
||||
actor_name=self.actor_name,
|
||||
version=version or self.version,
|
||||
end_time_ms=self.end_time_ms,
|
||||
route_prefix=route_prefix or self.route_prefix,
|
||||
ingress=self.ingress,
|
||||
ingress_request_router=self.ingress_request_router,
|
||||
target_capacity=self.target_capacity,
|
||||
target_capacity_direction=self.target_capacity_direction,
|
||||
)
|
||||
|
||||
def set_target_capacity(
|
||||
self,
|
||||
new_target_capacity: Optional[float],
|
||||
new_target_capacity_direction: Optional[TargetCapacityDirection],
|
||||
):
|
||||
self.target_capacity = new_target_capacity
|
||||
self.target_capacity_direction = new_target_capacity_direction
|
||||
|
||||
def config_changed(self, other) -> bool:
|
||||
return (
|
||||
self.deployment_config != other.deployment_config
|
||||
or self.replica_config.ray_actor_options
|
||||
!= other.replica_config.ray_actor_options
|
||||
or other.version is None
|
||||
or self.version != other.version
|
||||
)
|
||||
|
||||
@property
|
||||
def actor_def(self):
|
||||
if self._cached_actor_def is None:
|
||||
assert self.actor_name is not None
|
||||
|
||||
# Break circular import :(.
|
||||
from ray.serve._private.replica import ReplicaActor
|
||||
|
||||
# Dynamically create a new class with custom name here so Ray picks it up
|
||||
# correctly in actor metadata table and observability stack.
|
||||
self._cached_actor_def = ray.remote(
|
||||
type(
|
||||
self.actor_name,
|
||||
(ReplicaActor,),
|
||||
dict(ReplicaActor.__dict__),
|
||||
)
|
||||
)
|
||||
|
||||
return self._cached_actor_def
|
||||
|
||||
@classmethod
|
||||
def from_proto(cls, proto: DeploymentInfoProto):
|
||||
deployment_config = (
|
||||
DeploymentConfig.from_proto(proto.deployment_config)
|
||||
if proto.deployment_config
|
||||
else None
|
||||
)
|
||||
|
||||
target_capacity = proto.target_capacity if proto.target_capacity != -1 else None
|
||||
|
||||
target_capacity_direction = TargetCapacityDirectionProto.Name(
|
||||
proto.target_capacity_direction
|
||||
)
|
||||
if target_capacity_direction == "UNSET":
|
||||
target_capacity_direction = None
|
||||
else:
|
||||
target_capacity_direction = TargetCapacityDirection(
|
||||
target_capacity_direction
|
||||
)
|
||||
|
||||
data = {
|
||||
"deployment_config": deployment_config,
|
||||
"replica_config": ReplicaConfig.from_proto(
|
||||
proto.replica_config,
|
||||
deployment_config.needs_pickle() if deployment_config else True,
|
||||
),
|
||||
"start_time_ms": proto.start_time_ms,
|
||||
"actor_name": proto.actor_name if proto.actor_name != "" else None,
|
||||
"version": proto.version if proto.version != "" else None,
|
||||
"end_time_ms": proto.end_time_ms if proto.end_time_ms != 0 else None,
|
||||
"deployer_job_id": ray.get_runtime_context().get_job_id(),
|
||||
"target_capacity": target_capacity,
|
||||
"target_capacity_direction": target_capacity_direction,
|
||||
"ingress_request_router": proto.ingress_request_router,
|
||||
}
|
||||
|
||||
return cls(**data)
|
||||
|
||||
def to_proto(self):
|
||||
data = {
|
||||
"start_time_ms": self.start_time_ms,
|
||||
"actor_name": self.actor_name,
|
||||
"version": self.version,
|
||||
"end_time_ms": self.end_time_ms,
|
||||
}
|
||||
if self.deployment_config:
|
||||
data["deployment_config"] = self.deployment_config.to_proto()
|
||||
if self.replica_config:
|
||||
data["replica_config"] = self.replica_config.to_proto()
|
||||
if self.target_capacity is None:
|
||||
data["target_capacity"] = -1
|
||||
else:
|
||||
data["target_capacity"] = self.target_capacity
|
||||
if self.target_capacity_direction is None:
|
||||
data["target_capacity_direction"] = TargetCapacityDirectionProto.UNSET
|
||||
else:
|
||||
data["target_capacity_direction"] = self.target_capacity_direction.name
|
||||
data["ingress_request_router"] = self.ingress_request_router
|
||||
return DeploymentInfoProto(**data)
|
||||
|
||||
def to_dict(self):
|
||||
# only use for logging purposes
|
||||
return {
|
||||
"deployment_config": (
|
||||
self.deployment_config.to_dict() if self.deployment_config else None
|
||||
),
|
||||
"replica_config": (
|
||||
self.replica_config.to_dict() if self.replica_config else None
|
||||
),
|
||||
"start_time_ms": self.start_time_ms,
|
||||
"actor_name": self.actor_name,
|
||||
"version": self.version,
|
||||
"end_time_ms": self.end_time_ms,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ray.dag import DAGNode
|
||||
from ray.dag.format_utils import get_dag_node_str
|
||||
from ray.serve.deployment import Deployment
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
|
||||
class DeploymentNode(DAGNode):
|
||||
"""Represents a deployment node in a DAG authored Ray DAG API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# For serve structured deployment, deployment body can be import path
|
||||
# to the class or function instead.
|
||||
deployment: Deployment,
|
||||
app_name: str,
|
||||
deployment_init_args: Tuple[Any],
|
||||
deployment_init_kwargs: Dict[str, Any],
|
||||
ray_actor_options: Dict[str, Any],
|
||||
other_args_to_resolve: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
# Assign instance variables in base class constructor.
|
||||
super().__init__(
|
||||
deployment_init_args,
|
||||
deployment_init_kwargs,
|
||||
ray_actor_options,
|
||||
other_args_to_resolve=other_args_to_resolve,
|
||||
)
|
||||
self._app_name = app_name
|
||||
self._deployment = deployment
|
||||
self._deployment_handle = DeploymentHandle(
|
||||
self._deployment.name, self._app_name
|
||||
)
|
||||
|
||||
def _copy_impl(
|
||||
self,
|
||||
new_args: List[Any],
|
||||
new_kwargs: Dict[str, Any],
|
||||
new_options: Dict[str, Any],
|
||||
new_other_args_to_resolve: Dict[str, Any],
|
||||
):
|
||||
return DeploymentNode(
|
||||
self._deployment,
|
||||
self._app_name,
|
||||
new_args,
|
||||
new_kwargs,
|
||||
new_options,
|
||||
other_args_to_resolve=new_other_args_to_resolve,
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return get_dag_node_str(self, str(self._deployment))
|
||||
|
||||
def get_deployment_name(self):
|
||||
return self._deployment.name
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator, Optional, TypeVar
|
||||
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Sentinel placed on the queue to signal that the request stream has ended.
|
||||
_STREAM_END = object()
|
||||
|
||||
|
||||
class gRPCDIReceiveStream(AsyncIterator[T]):
|
||||
"""Exposes a native gRPC request iterator to user code for direct ingress.
|
||||
|
||||
The native iterator is bound to the replica's server event loop, but user code
|
||||
may run on a separate event loop. When the loops differ, a fetch task drains the
|
||||
iterator on the server loop and hands messages to user code across loops; when
|
||||
they're the same, user code iterates the native iterator directly.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_iterator: AsyncIterator[T],
|
||||
user_event_loop: asyncio.AbstractEventLoop,
|
||||
*,
|
||||
cancel_event: Optional[asyncio.Event] = None,
|
||||
):
|
||||
self._request_iterator = request_iterator
|
||||
self._user_event_loop = user_event_loop
|
||||
self._same_event_loop = user_event_loop is get_or_create_event_loop()
|
||||
# Set when the client disconnects/errors mid-stream so the consumer's
|
||||
# gRPCInputStream reports is_cancelled() and ends gracefully.
|
||||
self._cancel_event = cancel_event
|
||||
# Lazily created so it binds to the user-code event loop (where it is
|
||||
# always accessed), never the server loop.
|
||||
self._queue: Optional[asyncio.Queue] = None
|
||||
self._fetch_task: Optional[asyncio.Task] = None
|
||||
|
||||
@property
|
||||
def _message_queue(self) -> asyncio.Queue:
|
||||
if self._queue is None:
|
||||
self._queue = asyncio.Queue()
|
||||
return self._queue
|
||||
|
||||
def _put(self, item: Any):
|
||||
self._message_queue.put_nowait(item)
|
||||
|
||||
def start(self) -> Optional[asyncio.Task]:
|
||||
"""Start draining the native iterator. Must be called on the server loop."""
|
||||
# We don't create another task to consume the input stream if we run on the
|
||||
# same event loop.
|
||||
if self._same_event_loop:
|
||||
return
|
||||
|
||||
self._fetch_task = asyncio.ensure_future(self._fetch_until_done())
|
||||
return self._fetch_task
|
||||
|
||||
def cancel(self):
|
||||
"""Stop draining the native iterator (e.g. if the consumer finished)."""
|
||||
if self._fetch_task is not None:
|
||||
self._fetch_task.cancel()
|
||||
|
||||
async def _fetch_until_done(self):
|
||||
"""Drive the native iterator on the server loop, forwarding each message.
|
||||
|
||||
A stream error (e.g. client disconnect) is treated as cancellation: the
|
||||
cancel event is set and the stream ends gracefully -- mirroring the proxy
|
||||
path (`receive_grpc_messages`) -- rather than surfacing the raw gRPC error
|
||||
to user code. A sentinel is always enqueued at the end so the consumer
|
||||
terminates.
|
||||
"""
|
||||
try:
|
||||
async for message in self._request_iterator:
|
||||
# Stop draining if the consumer cancelled (e.g. user code called
|
||||
# input_stream.cancel()).
|
||||
if self._cancel_event is not None and self._cancel_event.is_set():
|
||||
break
|
||||
self._user_event_loop.call_soon_threadsafe(self._put, message)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
if self._cancel_event is not None:
|
||||
self._user_event_loop.call_soon_threadsafe(self._cancel_event.set)
|
||||
finally:
|
||||
self._user_event_loop.call_soon_threadsafe(self._put, _STREAM_END)
|
||||
|
||||
def __aiter__(self) -> "gRPCDIReceiveStream":
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> T:
|
||||
"""Return the next request message. Runs on the user-code loop."""
|
||||
if self._same_event_loop:
|
||||
try:
|
||||
return await anext(self._request_iterator)
|
||||
except (asyncio.CancelledError, StopAsyncIteration):
|
||||
raise
|
||||
except Exception:
|
||||
if self._cancel_event is not None:
|
||||
self._cancel_event.set()
|
||||
raise StopAsyncIteration
|
||||
|
||||
item = await self._message_queue.get()
|
||||
if item is _STREAM_END:
|
||||
raise StopAsyncIteration
|
||||
return item
|
||||
@@ -0,0 +1,104 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from starlette.types import Message, Receive, Scope
|
||||
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class ASGIDIReceiveProxy:
|
||||
"""Proxies ASGI receive from an actor.
|
||||
|
||||
The `receive_asgi_messages` callback will be called repeatedly to fetch messages
|
||||
until a disconnect message is received.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scope: Scope,
|
||||
receive: Receive,
|
||||
user_event_loop: asyncio.AbstractEventLoop,
|
||||
):
|
||||
self._type = scope["type"] # Either 'http' or 'websocket'.
|
||||
# Lazy init the queue to ensure it is created in the user code event loop.
|
||||
self._queue = None
|
||||
self._receive = receive
|
||||
self._user_event_loop = user_event_loop
|
||||
self._disconnect_message = None
|
||||
|
||||
def _get_default_disconnect_message(self) -> Message:
|
||||
"""Return the appropriate disconnect message based on the connection type.
|
||||
|
||||
HTTP ASGI spec:
|
||||
https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event
|
||||
|
||||
WS ASGI spec:
|
||||
https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws
|
||||
"""
|
||||
if self._type == "websocket":
|
||||
return {
|
||||
"type": "websocket.disconnect",
|
||||
# 1005 is the default disconnect code according to the ASGI spec.
|
||||
"code": 1005,
|
||||
}
|
||||
else:
|
||||
return {"type": "http.disconnect"}
|
||||
|
||||
@property
|
||||
def queue(self) -> asyncio.Queue:
|
||||
if self._queue is None:
|
||||
self._queue = asyncio.Queue()
|
||||
|
||||
return self._queue
|
||||
|
||||
def put_message(self, msg: Message):
|
||||
self.queue.put_nowait(msg)
|
||||
|
||||
def close_queue(self):
|
||||
self.queue.close()
|
||||
|
||||
def fetch_until_disconnect_task(self) -> asyncio.Task:
|
||||
return asyncio.create_task(self._fetch_until_disconnect())
|
||||
|
||||
async def _fetch_until_disconnect(self):
|
||||
"""Fetch messages repeatedly until a disconnect message is received.
|
||||
|
||||
If a disconnect message is received, this function exits and returns it.
|
||||
|
||||
If an exception occurs, it will be raised on the next __call__ and no more
|
||||
messages will be received.
|
||||
|
||||
Note that this is meant to be called in the system event loop.
|
||||
"""
|
||||
while True:
|
||||
msg = await self._receive()
|
||||
if asyncio.get_running_loop() == self._user_event_loop:
|
||||
await self.queue.put(msg)
|
||||
else:
|
||||
self._user_event_loop.call_soon_threadsafe(self.put_message, msg)
|
||||
|
||||
if msg["type"] == "http.disconnect":
|
||||
self._disconnect_message = msg
|
||||
return None
|
||||
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
self._disconnect_message = msg
|
||||
return msg["code"]
|
||||
|
||||
async def __call__(self) -> Message:
|
||||
"""Return the next message once available.
|
||||
|
||||
This will repeatedly return a disconnect message once it's been received.
|
||||
"""
|
||||
if self.queue.empty() and self._disconnect_message is not None:
|
||||
return self._disconnect_message
|
||||
|
||||
message = await self.queue.get()
|
||||
if isinstance(message, Exception):
|
||||
raise message
|
||||
|
||||
return message
|
||||
@@ -0,0 +1,109 @@
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from ray import cloudpickle
|
||||
from ray.serve._private.common import DeploymentID, EndpointInfo
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.long_poll import LongPollHost, LongPollNamespace
|
||||
from ray.serve._private.storage.kv_store import KVStoreBase
|
||||
|
||||
CHECKPOINT_KEY = "serve-endpoint-state-checkpoint"
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class EndpointState:
|
||||
"""Manages all state for endpoints in the system.
|
||||
|
||||
This class is *not* thread safe, so any state-modifying methods should be
|
||||
called with a lock held.
|
||||
"""
|
||||
|
||||
def __init__(self, kv_store: KVStoreBase, long_poll_host: LongPollHost):
|
||||
self._kv_store = kv_store
|
||||
self._long_poll_host = long_poll_host
|
||||
self._endpoints: Dict[DeploymentID, EndpointInfo] = dict()
|
||||
|
||||
checkpoint = self._kv_store.get(CHECKPOINT_KEY)
|
||||
if checkpoint is not None:
|
||||
self._endpoints = cloudpickle.loads(checkpoint)
|
||||
|
||||
self._notify_route_table_changed()
|
||||
|
||||
def shutdown(self):
|
||||
self._kv_store.delete(CHECKPOINT_KEY)
|
||||
|
||||
def is_ready_for_shutdown(self) -> bool:
|
||||
"""Returns whether the endpoint checkpoint has been deleted.
|
||||
|
||||
Get the endpoint checkpoint from the kv store. If it is None, then it has been
|
||||
deleted.
|
||||
"""
|
||||
return self._kv_store.get(CHECKPOINT_KEY) is None
|
||||
|
||||
def _checkpoint(self):
|
||||
self._kv_store.put(CHECKPOINT_KEY, cloudpickle.dumps(self._endpoints))
|
||||
|
||||
def _notify_route_table_changed(self):
|
||||
self._long_poll_host.notify_changed(
|
||||
{LongPollNamespace.ROUTE_TABLE: self._endpoints}
|
||||
)
|
||||
|
||||
def _get_endpoint_for_route(self, route: str) -> Optional[DeploymentID]:
|
||||
for endpoint, info in self._endpoints.items():
|
||||
if info.route == route:
|
||||
return endpoint
|
||||
|
||||
return None
|
||||
|
||||
def update_endpoint(
|
||||
self, endpoint: DeploymentID, endpoint_info: EndpointInfo
|
||||
) -> None:
|
||||
"""Create or update the given endpoint.
|
||||
|
||||
This method is idempotent - if the endpoint already exists it will be
|
||||
updated to match the given parameters. Calling this twice with the same
|
||||
arguments is a no-op.
|
||||
"""
|
||||
|
||||
if self._endpoints.get(endpoint) == endpoint_info:
|
||||
return
|
||||
|
||||
existing_route_endpoint = self._get_endpoint_for_route(endpoint_info.route)
|
||||
if existing_route_endpoint is not None and existing_route_endpoint != endpoint:
|
||||
logger.debug(
|
||||
f'route_prefix "{endpoint_info.route}" is currently '
|
||||
f'registered to deployment "{existing_route_endpoint.name}". '
|
||||
f'Re-registering route_prefix "{endpoint_info.route}" to '
|
||||
f'deployment "{endpoint.name}".'
|
||||
)
|
||||
del self._endpoints[existing_route_endpoint]
|
||||
|
||||
self._endpoints[endpoint] = endpoint_info
|
||||
|
||||
self._checkpoint()
|
||||
self._notify_route_table_changed()
|
||||
|
||||
def get_endpoint_route(self, endpoint: DeploymentID) -> Optional[str]:
|
||||
if endpoint in self._endpoints:
|
||||
return self._endpoints[endpoint].route
|
||||
return None
|
||||
|
||||
def get_endpoints(self) -> Dict[DeploymentID, Dict[str, Any]]:
|
||||
endpoints = {}
|
||||
for endpoint, info in self._endpoints.items():
|
||||
endpoints[endpoint] = {
|
||||
"route": info.route,
|
||||
}
|
||||
return endpoints
|
||||
|
||||
def delete_endpoint(self, endpoint: DeploymentID) -> None:
|
||||
# This method must be idempotent. We should validate that the
|
||||
# specified endpoint exists on the client.
|
||||
if endpoint not in self._endpoints:
|
||||
return
|
||||
|
||||
del self._endpoints[endpoint]
|
||||
|
||||
self._checkpoint()
|
||||
self._notify_route_table_changed()
|
||||
@@ -0,0 +1,193 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_EVENT_LOOP_MONITORING_INTERVAL_S,
|
||||
SERVE_EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES_MS,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.util import metrics
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def setup_event_loop_monitoring(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
scheduling_latency: metrics.Histogram,
|
||||
iterations: metrics.Counter,
|
||||
tasks: metrics.Gauge,
|
||||
tags: Dict[str, str],
|
||||
interval_s: Optional[float] = None,
|
||||
) -> asyncio.Task:
|
||||
"""Start monitoring an event loop and recording metrics.
|
||||
|
||||
This function creates a background task that periodically measures:
|
||||
- How long it takes for the event loop to wake up after sleeping
|
||||
(scheduling latency / event loop lag)
|
||||
- The number of pending asyncio tasks
|
||||
|
||||
Args:
|
||||
loop: The asyncio event loop to monitor.
|
||||
scheduling_latency: Histogram metric to record scheduling latency.
|
||||
iterations: Counter metric to track monitoring iterations.
|
||||
tasks: Gauge metric to track number of pending tasks.
|
||||
tags: Dictionary of tags to apply to all metrics.
|
||||
interval_s: Optional override for the monitoring interval.
|
||||
Defaults to RAY_SERVE_EVENT_LOOP_MONITORING_INTERVAL_S.
|
||||
|
||||
Returns:
|
||||
The asyncio Task running the monitoring loop.
|
||||
"""
|
||||
if interval_s is None:
|
||||
interval_s = RAY_SERVE_EVENT_LOOP_MONITORING_INTERVAL_S
|
||||
|
||||
return loop.create_task(
|
||||
_run_monitoring_loop(
|
||||
loop=loop,
|
||||
schedule_latency=scheduling_latency,
|
||||
iterations=iterations,
|
||||
task_gauge=tasks,
|
||||
tags=tags,
|
||||
interval_s=interval_s,
|
||||
),
|
||||
name="serve_event_loop_monitoring",
|
||||
)
|
||||
|
||||
|
||||
async def _run_monitoring_loop(
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
schedule_latency: metrics.Histogram,
|
||||
iterations: metrics.Counter,
|
||||
task_gauge: metrics.Gauge,
|
||||
tags: Dict[str, str],
|
||||
interval_s: float,
|
||||
) -> None:
|
||||
"""Internal monitoring loop that runs until the event loop stops.
|
||||
|
||||
The scheduling latency is measured by comparing the actual elapsed time
|
||||
after sleeping to the expected sleep duration. In an ideal scenario
|
||||
with no blocking, the latency should be close to zero.
|
||||
"""
|
||||
while loop.is_running():
|
||||
iterations.inc(1, tags)
|
||||
num_tasks = len(asyncio.all_tasks(loop))
|
||||
task_gauge.set(num_tasks, tags)
|
||||
yield_time = time.monotonic()
|
||||
await asyncio.sleep(interval_s)
|
||||
elapsed_time = time.monotonic() - yield_time
|
||||
|
||||
# Historically, Ray's implementation of histograms are extremely finicky
|
||||
# with non-positive values (https://github.com/ray-project/ray/issues/26698).
|
||||
# Technically it shouldn't be possible for this to be negative, add the
|
||||
# max just to be safe.
|
||||
# Convert to milliseconds for the metric.
|
||||
latency_ms = max(0.0, (elapsed_time - interval_s) * 1000)
|
||||
schedule_latency.observe(latency_ms, tags)
|
||||
|
||||
|
||||
class EventLoopMonitor:
|
||||
TAG_KEY_COMPONENT = "component"
|
||||
TAG_KEY_LOOP_TYPE = "loop_type"
|
||||
TAG_KEY_ACTOR_ID = "actor_id"
|
||||
|
||||
# Component types
|
||||
COMPONENT_PROXY = "proxy"
|
||||
COMPONENT_REPLICA = "replica"
|
||||
COMPONENT_UNKNOWN = "unknown"
|
||||
|
||||
# Loop types
|
||||
LOOP_TYPE_MAIN = "main"
|
||||
LOOP_TYPE_USER_CODE = "user_code"
|
||||
LOOP_TYPE_ROUTER = "router"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component: str,
|
||||
loop_type: str,
|
||||
actor_id: str,
|
||||
interval_s: float = RAY_SERVE_EVENT_LOOP_MONITORING_INTERVAL_S,
|
||||
extra_tags: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Initialize the event loop monitor.
|
||||
|
||||
Args:
|
||||
component: The component type ("proxy" or "replica").
|
||||
loop_type: The type of event loop ("main", "user_code", or "router").
|
||||
actor_id: The ID of the actor where this event loop runs.
|
||||
interval_s: Optional override for the monitoring interval.
|
||||
extra_tags: Optional dictionary of additional tags to include in metrics.
|
||||
"""
|
||||
self._interval_s = interval_s
|
||||
self._tags = {
|
||||
self.TAG_KEY_COMPONENT: component,
|
||||
self.TAG_KEY_LOOP_TYPE: loop_type,
|
||||
self.TAG_KEY_ACTOR_ID: actor_id,
|
||||
}
|
||||
if extra_tags:
|
||||
self._tags.update(extra_tags)
|
||||
self._tag_keys = tuple(self._tags.keys())
|
||||
|
||||
# Create metrics
|
||||
self._scheduling_latency = metrics.Histogram(
|
||||
"serve_event_loop_scheduling_latency_ms",
|
||||
description=(
|
||||
"Latency of getting yielded control on the event loop in milliseconds. "
|
||||
"High values indicate the event loop is blocked."
|
||||
),
|
||||
boundaries=SERVE_EVENT_LOOP_LATENCY_HISTOGRAM_BOUNDARIES_MS,
|
||||
tag_keys=self._tag_keys,
|
||||
)
|
||||
self._scheduling_latency.set_default_tags(self._tags)
|
||||
|
||||
self._iterations = metrics.Counter(
|
||||
"serve_event_loop_monitoring_iterations",
|
||||
description=(
|
||||
"Number of times the event loop monitoring task has run. "
|
||||
"Can be used as a heartbeat."
|
||||
),
|
||||
tag_keys=self._tag_keys,
|
||||
)
|
||||
self._iterations.set_default_tags(self._tags)
|
||||
|
||||
self._tasks = metrics.Gauge(
|
||||
"serve_event_loop_tasks",
|
||||
description="Number of pending asyncio tasks on the event loop.",
|
||||
tag_keys=self._tag_keys,
|
||||
)
|
||||
self._tasks.set_default_tags(self._tags)
|
||||
|
||||
self._monitoring_task: Optional[asyncio.Task] = None
|
||||
|
||||
def start(self, loop: asyncio.AbstractEventLoop) -> asyncio.Task:
|
||||
"""Start monitoring the given event loop.
|
||||
|
||||
Args:
|
||||
loop: The asyncio event loop to monitor.
|
||||
|
||||
Returns:
|
||||
The asyncio Task running the monitoring loop.
|
||||
"""
|
||||
self._monitoring_task = setup_event_loop_monitoring(
|
||||
loop=loop,
|
||||
scheduling_latency=self._scheduling_latency,
|
||||
iterations=self._iterations,
|
||||
tasks=self._tasks,
|
||||
tags=self._tags,
|
||||
interval_s=self._interval_s,
|
||||
)
|
||||
logger.debug(
|
||||
f"Started event loop monitoring for {self._tags[self.TAG_KEY_COMPONENT]} "
|
||||
f"({self._tags[self.TAG_KEY_LOOP_TYPE]}) actor {self._tags[self.TAG_KEY_ACTOR_ID]}"
|
||||
)
|
||||
return self._monitoring_task
|
||||
|
||||
def stop(self):
|
||||
if self._monitoring_task is not None and not self._monitoring_task.done():
|
||||
self._monitoring_task.cancel()
|
||||
self._monitoring_task = None
|
||||
|
||||
@property
|
||||
def tags(self) -> Dict[str, str]:
|
||||
return self._tags.copy()
|
||||
@@ -0,0 +1,10 @@
|
||||
class DeploymentIsBeingDeletedError(Exception):
|
||||
"""Raised when an operation is attempted on a deployment that is being deleted."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExternalScalerDisabledError(Exception):
|
||||
"""Raised when the external scaling API is used but external_scaler_enabled is False."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
import math
|
||||
from typing import Any, Callable, Dict, Tuple
|
||||
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
|
||||
class GangSchedulingAutoscalingPolicy:
|
||||
"""Autoscaling policy that aligns replica counts to gang size multiples.
|
||||
|
||||
When gang scheduling is enabled, the number of replicas must always be a
|
||||
multiple of gang_size so that complete gangs can be scheduled or released
|
||||
atomically. This policy wraps a base scaling policy (e.g.
|
||||
replica_queue_length_autoscaling_policy or user's custom policy) and rounds
|
||||
up to the next gang-aligned multiple.
|
||||
|
||||
Always rounding up ensures the deployment never operates below the capacity
|
||||
the base policy requested, avoiding capacity deficits. The result is
|
||||
deterministic — the same desired count always produces the same output
|
||||
regardless of the current replica count, which prevents oscillation.
|
||||
|
||||
This class is not intended to be configured directly by users. It is
|
||||
automatically injected with a gang-scheduled deployment with autoscaling
|
||||
enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, base_scaling_policy: Callable, gang_size: int):
|
||||
self._base_scaling_policy = base_scaling_policy
|
||||
self._gang_size = gang_size
|
||||
|
||||
def __call__(self, ctx: AutoscalingContext) -> Tuple[int, Dict[str, Any]]:
|
||||
num_replicas, policy_state = self._base_scaling_policy(ctx)
|
||||
|
||||
if self._gang_size > 1 and num_replicas > 0:
|
||||
num_replicas = math.ceil(num_replicas / self._gang_size) * self._gang_size
|
||||
|
||||
return num_replicas, policy_state
|
||||
@@ -0,0 +1,231 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Callable, List, Optional, Sequence, Tuple
|
||||
from unittest.mock import Mock
|
||||
|
||||
import grpc
|
||||
from grpc.aio._server import Server
|
||||
|
||||
from ray.exceptions import RayActorError, RayTaskError
|
||||
from ray.serve._private.constants import (
|
||||
DEFAULT_GRPC_SERVER_OPTIONS,
|
||||
RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.proxy_request_response import ResponseStatus, gRPCStreamingType
|
||||
from ray.serve.config import gRPCOptions
|
||||
from ray.serve.exceptions import (
|
||||
BackPressureError,
|
||||
DeploymentUnavailableError,
|
||||
gRPCStatusError,
|
||||
)
|
||||
from ray.serve.generated.serve_pb2_grpc import add_RayServeAPIServiceServicer_to_server
|
||||
|
||||
# Maximum length for gRPC status details to avoid hitting HTTP/2 trailer limits.
|
||||
# gRPC default max metadata size is 8KB, so we use a conservative limit.
|
||||
GRPC_MAX_STATUS_DETAILS_LENGTH = 4096
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class gRPCGenericServer(Server):
|
||||
"""Custom gRPC server that will override all service method handlers.
|
||||
|
||||
Original implementation see: https://github.com/grpc/grpc/blob/
|
||||
60c1701f87cacf359aa1ad785728549eeef1a4b0/src/python/grpcio/grpc/aio/_server.py
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_handler_factory: Callable,
|
||||
*,
|
||||
extra_options: Optional[List[Tuple[str, str]]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
thread_pool=None,
|
||||
generic_handlers=(),
|
||||
interceptors=(),
|
||||
maximum_concurrent_rpcs=None,
|
||||
compression=None,
|
||||
options=DEFAULT_GRPC_SERVER_OPTIONS + (extra_options or []),
|
||||
)
|
||||
self.generic_rpc_handlers = []
|
||||
self.service_handler_factory = service_handler_factory
|
||||
|
||||
def add_generic_rpc_handlers(
|
||||
self, generic_rpc_handlers: Sequence[grpc.GenericRpcHandler]
|
||||
):
|
||||
"""Override generic_rpc_handlers before adding to the gRPC server.
|
||||
|
||||
This function will override all user defined handlers to have
|
||||
1. None `response_serializer` so the server can pass back the
|
||||
raw protobuf bytes to the user.
|
||||
2. `unary_unary` is always calling the unary function generated via
|
||||
`self.service_handler_factory`
|
||||
3. `unary_stream` is always calling the streaming function generated via
|
||||
`self.service_handler_factory`
|
||||
4. `stream_unary` for client streaming requests
|
||||
5. `stream_stream` for bidirectional streaming requests
|
||||
"""
|
||||
serve_rpc_handlers = {}
|
||||
rpc_handler = generic_rpc_handlers[0]
|
||||
for service_method, method_handler in rpc_handler._method_handlers.items():
|
||||
serve_method_handler = method_handler._replace(
|
||||
response_serializer=None,
|
||||
unary_unary=self.service_handler_factory(
|
||||
service_method=service_method,
|
||||
streaming_type=gRPCStreamingType.UNARY_UNARY,
|
||||
),
|
||||
unary_stream=self.service_handler_factory(
|
||||
service_method=service_method,
|
||||
streaming_type=gRPCStreamingType.UNARY_STREAM,
|
||||
),
|
||||
stream_unary=self.service_handler_factory(
|
||||
service_method=service_method,
|
||||
streaming_type=gRPCStreamingType.STREAM_UNARY,
|
||||
),
|
||||
stream_stream=self.service_handler_factory(
|
||||
service_method=service_method,
|
||||
streaming_type=gRPCStreamingType.STREAM_STREAM,
|
||||
),
|
||||
)
|
||||
serve_rpc_handlers[service_method] = serve_method_handler
|
||||
generic_rpc_handlers[0]._method_handlers = serve_rpc_handlers
|
||||
self.generic_rpc_handlers.append(generic_rpc_handlers)
|
||||
super().add_generic_rpc_handlers(generic_rpc_handlers)
|
||||
|
||||
|
||||
async def start_grpc_server(
|
||||
service_handler_factory: Callable,
|
||||
grpc_options: gRPCOptions,
|
||||
*,
|
||||
event_loop: asyncio.AbstractEventLoop,
|
||||
enable_so_reuseport: bool = False,
|
||||
) -> Tuple[asyncio.Task, gRPCGenericServer]:
|
||||
"""Start a gRPC server that handles requests with the service handler factory.
|
||||
|
||||
Returns a task that blocks until the server exits (e.g., due to error) and
|
||||
the server object itself (so callers can shut it down gracefully).
|
||||
"""
|
||||
from ray.serve._private.default_impl import add_grpc_address
|
||||
|
||||
server = gRPCGenericServer(
|
||||
service_handler_factory,
|
||||
extra_options=[("grpc.so_reuseport", str(int(enable_so_reuseport)))],
|
||||
)
|
||||
add_grpc_address(server, f"[::]:{grpc_options.port}")
|
||||
|
||||
# Add built-in gRPC service and user-defined services to the server.
|
||||
# We pass a mock servicer because the actual implementation will be overwritten
|
||||
# in the gRPCGenericServer implementation.
|
||||
mock_servicer = Mock()
|
||||
for servicer_fn in [
|
||||
add_RayServeAPIServiceServicer_to_server
|
||||
] + grpc_options.grpc_servicer_func_callable:
|
||||
servicer_fn(mock_servicer, server)
|
||||
|
||||
await server.start()
|
||||
return event_loop.create_task(server.wait_for_termination()), server
|
||||
|
||||
|
||||
def _truncate_message(
|
||||
message: str, max_length: int = GRPC_MAX_STATUS_DETAILS_LENGTH
|
||||
) -> str:
|
||||
"""Truncate a message to avoid exceeding HTTP/2 trailer limits.
|
||||
|
||||
gRPC status details are sent as part of HTTP/2 trailers, which have a fixed size limit.
|
||||
If the message (e.g., a stack trace) is too long, it can cause issues on the client side.
|
||||
"""
|
||||
if len(message) <= max_length:
|
||||
return message
|
||||
truncation_notice = "... [truncated]"
|
||||
return message[: max_length - len(truncation_notice)] + truncation_notice
|
||||
|
||||
|
||||
def get_grpc_response_status(
|
||||
exc: BaseException, request_timeout_s: float, request_id: str
|
||||
) -> ResponseStatus:
|
||||
if isinstance(exc, TimeoutError):
|
||||
message = f"Request timed out after {request_timeout_s}s."
|
||||
return ResponseStatus(
|
||||
code=grpc.StatusCode.DEADLINE_EXCEEDED,
|
||||
is_error=True,
|
||||
message=message,
|
||||
)
|
||||
elif isinstance(exc, asyncio.CancelledError):
|
||||
message = f"Client for request {request_id} disconnected."
|
||||
return ResponseStatus(
|
||||
code=grpc.StatusCode.CANCELLED,
|
||||
is_error=True,
|
||||
message=message,
|
||||
)
|
||||
elif isinstance(exc, BackPressureError):
|
||||
return ResponseStatus(
|
||||
code=grpc.StatusCode.RESOURCE_EXHAUSTED,
|
||||
is_error=True,
|
||||
message=exc.message,
|
||||
)
|
||||
elif isinstance(exc, DeploymentUnavailableError):
|
||||
if isinstance(exc, RayTaskError):
|
||||
logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False})
|
||||
return ResponseStatus(
|
||||
code=grpc.StatusCode.UNAVAILABLE,
|
||||
is_error=True,
|
||||
message=exc.message,
|
||||
)
|
||||
elif isinstance(exc, gRPCStatusError):
|
||||
# User set a gRPC status code before raising the exception.
|
||||
# Respect the user's status code instead of returning INTERNAL.
|
||||
original_exc = exc.original_exception
|
||||
if isinstance(original_exc, (RayActorError, RayTaskError)):
|
||||
logger.warning(
|
||||
f"Request failed: {original_exc}", extra={"log_to_stderr": False}
|
||||
)
|
||||
else:
|
||||
logger.exception(
|
||||
f"Request failed with user-set gRPC status code {exc.grpc_code}."
|
||||
)
|
||||
# Use user-set details if provided, otherwise use the original exception message.
|
||||
message = exc.grpc_details if exc.grpc_details else str(original_exc)
|
||||
return ResponseStatus(
|
||||
code=exc.grpc_code,
|
||||
is_error=True,
|
||||
message=_truncate_message(message),
|
||||
)
|
||||
else:
|
||||
if isinstance(exc, (RayActorError, RayTaskError)):
|
||||
logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False})
|
||||
else:
|
||||
logger.exception("Request failed due to unexpected error.")
|
||||
return ResponseStatus(
|
||||
code=grpc.StatusCode.INTERNAL,
|
||||
is_error=True,
|
||||
message=_truncate_message(str(exc)),
|
||||
)
|
||||
|
||||
|
||||
def set_grpc_code_and_details(
|
||||
context: grpc._cython.cygrpc._ServicerContext, status: ResponseStatus
|
||||
):
|
||||
# Only the latest code and details will take effect. If the user already
|
||||
# set them to a truthy value in the context, skip setting them with Serve's
|
||||
# default values. By default, if nothing is set, the code is 0 and the
|
||||
# details is "", which both are falsy. So if the user did not set them or
|
||||
# if they're explicitly set to falsy values, such as None, Serve will
|
||||
# continue to set them with our default values.
|
||||
if not context.code():
|
||||
context.set_code(status.code)
|
||||
if not context.details():
|
||||
context.set_details(status.message)
|
||||
|
||||
|
||||
def set_proxy_default_grpc_options(grpc_options) -> gRPCOptions:
|
||||
grpc_options = deepcopy(grpc_options) or gRPCOptions()
|
||||
|
||||
if grpc_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S:
|
||||
grpc_options.request_timeout_s = (
|
||||
grpc_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S
|
||||
)
|
||||
|
||||
return grpc_options
|
||||
@@ -0,0 +1,82 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, fields
|
||||
|
||||
import ray
|
||||
from ray.serve._private.common import DeploymentHandleSource
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP,
|
||||
RAY_SERVE_USE_GRPC_BY_DEFAULT,
|
||||
)
|
||||
from ray.serve._private.utils import DEFAULT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InitHandleOptionsBase(ABC):
|
||||
"""Init options for each ServeHandle instance.
|
||||
|
||||
These fields can be set by calling `.init()` on a handle before
|
||||
sending the first request.
|
||||
"""
|
||||
|
||||
_prefer_local_routing: bool = False
|
||||
_source: DeploymentHandleSource = DeploymentHandleSource.UNKNOWN
|
||||
_run_router_in_separate_loop: bool = RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def create(cls, **kwargs) -> "InitHandleOptionsBase":
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InitHandleOptions(InitHandleOptionsBase):
|
||||
@classmethod
|
||||
def create(cls, **kwargs) -> "InitHandleOptions":
|
||||
for k in list(kwargs.keys()):
|
||||
if kwargs[k] == DEFAULT.VALUE:
|
||||
# Use default value
|
||||
del kwargs[k]
|
||||
|
||||
# Detect replica source for handles
|
||||
if (
|
||||
"_source" not in kwargs
|
||||
and ray.serve.context._get_internal_replica_context() is not None
|
||||
):
|
||||
kwargs["_source"] = DeploymentHandleSource.REPLICA
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicHandleOptionsBase(ABC):
|
||||
"""Dynamic options for each ServeHandle instance.
|
||||
|
||||
These fields can be changed by calling `.options()` on a handle.
|
||||
"""
|
||||
|
||||
method_name: str = "__call__"
|
||||
multiplexed_model_id: str = ""
|
||||
session_id: str = ""
|
||||
stream: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def copy_and_update(self, **kwargs) -> "DynamicHandleOptionsBase":
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DynamicHandleOptions(DynamicHandleOptionsBase):
|
||||
_by_reference: bool = not RAY_SERVE_USE_GRPC_BY_DEFAULT
|
||||
request_serialization: str = "cloudpickle"
|
||||
response_serialization: str = "cloudpickle"
|
||||
|
||||
def copy_and_update(self, **kwargs) -> "DynamicHandleOptions":
|
||||
new_kwargs = {}
|
||||
|
||||
for f in fields(self):
|
||||
if f.name not in kwargs or kwargs[f.name] == DEFAULT.VALUE:
|
||||
new_kwargs[f.name] = getattr(self, f.name)
|
||||
else:
|
||||
new_kwargs[f.name] = kwargs[f.name]
|
||||
|
||||
return DynamicHandleOptions(**new_kwargs)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,514 @@
|
||||
"""Metrics collection for the HAProxy ingress request router data path.
|
||||
|
||||
HAProxy is configured to emit one RFC 5424 syslog line per request to a
|
||||
dedicated Unix dgram socket. Existing rfc3164 log targets are unaffected.
|
||||
`HAProxyMetricsCollector` owns the parsing and the `ray.util.metrics`
|
||||
objects; `_DatagramHandler` is the asyncio glue that hands datagrams in.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from ray.serve._private.common import RequestProtocol
|
||||
from ray.serve._private.haproxy import HAProxyApi
|
||||
from ray.serve._private.request_ingress_metrics import RequestIngressMetrics
|
||||
from ray.util import metrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SD-ID we publish under. The leading bracket + this string is what the
|
||||
# parser anchors on; only lines containing this section are processed.
|
||||
_SD_ID = "serve@1"
|
||||
|
||||
# RFC 5424 SD element looks like `[serve@1 key="value" key="value"]`. Capture
|
||||
# the body between the SD-ID and the closing `]`. A `]` inside a value is
|
||||
# escaped as `\]`, so the body matches escaped pairs and stops at the first
|
||||
# unescaped `]`.
|
||||
_SD_SECTION_RE = re.compile(r"\[" + re.escape(_SD_ID) + r"(?P<body>(?:[^\]\\]|\\.)*)\]")
|
||||
|
||||
# Capture `key=value` pairs where the value is either RFC 5424 quoted (may
|
||||
# contain spaces; backslash escapes allowed) or a bare token. HAProxy quotes
|
||||
# sample-fetch values (`%[var(...)]`) and string aliases like `%HM`, but renders
|
||||
# numeric aliases (`%ST`, `%Ta`) unquoted, so the parser must accept both.
|
||||
_KV_RE = re.compile(r'(\w+)=(?:"((?:[^"\\]|\\.)*)"|(\S+))')
|
||||
|
||||
# HAProxy renders unset txn vars as an empty string in log-format. We map
|
||||
# that to None so callers don't have to distinguish "unset" from "empty".
|
||||
_UNSET = ""
|
||||
|
||||
# HAProxy escapes some characters in a quoted log value with a leading
|
||||
# backslash. Drop the backslash so the tag holds the original name.
|
||||
# Example: `a\"b` -> `a"b`.
|
||||
_UNESCAPE_RE = re.compile(r"\\(.)")
|
||||
|
||||
|
||||
def _unescape(value: str) -> str:
|
||||
return _UNESCAPE_RE.sub(r"\1", value)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedMetrics:
|
||||
"""One per-request observation, parsed from the SD section.
|
||||
|
||||
The first group is the general per-request ingress data present on every
|
||||
HTTP request through the frontend; it feeds the `serve_num_http_*` /
|
||||
`serve_http_request_latency_ms` families. The `ingress_request_*` fields
|
||||
are router-specific and only populated when ingress-request-router metrics
|
||||
are enabled and the request went through (or attempted) the router.
|
||||
"""
|
||||
|
||||
app: Optional[str] = None
|
||||
ingress_request_intended_server: Optional[str] = None
|
||||
ingress_request_actual_server: Optional[str] = None
|
||||
ingress_request_router_latency_us: Optional[int] = None
|
||||
ingress_request_body_truncated_full_length: Optional[int] = None
|
||||
ingress_request_via_router: bool = False
|
||||
ingress_request_failed: Optional[str] = None
|
||||
route: Optional[str] = None
|
||||
method: Optional[str] = None
|
||||
status_code: Optional[str] = None
|
||||
latency_ms: Optional[int] = None
|
||||
deployment: Optional[str] = None
|
||||
# HAProxy 2-char session termination state (%ts). A leading "C" means the
|
||||
# client aborted the connection; the recorder maps that to status 499.
|
||||
termination_state: Optional[str] = None
|
||||
|
||||
|
||||
class HAProxyMetricsCollector:
|
||||
"""Owns every `serve_haproxy_*` metric for one proxy node.
|
||||
|
||||
Two families of metrics live here:
|
||||
|
||||
- Per-request, push-based ingress-request-router metrics (Counters /
|
||||
Histogram), fed by HAProxy datagrams. `parse_line` and `record` are
|
||||
exposed for unit tests that want to drive these without binding
|
||||
anything; `bind_and_attach` wires an `AF_UNIX` dgram socket to the
|
||||
loop and `close` tears it down.
|
||||
- Node-level, poll-based gauges (process count and broadcasted-vs-reported
|
||||
target mismatch), sampled from an `HAProxyApi` on a periodic loop started
|
||||
by `start_node_metrics_polling`.
|
||||
|
||||
The node-level gauges are always emitted; the datagram reader is only
|
||||
bound when ingress-request-router metrics are enabled.
|
||||
"""
|
||||
|
||||
# Sub-millisecond to 1s, biased toward the expected sub-10ms range for
|
||||
# a healthy local router consultation.
|
||||
_LATENCY_BUCKETS_MS = [
|
||||
0.5,
|
||||
1.0,
|
||||
2.0,
|
||||
5.0,
|
||||
10.0,
|
||||
25.0,
|
||||
50.0,
|
||||
100.0,
|
||||
250.0,
|
||||
500.0,
|
||||
1000.0,
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
haproxy_api: HAProxyApi,
|
||||
node_id: str,
|
||||
node_ip_address: str = "",
|
||||
) -> None:
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._socket_path: Optional[str] = None
|
||||
|
||||
# Source for the node-level poll loop (process count + target mismatch).
|
||||
self._haproxy_api = haproxy_api
|
||||
self._node_id = node_id
|
||||
self._node_metrics_task: Optional[asyncio.Task] = None
|
||||
|
||||
# Per-request HTTP ingress metrics (serve_num_http_requests, latency,
|
||||
# errors). In HAProxy mode these are emitted here from HAProxy log
|
||||
# datagrams rather than by a Python proxy, so requests HAProxy terminates
|
||||
# itself (e.g. 404, /-/routes, health checks) are still counted. HTTP
|
||||
# only -- HAProxy does not expose gRPC status (it lives in HTTP/2
|
||||
# trailers), so gRPC ingress metrics stay on the replica. The
|
||||
# ongoing-requests gauge is not driven here: it can't be derived from
|
||||
# per-request log lines.
|
||||
self.request_ingress_metrics = RequestIngressMetrics(
|
||||
RequestProtocol.HTTP,
|
||||
source="proxy",
|
||||
node_id=node_id,
|
||||
node_ip_address=node_ip_address,
|
||||
)
|
||||
|
||||
self.truncated_bodies_counter = metrics.Counter(
|
||||
"serve_haproxy_ingress_router_truncations",
|
||||
description=(
|
||||
"Count of requests whose body was truncated by HAProxy "
|
||||
"(exceeded tune.bufsize) before being forwarded to the "
|
||||
"ingress request router."
|
||||
),
|
||||
tag_keys=("application",),
|
||||
)
|
||||
self.latency_histogram = metrics.Histogram(
|
||||
"serve_haproxy_ingress_router_latency_ms",
|
||||
description=(
|
||||
"Wall-clock time (in milliseconds) HAProxy spent to resolve "
|
||||
"the request to a server via the ingress request router. "
|
||||
"Only includes successful routing attempts."
|
||||
),
|
||||
boundaries=self._LATENCY_BUCKETS_MS,
|
||||
tag_keys=("application", "outcome"),
|
||||
)
|
||||
self.replica_mismatches_counter = metrics.Counter(
|
||||
"serve_haproxy_ingress_router_server_mismatch",
|
||||
description=(
|
||||
"Count of requests where HAProxy ultimately routed to a "
|
||||
"different replica than the one the ingress request router "
|
||||
"returned (typically because the named replica was DOWN and "
|
||||
"option redispatch picked another)."
|
||||
),
|
||||
tag_keys=("application",),
|
||||
)
|
||||
self.failures_counter = metrics.Counter(
|
||||
"serve_haproxy_ingress_router_failures",
|
||||
description=(
|
||||
"Count of ingress-request-router consultations that failed "
|
||||
"to pin a replica, broken down by reason. Possible reasons: "
|
||||
"'router_unreachable' (socket connect/send/recv failed), "
|
||||
"'router_non_200' (router returned a non-200 status), "
|
||||
"'unparseable_replica_id' (router 200 but response body "
|
||||
"did not contain a string replica_id), "
|
||||
"'unknown_replica_id' (router returned a replica_id not "
|
||||
"present in the current replica map). All reasons return 503 "
|
||||
"to the client except 'unknown_replica_id', which routes to the "
|
||||
"fallback proxy when one is available (otherwise 503)."
|
||||
),
|
||||
tag_keys=("application", "reason"),
|
||||
)
|
||||
self.requests_counter = metrics.Counter(
|
||||
"serve_haproxy_ingress_router_requests",
|
||||
description=(
|
||||
"The number of requests that have been processed by "
|
||||
"the ingress request router. This includes both successful "
|
||||
"and failed requests."
|
||||
),
|
||||
tag_keys=("application",),
|
||||
)
|
||||
|
||||
# Node-level gauges, sampled by _report_node_metrics_forever.
|
||||
self.process_count_gauge = metrics.Gauge(
|
||||
"serve_haproxy_process_count",
|
||||
description=(
|
||||
"Number of HAProxy processes running on the node for this proxy, "
|
||||
"spanning the live worker, draining workers from prior reloads, "
|
||||
"and any leaked/orphaned workers. A value persistently above 1 "
|
||||
"indicates HAProxy processes are not being reaped."
|
||||
),
|
||||
tag_keys=("node_id",),
|
||||
)
|
||||
self.target_mismatch_gauge = metrics.Gauge(
|
||||
"serve_haproxy_target_mismatch",
|
||||
description=(
|
||||
"Number of targets that differ between the controller's "
|
||||
"broadcasted target set and the targets HAProxy actually reports "
|
||||
"in its stats on this node (symmetric set difference). A non-zero "
|
||||
"value means the HAProxy config has not yet converged to the "
|
||||
"broadcasted targets."
|
||||
),
|
||||
tag_keys=("node_id",),
|
||||
)
|
||||
self.process_count_gauge.set_default_tags({"node_id": node_id})
|
||||
self.target_mismatch_gauge.set_default_tags({"node_id": node_id})
|
||||
|
||||
@staticmethod
|
||||
def parse_line(line: bytes) -> Optional[ParsedMetrics]:
|
||||
"""Extract metric fields from one RFC 5424 log datagram.
|
||||
|
||||
Returns `None` when the SD section is absent or unparseable. The
|
||||
rest of the syslog line (priority, timestamp, message) is ignored.
|
||||
"""
|
||||
try:
|
||||
text = line.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
match = _SD_SECTION_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
kv: dict = {}
|
||||
for key, quoted, bare in _KV_RE.findall(match.group("body")):
|
||||
# Bare tokens (numeric aliases) are used as-is; quoted values are
|
||||
# unescaped so the tag holds the original string. An empty quoted
|
||||
# value ("") becomes None.
|
||||
value = bare if bare else _unescape(quoted)
|
||||
kv[key] = value if value != _UNSET else None
|
||||
|
||||
def as_int(key: str) -> Optional[int]:
|
||||
raw = kv.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return ParsedMetrics(
|
||||
app=kv.get("app"),
|
||||
ingress_request_intended_server=kv.get("intended"),
|
||||
ingress_request_actual_server=kv.get("actual"),
|
||||
ingress_request_router_latency_us=as_int("router_latency_us"),
|
||||
ingress_request_body_truncated_full_length=as_int(
|
||||
"body_truncated_full_length"
|
||||
),
|
||||
# HAProxy renders booleans as "1"/"0"; absence as "" -> False.
|
||||
ingress_request_via_router=kv.get("via_router") == "1",
|
||||
ingress_request_failed=kv.get("failed"),
|
||||
route=kv.get("route"),
|
||||
method=kv.get("method"),
|
||||
status_code=kv.get("status"),
|
||||
latency_ms=as_int("latency_ms"),
|
||||
deployment=kv.get("deployment"),
|
||||
termination_state=kv.get("term_state"),
|
||||
)
|
||||
|
||||
def _record_ingress_request(self, parsed: ParsedMetrics) -> None:
|
||||
"""Emit the per-request RequestIngressMetrics for one observation.
|
||||
|
||||
Mirrors what the Python proxy records per request (the
|
||||
`serve_num_http_*` and `serve_http_request_latency_ms` families) --
|
||||
for every request, including ones HAProxy terminates itself (404,
|
||||
`/-/routes`, health checks), matching the proxy's tags (`application`
|
||||
and `route` are empty for those). Skips lines with no status, which
|
||||
aren't real request observations.
|
||||
"""
|
||||
if parsed.status_code is None:
|
||||
return
|
||||
|
||||
# A client abort (HAProxy termination state with a leading "C") is
|
||||
# recorded as 499, matching the Python proxy's client-disconnect
|
||||
# convention.
|
||||
status_code = parsed.status_code
|
||||
if parsed.termination_state and parsed.termination_state.startswith("C"):
|
||||
status_code = "499"
|
||||
|
||||
try:
|
||||
is_error = int(status_code) >= 400
|
||||
except ValueError:
|
||||
# Non-numeric status (shouldn't happen for HTTP); treat as non-error.
|
||||
is_error = False
|
||||
|
||||
self.request_ingress_metrics.record_request(
|
||||
route=parsed.route or "",
|
||||
method=parsed.method or "",
|
||||
application=parsed.app or "",
|
||||
status_code=status_code,
|
||||
# %Ta is integer-ms resolution; sub-ms requests round to 0.
|
||||
latency_ms=float(parsed.latency_ms or 0),
|
||||
is_error=is_error,
|
||||
deployment_name=parsed.deployment or "",
|
||||
)
|
||||
|
||||
def record(self, parsed: ParsedMetrics) -> None:
|
||||
"""Update metrics from one parsed observation.
|
||||
|
||||
First records the general per-request ingress metrics (every HTTP
|
||||
request). Then records the router-specific metrics, which only apply to
|
||||
requests that went through (or attempted) the ingress request router:
|
||||
- `ingress_request_failed` set: the Lua action set `txn.ingress_request_router_failed`
|
||||
and returned early. Bump the failures counter with the reason; no
|
||||
replica was pinned, so other router metrics don't apply.
|
||||
- `ingress_request_via_router` true: the Lua action successfully pinned a replica.
|
||||
Record latency, truncation, and replica-mismatch as applicable.
|
||||
- Neither: the request didn't go through the router path at all
|
||||
(no router-bearing app matched, or router state not yet pushed).
|
||||
No router metrics to record.
|
||||
"""
|
||||
# General per-request ingress metrics, independent of the router path.
|
||||
self._record_ingress_request(parsed)
|
||||
|
||||
# `application` tag is required by the metric definitions; default
|
||||
# to "unknown" rather than dropping the observation, so misconfigured
|
||||
# frontends still show up in the data.
|
||||
app_tag = parsed.app or "unknown"
|
||||
tags = {"application": app_tag}
|
||||
|
||||
if parsed.ingress_request_via_router and not parsed.ingress_request_failed:
|
||||
self.requests_counter.inc(tags=tags)
|
||||
|
||||
if parsed.ingress_request_body_truncated_full_length is not None:
|
||||
self.truncated_bodies_counter.inc(tags=tags)
|
||||
|
||||
# Only count mismatch when we have both sides AND the request actually
|
||||
# reached a server (ingress_request_actual_server is not None / "<NOSRV>"). If the
|
||||
# router pinned a replica but the request was rejected upstream of
|
||||
# server selection (e.g. queued and aborted), HAProxy logs "<NOSRV>"
|
||||
# for %s — we treat that as "not a mismatch, not a match".
|
||||
if (
|
||||
parsed.ingress_request_intended_server
|
||||
and parsed.ingress_request_actual_server
|
||||
and parsed.ingress_request_actual_server != "<NOSRV>"
|
||||
and parsed.ingress_request_intended_server
|
||||
!= parsed.ingress_request_actual_server
|
||||
):
|
||||
self.replica_mismatches_counter.inc(tags=tags)
|
||||
elif parsed.ingress_request_failed:
|
||||
self.requests_counter.inc(tags=tags)
|
||||
self.failures_counter.inc(
|
||||
tags={**tags, "reason": parsed.ingress_request_failed}
|
||||
)
|
||||
else:
|
||||
return
|
||||
|
||||
if parsed.ingress_request_router_latency_us is not None:
|
||||
self.latency_histogram.observe(
|
||||
parsed.ingress_request_router_latency_us / 1_000.0,
|
||||
tags={
|
||||
**tags,
|
||||
"outcome": "failure"
|
||||
if parsed.ingress_request_failed
|
||||
else "success",
|
||||
},
|
||||
)
|
||||
|
||||
async def _report_node_metrics_forever(self, interval_s: float) -> None:
|
||||
"""Background task to emit the node-level HAProxy observability gauges."""
|
||||
consecutive_errors = 0
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval_s)
|
||||
# count_haproxy_processes does blocking /proc IO that scales
|
||||
# with the node's process count; run it in a thread so the
|
||||
# actor's event loop (health checks, reloads) isn't stalled.
|
||||
loop = asyncio.get_running_loop()
|
||||
count = await loop.run_in_executor(
|
||||
None, self._haproxy_api.count_haproxy_processes
|
||||
)
|
||||
self.process_count_gauge.set(count)
|
||||
self.target_mismatch_gauge.set(
|
||||
await self._haproxy_api.compute_target_mismatch()
|
||||
)
|
||||
# num_ongoing_requests can't be derived from the per-request log
|
||||
# lines, so it's sampled here from HAProxy's backend `scur`.
|
||||
self.request_ingress_metrics.set_num_ongoing_requests(
|
||||
await self._haproxy_api.count_ongoing_http_requests()
|
||||
)
|
||||
consecutive_errors = 0
|
||||
except Exception:
|
||||
logger.exception("Unexpected error reporting HAProxy node 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 start_node_metrics_polling(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
interval_s: float,
|
||||
) -> None:
|
||||
"""Start the periodic loop that emits the node-level gauges."""
|
||||
if self._node_metrics_task is None:
|
||||
self._node_metrics_task = loop.create_task(
|
||||
self._report_node_metrics_forever(interval_s)
|
||||
)
|
||||
|
||||
def start(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
*,
|
||||
poll_interval_s: float,
|
||||
metrics_socket_path: Optional[str] = None,
|
||||
) -> asyncio.Task:
|
||||
"""Start all metric collection for this proxy node.
|
||||
|
||||
Always starts the node-level gauge poll loop. Also always creates the
|
||||
dgram socket directory and binds the per-request reader at `metrics_socket_path`
|
||||
(where HAProxy writes one RFC 5424 line per request), returning the
|
||||
bind task so the caller can await it (e.g. to surface bind failures at
|
||||
actor-readiness time).
|
||||
"""
|
||||
self.start_node_metrics_polling(loop, poll_interval_s)
|
||||
os.makedirs(os.path.dirname(metrics_socket_path), exist_ok=True)
|
||||
return loop.create_task(self.bind_and_attach(metrics_socket_path, loop=loop))
|
||||
|
||||
async def bind_and_attach(
|
||||
self,
|
||||
socket_path: str,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
) -> None:
|
||||
"""Bind a Unix dgram socket at `socket_path` and register the
|
||||
asyncio reader on `loop`.
|
||||
|
||||
Many HAProxy frontends can write to the same socket; dgram
|
||||
delivery preserves message boundaries so the reader gets one
|
||||
observation per `recvfrom`.
|
||||
|
||||
Idempotent: if a transport is already attached, it is closed
|
||||
first. On failure, the collector is left in an unbound state and
|
||||
`close()` is still safe to call.
|
||||
"""
|
||||
if self._transport is not None:
|
||||
self.close()
|
||||
|
||||
if loop is None:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
os.unlink(socket_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.bind(socket_path)
|
||||
# Match the existing admin socket pattern (mode 666) so HAProxy
|
||||
# processes running as a different user can still send to it.
|
||||
os.chmod(socket_path, 0o666)
|
||||
transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: _DatagramHandler(self), sock=sock
|
||||
)
|
||||
except Exception:
|
||||
sock.close()
|
||||
raise
|
||||
|
||||
self._socket_path = socket_path
|
||||
self._transport = transport
|
||||
|
||||
def close(self) -> None:
|
||||
"""Tear down the node-metrics loop, the dgram transport, and the
|
||||
socket file.
|
||||
|
||||
Safe to call multiple times; safe to call without ever having
|
||||
bound or started polling. The metric objects survive close — they
|
||||
are owned by Ray's metric registry, not this instance.
|
||||
"""
|
||||
if self._node_metrics_task is not None:
|
||||
self._node_metrics_task.cancel()
|
||||
self._node_metrics_task = None
|
||||
if self._transport is not None:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
if self._socket_path is not None:
|
||||
try:
|
||||
os.unlink(self._socket_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self._socket_path = None
|
||||
|
||||
|
||||
class _DatagramHandler(asyncio.DatagramProtocol):
|
||||
def __init__(self, collector: HAProxyMetricsCollector) -> None:
|
||||
self._collector = collector
|
||||
|
||||
def datagram_received(self, data: bytes, addr) -> None: # noqa: D401
|
||||
try:
|
||||
parsed = self._collector.parse_line(data)
|
||||
if parsed is not None:
|
||||
self._collector.record(parsed)
|
||||
except Exception:
|
||||
# A malformed datagram must never crash the proxy actor. Log
|
||||
# once per occurrence at debug to keep busy frontends quiet.
|
||||
logger.debug("Failed to handle HAProxy metrics datagram", exc_info=True)
|
||||
@@ -0,0 +1,435 @@
|
||||
HAPROXY_HEALTHZ_RULES_TEMPLATE = """ # Health check endpoint
|
||||
acl healthcheck path -i {{ config.health_check_endpoint }}
|
||||
# Keep health checks out of the access log but still record their metric:
|
||||
# tag them `debug` so the access-log target (level info) drops them while the
|
||||
# metrics socket (level debug) keeps them. Mirrors the proxy, which records
|
||||
# the healthz metric with should_record_access_log=False.
|
||||
http-request set-log-level debug if healthcheck
|
||||
{%- if config.metrics_enabled %}
|
||||
http-request set-var-fmt(txn.serve_route) {{ config.health_check_endpoint | haproxy_fmt }} if healthcheck
|
||||
{%- endif %}
|
||||
{%- if not health_info.healthy %}
|
||||
# Override: force health checks to fail (used by drain/disable)
|
||||
http-request return status {{ health_info.status }} content-type text/plain string "{{ health_info.health_message }}" if healthcheck
|
||||
{%- elif backends %}
|
||||
# 200 if any backend has at least one server UP
|
||||
{%- for backend in backends %}
|
||||
acl backend_{{ backend.name or 'unknown' }}_server_up nbsrv({{ backend.name or 'unknown' }}) ge 1
|
||||
{%- endfor %}
|
||||
# Any backend with a server UP passes the health check (OR logic)
|
||||
{%- for backend in backends %}
|
||||
http-request return status {{ health_info.status }} content-type text/plain string "{{ health_info.health_message }}" if healthcheck backend_{{ backend.name or 'unknown' }}_server_up
|
||||
{%- endfor %}
|
||||
http-request return status 503 content-type text/plain string "Service Unavailable" if healthcheck
|
||||
{%- elif config.is_head %}
|
||||
# Head is the always-on ingress endpoint, so it stays ready with no backends.
|
||||
# Mirrors is_head in ProxyRouter.ready_for_traffic.
|
||||
http-request return status {{ health_info.status }} content-type text/plain string "{{ health_info.health_message }}" if healthcheck
|
||||
{%- endif %}
|
||||
"""
|
||||
|
||||
# Same shape as HAPROXY_HEALTHZ_RULES_TEMPLATE, but emits gRPC trailers-only
|
||||
# responses. The OK / UNAVAILABLE distinction maps to grpc-status 0 / 14
|
||||
# (gRPC clients map HTTP 503 to UNAVAILABLE, which is why every shape here
|
||||
# uses HTTP 200 and signals state through grpc-status).
|
||||
HAPROXY_GRPC_HEALTHZ_RULES_TEMPLATE = """ # Health check endpoint (gRPC `Healthz`)
|
||||
acl is_healthz path /ray.serve.RayServeAPIService/Healthz
|
||||
# Suppress logging for health checks
|
||||
http-request set-log-level silent if is_healthz
|
||||
{%- if not health_info.healthy %}
|
||||
# Override: force health checks to fail (used by drain/disable)
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 14 hdr grpc-message "{{ health_info.health_message }}" if is_healthz
|
||||
{%- elif backends %}
|
||||
# OK if any backend has at least one server UP
|
||||
{%- for backend in backends %}
|
||||
acl backend_{{ backend.name or 'unknown' }}_server_up nbsrv({{ backend.name or 'unknown' }}) ge 1
|
||||
{%- endfor %}
|
||||
# Any backend with a server UP passes the health check (OR logic)
|
||||
{%- for backend in backends %}
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 0 if is_healthz backend_{{ backend.name or 'unknown' }}_server_up
|
||||
{%- endfor %}
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 14 hdr grpc-message "Service Unavailable" if is_healthz
|
||||
{%- elif config.is_head %}
|
||||
# Head is the always-on ingress endpoint, so it stays ready with no backends.
|
||||
# Mirrors is_head in ProxyRouter.ready_for_traffic.
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 0 if is_healthz
|
||||
{%- endif %}
|
||||
"""
|
||||
|
||||
HAPROXY_CONFIG_TEMPLATE = """global
|
||||
# Access/event log at `info`. The system endpoints (/-/healthz, /-/routes)
|
||||
# tag their logs `debug` so they are dropped here but still reach the
|
||||
# rfc5424 metrics socket (level `debug`) when metrics are enabled -- mirroring
|
||||
# the proxy, which records their metric but not their access log.
|
||||
log {{ config.log_target }} local0 info
|
||||
stats socket {{ config.socket_path }} mode 666 level admin expose-fd listeners
|
||||
stats timeout 30s
|
||||
maxconn {{ config.maxconn }}
|
||||
nbthread {{ config.nbthread }}
|
||||
{%- if has_ingress_request_router %}
|
||||
lua-load-per-thread {{ ingress_request_router_lua_path }}
|
||||
{%- endif %}
|
||||
{%- if has_ingress_request_router and ingress_request_router_forward_body %}
|
||||
tune.bufsize {{ ingress_request_router_bufsize }}
|
||||
{%- else %}
|
||||
tune.bufsize {{ config.bufsize }}
|
||||
{%- endif %}
|
||||
{%- if config.enable_hap_optimization %}
|
||||
server-state-base {{ config.server_state_base }}
|
||||
server-state-file {{ config.server_state_file }}
|
||||
{%- endif %}
|
||||
{%- if config.hard_stop_after_s is not none %}
|
||||
hard-stop-after {{ config.hard_stop_after_s }}s
|
||||
{%- endif %}
|
||||
{%- if config.grpc_enabled %}
|
||||
tune.h2.max-frame-size {{ config.h2_max_frame_size }}
|
||||
tune.h2.be.initial-window-size {{config.h2_be_initial_window_size}}
|
||||
tune.h2.be.max-concurrent-streams {{config.h2_be_max_concurrent_streams}}
|
||||
tune.h2.fe.initial-window-size {{config.h2_fe_initial_window_size}}
|
||||
tune.h2.fe.max-concurrent-streams {{config.h2_fe_max_concurrent_streams}}
|
||||
{%- endif %}
|
||||
defaults
|
||||
mode http
|
||||
option log-health-checks
|
||||
{% if config.timeout_connect_s is not none %}timeout connect {{ config.timeout_connect_s }}s{% endif %}
|
||||
{% if config.timeout_client_s is not none %}timeout client {{ config.timeout_client_s }}s{% endif %}
|
||||
{% if config.timeout_server_s is not none %}timeout server {{ config.timeout_server_s }}s{% endif %}
|
||||
{% if config.timeout_http_request_s is not none %}timeout http-request {{ config.timeout_http_request_s }}s{% endif %}
|
||||
{% if config.timeout_http_keep_alive_s is not none %}timeout http-keep-alive {{ config.timeout_http_keep_alive_s }}s{% endif %}
|
||||
{% if config.timeout_queue_s is not none %}timeout queue {{ config.timeout_queue_s }}s{% endif %}
|
||||
log global
|
||||
option httplog
|
||||
option abortonclose
|
||||
option splice-request
|
||||
option splice-response
|
||||
# On a retry, use a different slot (`1`). retry-on defaults to connect
|
||||
# failures only (nothing was sent → safe to replay); override globally via
|
||||
# RAY_SERVE_HAPROXY_RETRY_ON. Inherited by every backend.
|
||||
option redispatch 1
|
||||
retry-on {{ config.retry_on }}
|
||||
{%- if config.retries is not none %}
|
||||
retries {{ config.retries }}
|
||||
{%- endif %}
|
||||
{%- if config.tcp_nodelay %}
|
||||
# Set TCP_NODELAY on all connections
|
||||
option http-no-delay
|
||||
{%- endif %}
|
||||
{%- if config.enable_hap_optimization %}
|
||||
option idle-close-on-response
|
||||
{%- endif %}
|
||||
# Normalize 502/503/504 to 500 per Serve's default behavior. 503
|
||||
# covers HAProxy's own "all retries exhausted / no server" response.
|
||||
{%- if config.error_file_path %}
|
||||
errorfile 502 {{ config.error_file_path }}
|
||||
errorfile 503 {{ config.error_file_path }}
|
||||
errorfile 504 {{ config.error_file_path }}
|
||||
{%- endif %}
|
||||
{%- if config.enable_hap_optimization %}
|
||||
load-server-state-from-file global
|
||||
{%- endif %}
|
||||
balance {{ config.balance_algorithm }}
|
||||
frontend prometheus
|
||||
bind :{{ config.metrics_port }}
|
||||
mode http
|
||||
http-request use-service prometheus-exporter if { path {{ config.metrics_uri }} }
|
||||
no log
|
||||
frontend http_frontend
|
||||
bind {{ config.frontend_host }}:{{ config.frontend_port }}
|
||||
{%- if config.metrics_enabled %}
|
||||
log global
|
||||
# Per-request HTTP ingress metrics. One RFC 5424 line per request matched to
|
||||
# a Serve app backend, scraped into the serve_num_http_* /
|
||||
# serve_http_request_latency_ms families (the metrics the Python proxy emits
|
||||
# in non-HAProxy mode). Goes only to the rfc5424 target below; the inherited
|
||||
# rfc3164 targets do not include the SD section, so their byte stream is
|
||||
# unchanged. The general fields come from txn.serve_* vars set per backend
|
||||
# below; %ST/%Ta/%ts render unquoted (HAProxy does not quote those aliases).
|
||||
# term_state (%ts) is HAProxy's 2-char session termination state; a leading "C"
|
||||
# means the client aborted, which the collector maps to status 499 to match the
|
||||
# Python proxy's client-disconnect convention. When ingress-request-router
|
||||
# metrics are also enabled, the router-specific fields are appended to the same
|
||||
# line.
|
||||
log {{ metrics_socket_path }} len 8192 format rfc5424 local1 debug
|
||||
log-format-sd "%{+Q,+E}o [serve@1 app=%[var(txn.serve_app)] route=%[var(txn.serve_route)] method=%HM status=%ST latency_ms=%Ta deployment=%[var(txn.serve_deployment)] term_state=%ts{% if ingress_request_router_metrics_enabled and has_ingress_request_router %} intended=%[var(txn.ingress_request_router_target)] actual=%s router_latency_us=%[var(txn.ingress_request_router_latency_us)] body_truncated_full_length=%[var(txn.ingress_request_router_truncated_full_length)] via_router=%[var(txn.via_ingress_request_router)] failed=%[var(txn.ingress_request_router_failed)]{% endif %}]"
|
||||
{%- endif %}
|
||||
{%- if config.root_path %}
|
||||
# Strip the configured global root_path so the health/routes endpoints, the
|
||||
# per-backend path ACLs, and the path forwarded to replicas are all
|
||||
# root_path-agnostic. Mirrors the native Serve proxy, which mounts the app
|
||||
# under root_path. An exact root_path match becomes "/", and paths outside
|
||||
# root_path are left unchanged.
|
||||
http-request set-path / if { path {{ config.root_path }} }
|
||||
http-request set-path %[path,regsub(^{{ config.root_path }}/,/)] if { path_beg {{ config.root_path }}/ }
|
||||
{%- endif %}
|
||||
{{ healthz_rules|safe }}
|
||||
# Routes endpoint
|
||||
acl routes path -i /-/routes
|
||||
# Like health checks: kept out of the access log (tagged `debug`); its metric
|
||||
# is recorded (route=/-/routes, app unset) when metrics are enabled.
|
||||
http-request set-log-level debug if routes
|
||||
{%- if config.metrics_enabled %}
|
||||
http-request set-var-fmt(txn.serve_route) {{ '/-/routes' | haproxy_fmt }} if routes
|
||||
{%- endif %}
|
||||
http-request return status {{ route_info.status }} content-type {{ route_info.routes_content_type }} string "{{ route_info.routes_message }}" if routes
|
||||
|
||||
{%- if config.inject_process_id_header and config.reload_id %}
|
||||
# Inject unique reload ID as header to track which HAProxy instance handled the request (testing only)
|
||||
http-request set-header x-haproxy-reload-id {{ config.reload_id }}
|
||||
{%- endif %}
|
||||
# Per-backend path ACLs (used for both ingress-request-router dispatch
|
||||
# and static use_backend selection below).
|
||||
{%- for backend in backends %}
|
||||
acl is_{{ backend.name or 'unknown' }} path_beg {{ '/' if not backend.path_prefix or backend.path_prefix == '/' else backend.path_prefix ~ '/' }}
|
||||
acl is_{{ backend.name or 'unknown' }} path {{ backend.path_prefix or '/' }}
|
||||
{%- endfor %}
|
||||
{%- if config.metrics_enabled %}
|
||||
# Per-request HTTP metric vars (app / route / ingress deployment), set on the
|
||||
# first matching backend. Backends are sorted longest-prefix-first and the
|
||||
# !found guard makes the longest match win, mirroring the use_backend rules
|
||||
# below. Requests that match no app backend (e.g. /-/routes, 404s) leave
|
||||
# these unset, so the collector can skip them.
|
||||
{%- for backend in backends %}
|
||||
http-request set-var-fmt(txn.serve_app) {{ (backend.app_name or 'unknown') | haproxy_fmt }} if is_{{ backend.name or 'unknown' }} !{ var(txn.serve_app) -m found }
|
||||
http-request set-var-fmt(txn.serve_route) {{ (backend.path_prefix or '/') | haproxy_fmt }} if is_{{ backend.name or 'unknown' }} !{ var(txn.serve_route) -m found }
|
||||
{%- if backend.ingress_deployment_name %}
|
||||
http-request set-var-fmt(txn.serve_deployment) {{ backend.ingress_deployment_name | haproxy_fmt }} if is_{{ backend.name or 'unknown' }} !{ var(txn.serve_deployment) -m found }
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{%- if has_ingress_request_router %}
|
||||
# Set txn.ingress_request_router_app to the first matching router-bearing
|
||||
# backend. Backends are sorted longest-prefix-first, and the !found guard
|
||||
# ensures only the longest match wins.
|
||||
{%- for backend in backends %}
|
||||
{%- if backend.ingress_request_router_servers %}
|
||||
http-request set-var(txn.ingress_request_router_app) str({{ backend.name or 'unknown' }}) if is_{{ backend.name or 'unknown' }} !{ var(txn.ingress_request_router_app) -m found }
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
acl has_ingress_request_router_app var(txn.ingress_request_router_app) -m found
|
||||
{%- if ingress_request_router_forward_body %}
|
||||
http-request wait-for-body time {{ ingress_request_router_timeout_s }}s if METH_POST has_ingress_request_router_app
|
||||
{%- endif %}
|
||||
http-request lua.route_via_ingress_request_router if METH_POST has_ingress_request_router_app
|
||||
# A pin-miss is recoverable only if its app has a fallback proxy. Mark it
|
||||
# per app so the 503 below fails loud for apps with none.
|
||||
{%- for backend in backends %}
|
||||
{%- if backend.ingress_request_router_servers and backend.fallback_server %}
|
||||
http-request set-var(txn.ingress_request_router_recoverable) str(1) if { var(txn.ingress_request_router_app) -m str "{{ backend.name or 'unknown' }}" } { var(txn.ingress_request_router_failed) -m str "unknown_replica_id" }
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
# 503 on any router failure except a recoverable pin-miss. Must precede the
|
||||
# use_backend rules so failures never fall through to the primary backend.
|
||||
http-request return status 503 content-type text/plain lf-string "Ingress request router failed: %[var(txn.ingress_request_router_failed)]" hdr X-Serve-Reason %[var(txn.ingress_request_router_failed)] if { var(txn.ingress_request_router_failed) -m found } !{ var(txn.ingress_request_router_recoverable) -m found }
|
||||
{%- endif %}
|
||||
# Static routing based on path prefixes in decreasing length then alphabetical order
|
||||
{%- for backend in backends %}
|
||||
{%- if has_ingress_request_router and backend.ingress_request_router_servers %}
|
||||
use_backend {{ backend.name or 'unknown' }}-via-ingress-request-router if is_{{ backend.name or 'unknown' }} { var(txn.via_ingress_request_router) -m found }
|
||||
{%- if backend.fallback_server %}
|
||||
# Pin-miss recovery: route into the router backend, which picks the fallback.
|
||||
use_backend {{ backend.name or 'unknown' }}-via-ingress-request-router if is_{{ backend.name or 'unknown' }} { var(txn.ingress_request_router_failed) -m str "unknown_replica_id" }
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
use_backend {{ backend.name or 'unknown' }} if is_{{ backend.name or 'unknown' }}
|
||||
{%- endfor %}
|
||||
default_backend default_backend
|
||||
backend default_backend
|
||||
http-request return status 404 content-type text/plain lf-string "Path \'%[path]\' not found. Ping http://.../-/routes for available routes."
|
||||
{%- for item in backends_with_health_config %}
|
||||
{%- set backend = item.backend %}
|
||||
{%- set hc = item.health_config %}
|
||||
backend {{ backend.name or 'unknown' }}
|
||||
log global
|
||||
# Enable HTTP connection reuse for better performance
|
||||
http-reuse always
|
||||
# Set backend-specific timeouts, overriding defaults if specified
|
||||
{%- if backend.timeout_connect_s is not none %}
|
||||
timeout connect {{ backend.timeout_connect_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_server_s is not none %}
|
||||
timeout server {{ backend.timeout_server_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_client_s is not none %}
|
||||
timeout client {{ backend.timeout_client_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_http_request_s is not none %}
|
||||
timeout http-request {{ backend.timeout_http_request_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_queue_s is not none %}
|
||||
timeout queue {{ backend.timeout_queue_s }}s
|
||||
{%- endif %}
|
||||
# Set timeouts to support keep-alive connections
|
||||
{%- if backend.timeout_http_keep_alive_s is not none %}
|
||||
timeout http-keep-alive {{ backend.timeout_http_keep_alive_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_tunnel_s is not none %}
|
||||
timeout tunnel {{ backend.timeout_tunnel_s }}s
|
||||
{%- endif %}
|
||||
# Health check configuration - use backend-specific or global defaults
|
||||
{%- if hc.health_path %}
|
||||
# HTTP health check with custom path
|
||||
option httpchk GET {{ hc.health_path }}
|
||||
http-check expect status 200
|
||||
{%- endif %}
|
||||
{{ hc.default_server_directive }}
|
||||
# Servers in this backend
|
||||
{%- for server in backend.servers %}
|
||||
server {{ server.name }} {{ server.host }}:{{ server.port }} check
|
||||
{%- endfor %}
|
||||
{%- if backend.fallback_server %}
|
||||
# Fallback to head node's Serve proxy when no ingress replicas are available
|
||||
server {{ backend.fallback_server.name }} {{ backend.fallback_server.host }}:{{ backend.fallback_server.port }} check backup
|
||||
{%- endif %}
|
||||
{%- if has_ingress_request_router and backend.ingress_request_router_servers %}
|
||||
backend {{ backend.name or 'unknown' }}-via-ingress-request-router
|
||||
log global
|
||||
# Keep the pinned data-plane path on the same connection policy as the
|
||||
# primary backend. For streamed responses, forcing server-close can leave
|
||||
# HAProxy holding unread server-side FINs under a burst while worker
|
||||
# threads are still routing other requests.
|
||||
http-reuse always
|
||||
# Inherits the defaults block's `option redispatch 1` + retry-on, so a
|
||||
# DOWN/slow pinned server falls through to a different replica instead of
|
||||
# head-of-line-blocking on the original pick. One retry policy everywhere.
|
||||
{%- if backend.timeout_connect_s is not none %}
|
||||
timeout connect {{ backend.timeout_connect_s }}s
|
||||
{%- endif %}
|
||||
{%- if config.ingress_timeout_server_s is not none %}
|
||||
timeout server {{ config.ingress_timeout_server_s }}s
|
||||
{%- elif backend.timeout_server_s is not none %}
|
||||
timeout server {{ backend.timeout_server_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_http_keep_alive_s is not none %}
|
||||
timeout http-keep-alive {{ backend.timeout_http_keep_alive_s }}s
|
||||
{%- endif %}
|
||||
{%- for server in backend.servers %}
|
||||
use-server {{ server.name }} if { var(txn.ingress_request_router_target) -m str "{{ server.name }}" }
|
||||
{%- endfor %}
|
||||
{%- if backend.fallback_server %}
|
||||
# Pin-miss: route to the fallback Serve proxy, which re-pins via its own
|
||||
# router. If the fallback is DOWN this use-server is skipped and the request
|
||||
# load-balances onto a primary replica in this backend, so affinity lapses
|
||||
# until the fallback's health check passes. That is plain selection-time
|
||||
# fallthrough, not `option redispatch` (which only re-picks after a
|
||||
# connection failure to an already-selected server).
|
||||
use-server {{ backend.fallback_server.name }} if { var(txn.ingress_request_router_failed) -m str "unknown_replica_id" }
|
||||
{%- endif %}
|
||||
# `track` allows us to mirror primary-backend health and avoid double-checking.
|
||||
{%- for server in backend.servers %}
|
||||
server {{ server.name }} {{ server.host }}:{{ server.port }} track {{ backend.name or 'unknown' }}/{{ server.name }}
|
||||
{%- endfor %}
|
||||
{%- if backend.fallback_server %}
|
||||
server {{ backend.fallback_server.name }} {{ backend.fallback_server.host }}:{{ backend.fallback_server.port }} track {{ backend.name or 'unknown' }}/{{ backend.fallback_server.name }} backup
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if config.grpc_enabled %}
|
||||
frontend grpc_frontend
|
||||
# gRPC requires HTTP/2. HAProxy decodes H2 frames into HTTP request
|
||||
# semantics in `mode http` when `proto h2` is on the bind line.
|
||||
bind {{ config.grpc_frontend_host }}:{{ config.grpc_frontend_port }} proto h2
|
||||
mode http
|
||||
log global
|
||||
# No per-request ingress-metrics log line here (unlike http_frontend): a
|
||||
# gRPC call's real status is `grpc-status`, sent in HTTP/2 trailers on
|
||||
# success, and HAProxy cannot read trailers in log-format (haproxy/haproxy#112).
|
||||
# gRPC ingress metrics are therefore emitted by the replica instead.
|
||||
|
||||
{{ grpc_healthz_rules|safe }}
|
||||
|
||||
# ListApplications must aggregate across all apps, so it goes to the
|
||||
# head-node fallback Serve proxy rather than an individual replica.
|
||||
acl is_list_applications path /ray.serve.RayServeAPIService/ListApplications
|
||||
{%- if grpc_fallback_backend_with_health_config %}
|
||||
use_backend grpc_fallback_backend if is_list_applications
|
||||
{%- else %}
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 14 hdr grpc-message "ListApplications is unavailable" if is_list_applications
|
||||
{%- endif %}
|
||||
|
||||
# Route per-app on the `application` metadata that Ray Serve clients attach.
|
||||
{%- for backend in grpc_backends %}
|
||||
acl is_{{ backend.name or 'unknown' }} req.hdr(application) -m str {{ backend.app_name }}
|
||||
use_backend {{ backend.name or 'unknown' }} if is_{{ backend.name or 'unknown' }}
|
||||
{%- endfor %}
|
||||
{%- if grpc_backends|length == 1 %}
|
||||
# With exactly one app deployed, route there regardless of metadata so
|
||||
# clients can call it without setting the `application` header.
|
||||
default_backend {{ grpc_backends[0].name or 'unknown' }}
|
||||
{%- else %}
|
||||
# Zero apps, or multiple apps without a matching `application` header.
|
||||
default_backend default_grpc_backend
|
||||
{%- endif %}
|
||||
|
||||
{%- if grpc_fallback_backend_with_health_config %}
|
||||
{%- set backend = grpc_fallback_backend_with_health_config.backend %}
|
||||
{%- set hc = grpc_fallback_backend_with_health_config.health_config %}
|
||||
{%- if backend.servers %}
|
||||
{%- set server = backend.servers[0] %}
|
||||
backend grpc_fallback_backend
|
||||
mode http
|
||||
log global
|
||||
# gRPC health check: replay a complete unary `Healthz` request via
|
||||
# `tcp-check send-binary` and match the healthy message in the response.
|
||||
# `http-check` can't be used because its body is truncated at the first NUL
|
||||
# byte and a gRPC frame always starts with the NUL compression flag, so the
|
||||
# server would get a message-less unary and stall until timeout.
|
||||
option tcp-check
|
||||
tcp-check connect
|
||||
tcp-check send-binary {{ hc.grpc_healthcheck_request_hex }}
|
||||
tcp-check expect binary {{ hc.grpc_healthcheck_expect_hex }}
|
||||
{{ hc.default_server_directive }}
|
||||
# `proto h2` makes HAProxy speak HTTP/2 cleartext to the fallback gRPC server.
|
||||
server {{ server.name }} {{ server.host }}:{{ server.port }} proto h2 check
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if grpc_backends|length != 1 %}
|
||||
backend default_grpc_backend
|
||||
mode http
|
||||
log global
|
||||
# Trailers-only NOT_FOUND. gRPC clients surface this as
|
||||
# grpc.StatusCode.NOT_FOUND; an HTTP 503 would map to UNAVAILABLE instead.
|
||||
acl has_application_header req.hdr(application) -m found
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 5 hdr grpc-message "Application '%[req.hdr(application)]' not found. Ping /ray.serve.RayServeAPIService/ListApplications for available applications." if has_application_header
|
||||
http-request return status 200 content-type application/grpc hdr grpc-status 5 hdr grpc-message "Application metadata not set. Ping /ray.serve.RayServeAPIService/ListApplications for available applications."
|
||||
{%- endif %}
|
||||
{%- for item in grpc_backends_with_health_config %}
|
||||
{%- set backend = item.backend %}
|
||||
{%- set hc = item.health_config %}
|
||||
backend {{ backend.name or 'unknown' }}
|
||||
mode http
|
||||
log global
|
||||
http-reuse always
|
||||
{%- if backend.timeout_connect_s is not none %}
|
||||
timeout connect {{ backend.timeout_connect_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_server_s is not none %}
|
||||
timeout server {{ backend.timeout_server_s }}s
|
||||
{%- endif %}
|
||||
{%- if backend.timeout_client_s is not none %}
|
||||
timeout client {{ backend.timeout_client_s }}s
|
||||
{%- endif %}
|
||||
# gRPC health check: replay a complete unary `Healthz` request via
|
||||
# `tcp-check send-binary` and match the healthy message in the response.
|
||||
# `http-check` can't be used because its body is truncated at the first NUL
|
||||
# byte and a gRPC frame always starts with the NUL compression flag, so the
|
||||
# server would get a message-less unary and stall until timeout.
|
||||
option tcp-check
|
||||
tcp-check connect
|
||||
tcp-check send-binary {{ hc.grpc_healthcheck_request_hex }}
|
||||
tcp-check expect binary {{ hc.grpc_healthcheck_expect_hex }}
|
||||
{{ hc.default_server_directive }}
|
||||
# `proto h2` makes HAProxy speak HTTP/2 cleartext to backend gRPC servers.
|
||||
{%- for server in backend.servers %}
|
||||
server {{ server.name }} {{ server.host }}:{{ server.port }} proto h2 check
|
||||
{%- endfor %}
|
||||
{%- if backend.fallback_server %}
|
||||
server {{ backend.fallback_server.name }} {{ backend.fallback_server.host }}:{{ backend.fallback_server.port }} proto h2 check backup
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
listen stats
|
||||
bind *:{{ config.stats_port }}
|
||||
stats enable
|
||||
stats uri {{ config.stats_uri }}
|
||||
stats refresh 1s
|
||||
"""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
-- HAProxy Lua action that picks a backend replica via the ingress request
|
||||
-- router. Templated at config-reload time; placeholders are filled in by
|
||||
-- _write_ingress_request_router_lua in haproxy.py.
|
||||
--
|
||||
-- Bodies exceeding tune.bufsize are truncated; we forward what we have with
|
||||
-- X-Body-Truncated since prefix-routing only needs the head of the body.
|
||||
-- See RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY in constants.py.
|
||||
|
||||
local ROUTER_REQUEST_TIMEOUT_S = ${TIMEOUT_S}
|
||||
local FORWARD_BODY = ${FORWARD_BODY}
|
||||
-- Name of the client header that carries the session id Serve should
|
||||
-- pin on. Filled from SERVE_SESSION_ID (set via the env var
|
||||
-- RAY_SERVE_SESSION_ID_HEADER_KEY) in lowercase form. Forwarded on the
|
||||
-- same name to /internal/route so session-aware routers (e.g.
|
||||
-- ConsistentHashRouter) can use it.
|
||||
local SESSION_HEADER = "${SESSION_HEADER}"
|
||||
|
||||
-- Per-app state. The frontend sets txn.ingress_request_router_app to the
|
||||
-- backend name whose path prefix matched, and we look up that app's
|
||||
-- router pool and replica map here. Each app routes through its own
|
||||
-- router; replica IDs are scoped to the app to avoid cross-app pinning.
|
||||
local ROUTERS = ${ROUTERS}
|
||||
local REPLICA_TARGETS = ${REPLICA_TARGETS}
|
||||
|
||||
local function call_router(router, body, truncated_length, session_id)
|
||||
local sock = core.tcp()
|
||||
sock:settimeout(ROUTER_REQUEST_TIMEOUT_S)
|
||||
|
||||
if not sock:connect(router.host, router.port) then
|
||||
return nil
|
||||
end
|
||||
|
||||
local truncation_header = ""
|
||||
if truncated_length then
|
||||
truncation_header = "X-Body-Truncated: "
|
||||
.. #body .. "/" .. truncated_length .. "\r\n"
|
||||
end
|
||||
|
||||
local session_header = ""
|
||||
if session_id and session_id ~= "" then
|
||||
session_header = SESSION_HEADER .. ": " .. session_id .. "\r\n"
|
||||
end
|
||||
|
||||
-- Connection: close so sock:receive("*a") terminates on EOF.
|
||||
local req = "POST /internal/route HTTP/1.0\r\n"
|
||||
.. "Host: " .. router.host_header .. "\r\n"
|
||||
.. "Connection: close\r\n"
|
||||
.. "Content-Type: application/json\r\n"
|
||||
.. "Content-Length: " .. #body .. "\r\n"
|
||||
.. truncation_header
|
||||
.. session_header
|
||||
.. "\r\n"
|
||||
.. body
|
||||
|
||||
if not sock:send(req) then
|
||||
sock:close()
|
||||
return nil
|
||||
end
|
||||
|
||||
local response = sock:receive("*a")
|
||||
sock:close()
|
||||
return response
|
||||
end
|
||||
|
||||
local function is_http_200(response)
|
||||
return response:match("^HTTP/[%d%.]+ 200") ~= nil
|
||||
end
|
||||
|
||||
local function http_response_body(response)
|
||||
local _, separator_end = response:find("\r\n\r\n", 1, true)
|
||||
if not separator_end then
|
||||
return ""
|
||||
end
|
||||
return response:sub(separator_end + 1)
|
||||
end
|
||||
|
||||
-- Router contract: response MUST be a flat JSON object with `replica_id` as a
|
||||
-- plain string actor name (no escaped quotes, no nested objects). The regex
|
||||
-- relies on this; broaden the parser if the contract grows.
|
||||
local function extract_replica_id(json_body)
|
||||
return json_body:match('"replica_id"%s*:%s*"([^"]+)"')
|
||||
end
|
||||
|
||||
-- Returns the original Content-Length when `body` is shorter than the
|
||||
-- advertised length (truncated by tune.bufsize), or nil otherwise.
|
||||
local function truncated_full_length(txn, body)
|
||||
local cl = tonumber(txn.sf:hdr("content-length"))
|
||||
if cl and #body < cl then
|
||||
return cl
|
||||
end
|
||||
end
|
||||
|
||||
-- Core routing decision. Sets either txn.ingress_request_router_target +
|
||||
-- txn.via_ingress_request_router (success) or txn.ingress_request_router_failed
|
||||
-- (failure). Both outcomes are timed by the caller; only the silent early
|
||||
-- returns in the action handler skip timing because no routing was attempted.
|
||||
local function _route_via_ingress_request_router(txn, router, replica_map)
|
||||
-- FORWARD_BODY=false: don't read or forward the body; call the router
|
||||
-- with body="" so a Content-Length: 0 POST still goes through routing
|
||||
-- (any policy that needs the body must opt in via FORWARD_BODY=true).
|
||||
-- Empty body in FORWARD_BODY=true mode is treated the same -- a
|
||||
-- legitimate input the router can accept or reject on its own terms;
|
||||
-- we don't synthesize a sentinel for it here.
|
||||
local body = ""
|
||||
local truncated = nil
|
||||
if FORWARD_BODY then
|
||||
body = txn.sf:req_body() or ""
|
||||
if body ~= "" then
|
||||
truncated = truncated_full_length(txn, body)
|
||||
if truncated then
|
||||
core.log(core.warning,
|
||||
"ingress_request_router: forwarding truncated body to router ("
|
||||
.. #body .. "/" .. truncated .. " bytes)")
|
||||
${METRICS_SET_TRUNCATED}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Look up the configured session-id header from the client request.
|
||||
-- req_get_headers() returns a table keyed by lowercase header name
|
||||
-- with array values (header can appear multiple times); take the
|
||||
-- first occurrence. Missing header -> nil -> call_router skips the
|
||||
-- forwarded header line entirely.
|
||||
--
|
||||
-- Also probe the `-`/`_` variant so an intermediate proxy that rewrites
|
||||
-- the separator doesn't silently drop session affinity, matching the
|
||||
-- Python-side `_matches_session_id_header` behavior in http_util.py.
|
||||
local session_id = nil
|
||||
if SESSION_HEADER ~= "" then
|
||||
local hdrs = txn.http:req_get_headers()
|
||||
local entry = hdrs and hdrs[SESSION_HEADER]
|
||||
if hdrs and not entry then
|
||||
local alt = SESSION_HEADER:gsub("-", "_")
|
||||
if alt == SESSION_HEADER then
|
||||
alt = SESSION_HEADER:gsub("_", "-")
|
||||
end
|
||||
entry = hdrs[alt]
|
||||
end
|
||||
if entry then
|
||||
session_id = entry[0]
|
||||
end
|
||||
end
|
||||
|
||||
local response = call_router(router, body, truncated, session_id)
|
||||
if not response then
|
||||
txn:set_var("txn.ingress_request_router_failed", "router_unreachable")
|
||||
return
|
||||
end
|
||||
if not is_http_200(response) then
|
||||
txn:set_var("txn.ingress_request_router_failed", "router_non_200")
|
||||
return
|
||||
end
|
||||
|
||||
local replica_id = extract_replica_id(http_response_body(response))
|
||||
if not replica_id then
|
||||
txn:set_var("txn.ingress_request_router_failed", "unparseable_replica_id")
|
||||
return
|
||||
end
|
||||
|
||||
local server_name = replica_map[replica_id]
|
||||
if not server_name then
|
||||
-- Pin-miss: router named a replica not in HAProxy's server map, a
|
||||
-- transient gap between the router view and HAProxy's config snapshot.
|
||||
-- Arm `failed`. The frontend recovers it via the fallback proxy.
|
||||
txn:set_var("txn.ingress_request_router_failed", "unknown_replica_id")
|
||||
return
|
||||
end
|
||||
|
||||
txn:set_var("txn.ingress_request_router_target", server_name)
|
||||
txn:set_var("txn.via_ingress_request_router", true)
|
||||
end
|
||||
|
||||
-- Failure semantics: every path that reaches a router decision but cannot pin
|
||||
-- a replica must arm txn.ingress_request_router_failed so the frontend's 503
|
||||
-- rule fires instead of letting the request silently fall through to the
|
||||
-- primary backend. The product invariant is: requests to a router-bearing app
|
||||
-- are served via the router or fail; there is no quiet alternative path.
|
||||
-- Exception: `unknown_replica_id` is recovered via the fallback proxy when the
|
||||
-- app has one, otherwise 503ed. The fallback re-pins via its own router so the
|
||||
-- policy is not bypassed. While the fallback is DOWN the request load-balances
|
||||
-- onto a primary replica in the router backend instead of 503ing, so affinity
|
||||
-- lapses for that window. Still counted as a failure.
|
||||
-- Two silent returns are correct: (1) the request didn't target a
|
||||
-- router-bearing app (no txn var set, no app entry in our maps), and
|
||||
-- (2) the controller hasn't pushed router/replica state for this app yet.
|
||||
-- Failure-mode bucketing belongs in observability (response header label,
|
||||
-- metric label), not in the data plane.
|
||||
core.register_action("route_via_ingress_request_router", {"http-req"}, function(txn)
|
||||
-- Time the routing attempt regardless of outcome: both successful pins
|
||||
-- and explicit failures (router_unreachable, router_non_200,
|
||||
-- unparseable_replica_id, unknown_replica_id) record latency_us so
|
||||
-- failure paths are visible in the metrics stream.
|
||||
${METRICS_PRE_CALL_ROUTER}
|
||||
|
||||
local app = txn:get_var("txn.ingress_request_router_app")
|
||||
if not app then
|
||||
return
|
||||
end
|
||||
local router = ROUTERS[app]
|
||||
local replica_map = REPLICA_TARGETS[app]
|
||||
if not router or not replica_map then
|
||||
return
|
||||
end
|
||||
|
||||
_route_via_ingress_request_router(txn, router, replica_map)
|
||||
${METRICS_POST_CALL_ROUTER}
|
||||
end, 0)
|
||||
@@ -0,0 +1,406 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from functools import wraps
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Coroutine,
|
||||
Dict,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import ray
|
||||
from ray import cloudpickle
|
||||
from ray.serve._private.common import DeploymentID, RequestMetadata
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_RUN_SYNC_IN_THREADPOOL,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.replica import UserCallableWrapper
|
||||
from ray.serve._private.replica_result import ReplicaResult
|
||||
from ray.serve._private.request_router.replica_wrapper import ReplicaSelection
|
||||
from ray.serve._private.router import Router
|
||||
from ray.serve._private.utils import GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR
|
||||
from ray.serve.deployment import Deployment
|
||||
from ray.serve.exceptions import RequestCancelledError
|
||||
from ray.serve.handle import (
|
||||
DeploymentHandle,
|
||||
DeploymentResponse,
|
||||
DeploymentResponseGenerator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def _validate_deployment_options(
|
||||
deployment: Deployment,
|
||||
deployment_id: DeploymentID,
|
||||
):
|
||||
if "num_gpus" in deployment.ray_actor_options:
|
||||
logger.warning(
|
||||
f"Deployment {deployment_id} has num_gpus configured. "
|
||||
"CUDA_VISIBLE_DEVICES is not managed automatically in local testing mode. "
|
||||
)
|
||||
|
||||
if "runtime_env" in deployment.ray_actor_options:
|
||||
logger.warning(
|
||||
f"Deployment {deployment_id} has runtime_env configured. "
|
||||
"runtime_envs are ignored in local testing mode."
|
||||
)
|
||||
|
||||
|
||||
def make_local_deployment_handle(
|
||||
deployment: Deployment,
|
||||
app_name: str,
|
||||
) -> DeploymentHandle:
|
||||
"""Constructs an in-process DeploymentHandle.
|
||||
|
||||
This is used in the application build process for local testing mode,
|
||||
where all deployments of an app run in the local process which enables
|
||||
faster dev iterations and use of tooling like PDB.
|
||||
|
||||
The user callable will be run on an asyncio loop in a separate thread
|
||||
(sharing the same code that's used in the replica).
|
||||
|
||||
The constructor for the user callable is run eagerly in this function to
|
||||
ensure that any exceptions are raised during `serve.run`.
|
||||
"""
|
||||
deployment_id = DeploymentID(deployment.name, app_name)
|
||||
_validate_deployment_options(deployment, deployment_id)
|
||||
user_callable_wrapper = UserCallableWrapper(
|
||||
deployment.func_or_class,
|
||||
deployment.init_args,
|
||||
deployment.init_kwargs,
|
||||
deployment_id=deployment_id,
|
||||
run_sync_methods_in_threadpool=RAY_SERVE_RUN_SYNC_IN_THREADPOOL,
|
||||
run_user_code_in_separate_thread=True,
|
||||
local_testing_mode=True,
|
||||
deployment_config=deployment._deployment_config,
|
||||
actor_id="local",
|
||||
ray_actor_options=deployment.ray_actor_options,
|
||||
)
|
||||
try:
|
||||
logger.info(f"Initializing local replica class for {deployment_id}.")
|
||||
user_callable_wrapper.initialize_callable().result()
|
||||
user_callable_wrapper.call_reconfigure(deployment.user_config, rank=0)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to initialize deployment {deployment_id}.")
|
||||
raise
|
||||
|
||||
def _create_local_router(
|
||||
handle_id: str, deployment_id: DeploymentID, handle_options: Any
|
||||
) -> Router:
|
||||
return LocalRouter(
|
||||
user_callable_wrapper,
|
||||
deployment_id=deployment_id,
|
||||
handle_options=handle_options,
|
||||
)
|
||||
|
||||
return DeploymentHandle(
|
||||
deployment.name,
|
||||
app_name,
|
||||
_create_router=_create_local_router,
|
||||
)
|
||||
|
||||
|
||||
class LocalReplicaResult(ReplicaResult):
|
||||
"""ReplicaResult used by in-process Deployment Handles."""
|
||||
|
||||
OBJ_REF_NOT_SUPPORTED_ERROR = RuntimeError(
|
||||
"Converting DeploymentResponses to ObjectRefs is not supported "
|
||||
"in local testing mode."
|
||||
)
|
||||
REJECTION_NOT_SUPPORTED_ERROR = RuntimeError(
|
||||
"Request rejection is not supported in local testing mode."
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
future: concurrent.futures.Future,
|
||||
*,
|
||||
request_id: str,
|
||||
is_streaming: bool = False,
|
||||
generator_result_queue: Optional[queue.Queue] = None,
|
||||
):
|
||||
self._future = future
|
||||
self._lazy_asyncio_future = None
|
||||
self._request_id = request_id
|
||||
self._is_streaming = is_streaming
|
||||
|
||||
# For streaming requests, results must be written to this queue.
|
||||
# The queue will be consumed until the future is completed.
|
||||
self._generator_result_queue = generator_result_queue
|
||||
if self._is_streaming:
|
||||
assert (
|
||||
self._generator_result_queue is not None
|
||||
), "generator_result_queue must be provided for streaming results."
|
||||
|
||||
@property
|
||||
def _asyncio_future(self) -> asyncio.Future:
|
||||
if self._lazy_asyncio_future is None:
|
||||
self._lazy_asyncio_future = asyncio.wrap_future(self._future)
|
||||
|
||||
return self._lazy_asyncio_future
|
||||
|
||||
def _process_response(f: Union[Callable, Coroutine]):
|
||||
@wraps(f)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return f(self, *args, **kwargs)
|
||||
except (asyncio.CancelledError, concurrent.futures.CancelledError):
|
||||
raise RequestCancelledError(self._request_id)
|
||||
|
||||
@wraps(f)
|
||||
async def async_wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return await f(self, *args, **kwargs)
|
||||
except (asyncio.CancelledError, concurrent.futures.CancelledError):
|
||||
raise RequestCancelledError(self._request_id)
|
||||
|
||||
if inspect.iscoroutinefunction(f):
|
||||
return async_wrapper
|
||||
else:
|
||||
return wrapper
|
||||
|
||||
@_process_response
|
||||
async def get_rejection_response(self):
|
||||
raise self.REJECTION_NOT_SUPPORTED_ERROR
|
||||
|
||||
@_process_response
|
||||
def get(self, timeout_s: Optional[float]):
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "get() can only be called on a non-streaming result."
|
||||
|
||||
try:
|
||||
return self._future.result(timeout=timeout_s)
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise TimeoutError("Timed out waiting for result.")
|
||||
|
||||
@_process_response
|
||||
async def get_async(self):
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "get_async() can only be called on a non-streaming result."
|
||||
|
||||
return await self._asyncio_future
|
||||
|
||||
@_process_response
|
||||
def __next__(self):
|
||||
assert self._is_streaming, "next() can only be called on a streaming result."
|
||||
|
||||
while True:
|
||||
if self._future.done() and self._generator_result_queue.empty():
|
||||
if self._future.exception():
|
||||
raise self._future.exception()
|
||||
else:
|
||||
raise StopIteration
|
||||
|
||||
try:
|
||||
return self._generator_result_queue.get(timeout=0.01)
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
@_process_response
|
||||
async def __anext__(self):
|
||||
assert self._is_streaming, "anext() can only be called on a streaming result."
|
||||
|
||||
# This callback does not pull from the queue, only checks that a result is
|
||||
# available, else there is a race condition where the future finishes and the
|
||||
# queue is empty, but this function hasn't returned the result yet.
|
||||
def _wait_for_result():
|
||||
while True:
|
||||
if self._future.done() or not self._generator_result_queue.empty():
|
||||
return
|
||||
time.sleep(0.01)
|
||||
|
||||
wait_for_result_task = asyncio.get_running_loop().create_task(
|
||||
asyncio.to_thread(_wait_for_result),
|
||||
)
|
||||
done, _ = await asyncio.wait(
|
||||
[self._asyncio_future, wait_for_result_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if not self._generator_result_queue.empty():
|
||||
return self._generator_result_queue.get()
|
||||
|
||||
if self._asyncio_future.exception():
|
||||
raise self._asyncio_future.exception()
|
||||
|
||||
raise StopAsyncIteration
|
||||
|
||||
def add_done_callback(self, callback: Callable):
|
||||
self._future.add_done_callback(callback)
|
||||
|
||||
def cancel(self):
|
||||
self._future.cancel()
|
||||
|
||||
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
|
||||
raise self.OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
|
||||
async def to_object_ref_async(self) -> ray.ObjectRef:
|
||||
raise self.OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
|
||||
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
|
||||
raise self.OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
|
||||
|
||||
class LocalRouter(Router):
|
||||
def __init__(
|
||||
self,
|
||||
user_callable_wrapper: UserCallableWrapper,
|
||||
deployment_id: DeploymentID,
|
||||
handle_options: Any,
|
||||
):
|
||||
self._deployment_id = deployment_id
|
||||
self._user_callable_wrapper = user_callable_wrapper
|
||||
assert (
|
||||
self._user_callable_wrapper._callable is not None
|
||||
), "User callable must already be initialized."
|
||||
|
||||
def running_replicas_populated(self) -> bool:
|
||||
return True
|
||||
|
||||
def _resolve_deployment_responses(
|
||||
self, request_args: Tuple[Any], request_kwargs: Dict[str, Any]
|
||||
) -> Tuple[Tuple[Any], Dict[str, Any]]:
|
||||
"""Replace DeploymentResponse objects with their results.
|
||||
|
||||
NOTE(edoakes): this currently calls the blocking `.result()` method
|
||||
on the responses to resolve them to their values. This is a divergence
|
||||
from the remote codepath where they're resolved concurrently.
|
||||
"""
|
||||
|
||||
def _new_arg(arg: Any) -> Any:
|
||||
if isinstance(arg, DeploymentResponse):
|
||||
new_arg = arg.result(_skip_asyncio_check=True)
|
||||
elif isinstance(arg, DeploymentResponseGenerator):
|
||||
raise GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR
|
||||
else:
|
||||
new_arg = arg
|
||||
|
||||
return new_arg
|
||||
|
||||
# Serialize and deserialize the arguments to mimic remote call behavior.
|
||||
return cloudpickle.loads(
|
||||
cloudpickle.dumps(
|
||||
(
|
||||
tuple(_new_arg(arg) for arg in request_args),
|
||||
{k: _new_arg(v) for k, v in request_kwargs.items()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def assign_request(
|
||||
self,
|
||||
request_meta: RequestMetadata,
|
||||
*request_args,
|
||||
**request_kwargs,
|
||||
) -> concurrent.futures.Future[LocalReplicaResult]:
|
||||
request_args, request_kwargs = self._resolve_deployment_responses(
|
||||
request_args, request_kwargs
|
||||
)
|
||||
|
||||
if request_meta.is_streaming:
|
||||
generator_result_queue = queue.Queue()
|
||||
|
||||
def generator_result_callback(item: Any):
|
||||
generator_result_queue.put_nowait(item)
|
||||
|
||||
else:
|
||||
generator_result_queue = None
|
||||
generator_result_callback = None
|
||||
|
||||
# Conform to the router interface of returning a future to the ReplicaResult.
|
||||
if request_meta.is_http_request:
|
||||
fut = self._user_callable_wrapper._call_http_entrypoint(
|
||||
request_meta,
|
||||
request_args,
|
||||
request_kwargs,
|
||||
generator_result_callback=generator_result_callback,
|
||||
)
|
||||
elif request_meta.is_streaming:
|
||||
fut = self._user_callable_wrapper._call_user_generator(
|
||||
request_meta,
|
||||
request_args,
|
||||
request_kwargs,
|
||||
enqueue=generator_result_callback,
|
||||
)
|
||||
else:
|
||||
fut = self._user_callable_wrapper.call_user_method(
|
||||
request_meta,
|
||||
request_args,
|
||||
request_kwargs,
|
||||
)
|
||||
noop_future = concurrent.futures.Future()
|
||||
noop_future.set_result(
|
||||
LocalReplicaResult(
|
||||
fut,
|
||||
request_id=request_meta.request_id,
|
||||
is_streaming=request_meta.is_streaming,
|
||||
generator_result_queue=generator_result_queue,
|
||||
)
|
||||
)
|
||||
return noop_future
|
||||
|
||||
@asynccontextmanager
|
||||
async def choose_replica(
|
||||
self,
|
||||
request_meta: RequestMetadata,
|
||||
*request_args,
|
||||
**request_kwargs,
|
||||
) -> AsyncIterator[ReplicaSelection]:
|
||||
"""Choose replica is not supported in local testing mode.
|
||||
|
||||
This is a stub implementation to satisfy the Router ABC interface.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"choose_replica is not supported in local testing mode. "
|
||||
"Use assign_request instead."
|
||||
)
|
||||
yield # Make this a generator for asynccontextmanager
|
||||
|
||||
def dispatch(
|
||||
self,
|
||||
selection: ReplicaSelection,
|
||||
request_meta: RequestMetadata,
|
||||
*request_args,
|
||||
**request_kwargs,
|
||||
) -> concurrent.futures.Future[ReplicaResult]:
|
||||
"""Dispatch is not supported in local testing mode.
|
||||
|
||||
This is a stub implementation to satisfy the Router ABC interface.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"dispatch is not supported in local testing mode. "
|
||||
"Use assign_request instead."
|
||||
)
|
||||
|
||||
async def broadcast(
|
||||
self,
|
||||
request_meta: RequestMetadata,
|
||||
*request_args,
|
||||
**request_kwargs,
|
||||
) -> List[ReplicaResult]:
|
||||
"""Broadcast in local testing mode calls the single local replica."""
|
||||
result_future = self.assign_request(
|
||||
request_meta, *request_args, **request_kwargs
|
||||
)
|
||||
# In local testing mode there is only one replica.
|
||||
replica_result = result_future.result()
|
||||
return [replica_result]
|
||||
|
||||
def shutdown(self):
|
||||
noop_future = concurrent.futures.Future()
|
||||
noop_future.set_result(None)
|
||||
return noop_future
|
||||
@@ -0,0 +1,552 @@
|
||||
import builtins
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any, Optional
|
||||
|
||||
import ray
|
||||
from ray._common.filters import CoreContextFilter
|
||||
from ray._common.formatters import JSONFormatter, TextFormatter
|
||||
from ray._common.ray_constants import LOGGING_ROTATE_BACKUP_COUNT, LOGGING_ROTATE_BYTES
|
||||
from ray.serve._private.common import ServeComponentType
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_ENABLE_MEMORY_PROFILING,
|
||||
RAY_SERVE_LOG_CLIENT_ADDRESS,
|
||||
RAY_SERVE_LOG_TO_STDERR,
|
||||
SERVE_LOG_APPLICATION,
|
||||
SERVE_LOG_COMPONENT,
|
||||
SERVE_LOG_COMPONENT_ID,
|
||||
SERVE_LOG_DEPLOYMENT,
|
||||
SERVE_LOG_LEVEL_NAME,
|
||||
SERVE_LOG_MESSAGE,
|
||||
SERVE_LOG_RECORD_FORMAT,
|
||||
SERVE_LOG_REPLICA,
|
||||
SERVE_LOG_REQUEST_ID,
|
||||
SERVE_LOG_ROUTE,
|
||||
SERVE_LOG_TIME,
|
||||
SERVE_LOG_UNWANTED_ATTRS,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.utils import get_component_file_name
|
||||
from ray.serve.schema import EncodingType, LoggingConfig
|
||||
|
||||
buildin_print = builtins.print
|
||||
|
||||
|
||||
def should_skip_context_filter(record: logging.LogRecord) -> bool:
|
||||
"""Check if the log record should skip the context filter."""
|
||||
return getattr(record, "skip_context_filter", False)
|
||||
|
||||
|
||||
class ServeCoreContextFilter(CoreContextFilter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if should_skip_context_filter(record):
|
||||
return True
|
||||
return super().filter(record)
|
||||
|
||||
|
||||
class ServeComponentFilter(logging.Filter):
|
||||
"""Serve component filter.
|
||||
|
||||
The filter will add the component name, id, and type to the log record.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
component_type: Optional[ServeComponentType] = None,
|
||||
):
|
||||
self.component_name = component_name
|
||||
self.component_id = component_id
|
||||
self.component_type = component_type
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""Add component attributes to the log record.
|
||||
|
||||
Note: the filter doesn't do any filtering, it only adds the component
|
||||
attributes.
|
||||
"""
|
||||
if should_skip_context_filter(record):
|
||||
return True
|
||||
if self.component_type and self.component_type == ServeComponentType.REPLICA:
|
||||
setattr(record, SERVE_LOG_DEPLOYMENT, self.component_name)
|
||||
setattr(record, SERVE_LOG_REPLICA, self.component_id)
|
||||
setattr(record, SERVE_LOG_COMPONENT, self.component_type)
|
||||
else:
|
||||
setattr(record, SERVE_LOG_COMPONENT, self.component_name)
|
||||
setattr(record, SERVE_LOG_COMPONENT_ID, self.component_id)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ServeContextFilter(logging.Filter):
|
||||
"""Serve context filter.
|
||||
|
||||
The filter will add the route, request id, app name to the log record.
|
||||
|
||||
Note: the filter doesn't do any filtering, it only adds the serve request context
|
||||
attributes.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
if should_skip_context_filter(record):
|
||||
return True
|
||||
|
||||
request_context = ray.serve.context._get_serve_request_context()
|
||||
if request_context.route:
|
||||
setattr(record, SERVE_LOG_ROUTE, request_context.route)
|
||||
if request_context.request_id:
|
||||
setattr(record, SERVE_LOG_REQUEST_ID, request_context.request_id)
|
||||
if request_context.app_name:
|
||||
setattr(record, SERVE_LOG_APPLICATION, request_context.app_name)
|
||||
return True
|
||||
|
||||
|
||||
class ServeLogAttributeRemovalFilter(logging.Filter):
|
||||
"""Serve log attribute removal filter.
|
||||
|
||||
The filter will remove unwanted attributes on the log record so they won't be
|
||||
included in the structured logs.
|
||||
|
||||
Note: the filter doesn't do any filtering, it only removes unwanted attributes.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
for key in SERVE_LOG_UNWANTED_ATTRS:
|
||||
if hasattr(record, key):
|
||||
delattr(record, key)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ServeFormatter(TextFormatter):
|
||||
"""Serve Logging Formatter
|
||||
|
||||
The formatter will generate the log format on the fly based on the field of record.
|
||||
Optimized to pre-compute format strings and formatters for better performance.
|
||||
"""
|
||||
|
||||
COMPONENT_LOG_FMT = f"%({SERVE_LOG_LEVEL_NAME})s %({SERVE_LOG_TIME})s {{{SERVE_LOG_COMPONENT}}} {{{SERVE_LOG_COMPONENT_ID}}} " # noqa:E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
fmt: Optional[str] = None,
|
||||
datefmt: Optional[str] = None,
|
||||
style: str = "%",
|
||||
validate: bool = True,
|
||||
):
|
||||
super().__init__(fmt, datefmt, style, validate)
|
||||
self.component_log_fmt = ServeFormatter.COMPONENT_LOG_FMT.format(
|
||||
component_name=component_name, component_id=component_id
|
||||
)
|
||||
|
||||
# Pre-compute format strings and formatters for performance
|
||||
self._precompute_formatters()
|
||||
|
||||
def set_additional_log_standard_attrs(self, *args, **kwargs):
|
||||
super().set_additional_log_standard_attrs(*args, **kwargs)
|
||||
self._precompute_formatters()
|
||||
|
||||
def _precompute_formatters(self):
|
||||
self.base_formatter = self._create_formatter([])
|
||||
self.request_formatter = self._create_formatter(
|
||||
[SERVE_LOG_RECORD_FORMAT[SERVE_LOG_REQUEST_ID]]
|
||||
)
|
||||
|
||||
def _create_formatter(self, initial_attrs: list) -> logging.Formatter:
|
||||
attrs = initial_attrs.copy()
|
||||
attrs.extend([f"%({k})s" for k in self.additional_log_standard_attrs])
|
||||
attrs.append(SERVE_LOG_RECORD_FORMAT[SERVE_LOG_MESSAGE])
|
||||
|
||||
format_string = self.component_log_fmt + " ".join(attrs)
|
||||
return logging.Formatter(format_string)
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
"""Format the log record into the format string.
|
||||
|
||||
Args:
|
||||
record: The log record to be formatted.
|
||||
|
||||
Returns:
|
||||
The formatted log record in string format.
|
||||
"""
|
||||
# Use pre-computed formatters for better performance
|
||||
if SERVE_LOG_REQUEST_ID in record.__dict__:
|
||||
return self.request_formatter.format(record)
|
||||
else:
|
||||
return self.base_formatter.format(record)
|
||||
|
||||
|
||||
def format_grpc_peer_address(peer: str) -> str:
|
||||
"""Extract the client address from a gRPC peer() string.
|
||||
|
||||
gRPC peer() returns "ipv4:host:port" or "ipv6:%5Bhost%5D:port".
|
||||
Strips the protocol prefix and URL-decodes IPv6 brackets.
|
||||
"""
|
||||
if not peer:
|
||||
return ""
|
||||
for prefix in ("ipv4:", "ipv6:"):
|
||||
if peer.startswith(prefix):
|
||||
addr = peer[len(prefix) :]
|
||||
return addr.replace("%5B", "[").replace("%5D", "]")
|
||||
return peer
|
||||
|
||||
|
||||
def format_client_address(client) -> str:
|
||||
"""Format a raw ASGI scope client value into a string."""
|
||||
if isinstance(client, (tuple, list)):
|
||||
if len(client) != 2:
|
||||
return ":".join(str(x) for x in client)
|
||||
host, port = str(client[0]), str(client[1])
|
||||
# Wrap IPv6 addresses in brackets to avoid ambiguity (e.g. [::1]:54321).
|
||||
if ":" in host:
|
||||
return f"[{host}]:{port}"
|
||||
return f"{host}:{port}"
|
||||
elif isinstance(client, str):
|
||||
return client
|
||||
return str(client) if client else ""
|
||||
|
||||
|
||||
def access_log_msg(
|
||||
*, method: str, route: str, status: str, latency_ms: float, client: str = ""
|
||||
):
|
||||
"""Returns a formatted message for an HTTP or ServeHandle access log."""
|
||||
if client and RAY_SERVE_LOG_CLIENT_ADDRESS:
|
||||
return f"{client} {method} {route} {status} {latency_ms:.1f}ms"
|
||||
return f"{method} {route} {status} {latency_ms:.1f}ms"
|
||||
|
||||
|
||||
def log_to_stderr_filter(record: logging.LogRecord) -> bool:
|
||||
"""Filters log records based on a parameter in the `extra` dictionary."""
|
||||
if not hasattr(record, "log_to_stderr") or record.log_to_stderr is None:
|
||||
return True
|
||||
|
||||
return record.log_to_stderr
|
||||
|
||||
|
||||
def log_access_log_filter(record: logging.LogRecord) -> bool:
|
||||
"""Filters ray serve access log based on 'serve_access_log' key in `extra` dict."""
|
||||
if not hasattr(record, "serve_access_log") or record.serve_access_log is None:
|
||||
return True
|
||||
|
||||
return not record.serve_access_log
|
||||
|
||||
|
||||
def get_component_logger_file_path() -> Optional[str]:
|
||||
"""Returns the relative file path for the Serve logger, if it exists.
|
||||
|
||||
If a logger was configured through configure_component_logger() for the Serve
|
||||
component that's calling this function, this returns the location of the log file
|
||||
relative to the ray logs directory.
|
||||
"""
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
for handler in logger.handlers:
|
||||
if isinstance(handler, logging.handlers.MemoryHandler):
|
||||
absolute_path = handler.target.baseFilename
|
||||
ray_logs_dir = ray._private.worker._global_node.get_logs_dir_path()
|
||||
if absolute_path.startswith(ray_logs_dir):
|
||||
return absolute_path[len(ray_logs_dir) :]
|
||||
|
||||
|
||||
class StreamToLogger(object):
|
||||
"""
|
||||
Fake file-like stream object that redirects writes to a logger instance.
|
||||
|
||||
This comes from https://stackoverflow.com/a/36296215 directly.
|
||||
"""
|
||||
|
||||
def __init__(self, logger: logging.Logger, log_level: int, original_object: Any):
|
||||
self._logger = logger
|
||||
self._log_level = log_level
|
||||
self._original_object = original_object
|
||||
self._linebuf = ""
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
# getting attributes from the original object
|
||||
return getattr(self._original_object, attr)
|
||||
|
||||
@staticmethod
|
||||
def get_stacklevel() -> int:
|
||||
"""Rewind stack to get the stacklevel for the user code.
|
||||
|
||||
Going from the back of the traceback and traverse until it's no longer in
|
||||
logging_utils.py or site-packages.
|
||||
"""
|
||||
reverse_traces = traceback.extract_stack()[::-1]
|
||||
for index, trace in enumerate(reverse_traces):
|
||||
if (
|
||||
"logging_utils.py" not in trace.filename
|
||||
and "site-packages" not in trace.filename
|
||||
):
|
||||
return index
|
||||
return 1
|
||||
|
||||
def write(self, buf: str):
|
||||
temp_linebuf = self._linebuf + buf
|
||||
self._linebuf = ""
|
||||
for line in temp_linebuf.splitlines(True):
|
||||
# From the io.TextIOWrapper docs:
|
||||
# On output, if newline is None, any '\n' characters written
|
||||
# are translated to the system default line separator.
|
||||
# By default sys.stdout.write() expects '\n' newlines and then
|
||||
# translates them so this is still cross-platform.
|
||||
if line[-1] == "\n":
|
||||
self._logger.log(
|
||||
self._log_level,
|
||||
line.rstrip(),
|
||||
stacklevel=self.get_stacklevel(),
|
||||
)
|
||||
else:
|
||||
self._linebuf += line
|
||||
|
||||
def flush(self):
|
||||
if self._linebuf != "":
|
||||
self._logger.log(
|
||||
self._log_level,
|
||||
self._linebuf.rstrip(),
|
||||
stacklevel=self.get_stacklevel(),
|
||||
)
|
||||
self._linebuf = ""
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def redirected_print(*objects, sep=" ", end="\n", file=None, flush=False):
|
||||
"""Implement python's print function to redirect logs to Serve's logger.
|
||||
|
||||
If the file is set to anything other than stdout, stderr, or None, call the
|
||||
builtin print. Else, construct the message and redirect to Serve's logger.
|
||||
|
||||
See https://docs.python.org/3/library/functions.html#print
|
||||
"""
|
||||
if file not in [sys.stdout, sys.stderr, None]:
|
||||
return buildin_print(objects, sep=sep, end=end, file=file, flush=flush)
|
||||
|
||||
serve_logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
message = sep.join(map(str, objects)) + end
|
||||
# We monkey patched print function, so this is always at stack level 2.
|
||||
serve_logger.log(logging.INFO, message, stacklevel=2)
|
||||
|
||||
|
||||
def configure_component_logger(
|
||||
*,
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
logging_config: LoggingConfig,
|
||||
component_type: Optional[ServeComponentType] = None,
|
||||
max_bytes: Optional[int] = None,
|
||||
backup_count: Optional[int] = None,
|
||||
stream_handler_only: bool = False,
|
||||
buffer_size: int = 1,
|
||||
):
|
||||
"""Configure a logger to be used by a Serve component.
|
||||
|
||||
The logger will log using a standard format to make components identifiable
|
||||
using the provided name and unique ID for this instance (e.g., replica ID).
|
||||
|
||||
This logger will *not* propagate its log messages to the parent logger(s).
|
||||
"""
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
logger.propagate = False
|
||||
logger.setLevel(logging_config.log_level)
|
||||
logger.handlers.clear()
|
||||
|
||||
serve_formatter = ServeFormatter(component_name, component_id)
|
||||
json_formatter = JSONFormatter()
|
||||
if logging_config.additional_log_standard_attrs:
|
||||
json_formatter.set_additional_log_standard_attrs(
|
||||
logging_config.additional_log_standard_attrs
|
||||
)
|
||||
serve_formatter.set_additional_log_standard_attrs(
|
||||
logging_config.additional_log_standard_attrs
|
||||
)
|
||||
|
||||
# Only add stream handler if RAY_SERVE_LOG_TO_STDERR is True or if
|
||||
# `stream_handler_only` is set to True.
|
||||
if RAY_SERVE_LOG_TO_STDERR or stream_handler_only:
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(serve_formatter)
|
||||
stream_handler.addFilter(log_to_stderr_filter)
|
||||
stream_handler.addFilter(ServeContextFilter())
|
||||
if logging_config.enable_access_log is False:
|
||||
stream_handler.addFilter(log_access_log_filter)
|
||||
logger.addHandler(stream_handler)
|
||||
|
||||
# Skip setting up file handler and stdout/stderr redirect if `stream_handler_only`
|
||||
# is set to True. Logger such as default serve logger can be configured outside the
|
||||
# context of a Serve component, we don't want those logs to redirect into serve's
|
||||
# logger and log files.
|
||||
if stream_handler_only:
|
||||
return
|
||||
|
||||
if logging_config.logs_dir:
|
||||
logs_dir = logging_config.logs_dir
|
||||
else:
|
||||
logs_dir = get_serve_logs_dir()
|
||||
os.makedirs(logs_dir, exist_ok=True)
|
||||
|
||||
if max_bytes is None:
|
||||
max_bytes = ray._private.worker._global_node.max_bytes
|
||||
if backup_count is None:
|
||||
backup_count = ray._private.worker._global_node.backup_count
|
||||
|
||||
log_file_name = get_component_file_name(
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
component_type=component_type,
|
||||
suffix=".log",
|
||||
)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
os.path.join(logs_dir, log_file_name),
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
)
|
||||
# Create a memory handler that buffers log records and flushes to file handler
|
||||
# Buffer capacity: buffer_size records
|
||||
# Flush triggers: buffer full, ERROR messages, or explicit flush
|
||||
memory_handler = logging.handlers.MemoryHandler(
|
||||
capacity=buffer_size,
|
||||
target=file_handler,
|
||||
flushLevel=logging.ERROR, # Auto-flush on ERROR/CRITICAL
|
||||
)
|
||||
# Add filters directly to the memory handler effective for both buffered and non buffered cases
|
||||
if logging_config.encoding == EncodingType.JSON:
|
||||
memory_handler.addFilter(ServeCoreContextFilter())
|
||||
memory_handler.addFilter(ServeContextFilter())
|
||||
memory_handler.addFilter(
|
||||
ServeComponentFilter(component_name, component_id, component_type)
|
||||
)
|
||||
file_handler.setFormatter(json_formatter)
|
||||
else:
|
||||
file_handler.setFormatter(serve_formatter)
|
||||
|
||||
if logging_config.enable_access_log is False:
|
||||
memory_handler.addFilter(log_access_log_filter)
|
||||
else:
|
||||
memory_handler.addFilter(ServeContextFilter())
|
||||
|
||||
# Remove unwanted attributes from the log record.
|
||||
memory_handler.addFilter(ServeLogAttributeRemovalFilter())
|
||||
|
||||
# Redirect print, stdout, and stderr to Serve logger, only when it's on the replica.
|
||||
if not RAY_SERVE_LOG_TO_STDERR and component_type == ServeComponentType.REPLICA:
|
||||
builtins.print = redirected_print
|
||||
sys.stdout = StreamToLogger(logger, logging.INFO, sys.stdout)
|
||||
sys.stderr = StreamToLogger(logger, logging.INFO, sys.stderr)
|
||||
|
||||
# Add the memory handler instead of the file handler directly
|
||||
logger.addHandler(memory_handler)
|
||||
|
||||
|
||||
def configure_default_serve_logger():
|
||||
"""Helper function to configure the default Serve logger that's used outside of
|
||||
individual Serve components."""
|
||||
configure_component_logger(
|
||||
component_name="serve",
|
||||
component_id=str(os.getpid()),
|
||||
logging_config=LoggingConfig(),
|
||||
max_bytes=LOGGING_ROTATE_BYTES,
|
||||
backup_count=LOGGING_ROTATE_BACKUP_COUNT,
|
||||
stream_handler_only=True,
|
||||
)
|
||||
|
||||
|
||||
def configure_component_memory_profiler(
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
component_type: Optional[ServeComponentType] = None,
|
||||
):
|
||||
"""Configures the memory logger for this component.
|
||||
|
||||
Does nothing if RAY_SERVE_ENABLE_MEMORY_PROFILING is disabled.
|
||||
"""
|
||||
|
||||
if RAY_SERVE_ENABLE_MEMORY_PROFILING:
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
try:
|
||||
import memray
|
||||
|
||||
logs_dir = get_serve_logs_dir()
|
||||
memray_file_name = get_component_file_name(
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
component_type=component_type,
|
||||
suffix="_memray_0.bin",
|
||||
)
|
||||
memray_file_path = os.path.join(logs_dir, memray_file_name)
|
||||
|
||||
# If the actor restarted, memray requires a new file to start
|
||||
# tracking memory.
|
||||
restart_counter = 1
|
||||
while os.path.exists(memray_file_path):
|
||||
memray_file_name = get_component_file_name(
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
component_type=component_type,
|
||||
suffix=f"_memray_{restart_counter}.bin",
|
||||
)
|
||||
memray_file_path = os.path.join(logs_dir, memray_file_name)
|
||||
restart_counter += 1
|
||||
|
||||
# Memray usually tracks the memory usage of only a block of code
|
||||
# within a context manager. We explicitly call __enter__ here
|
||||
# instead of using a context manager to track memory usage across
|
||||
# all of the caller's code instead.
|
||||
memray.Tracker(memray_file_path, native_traces=True).__enter__()
|
||||
|
||||
logger.info(
|
||||
"RAY_SERVE_ENABLE_MEMORY_PROFILING is enabled. Started a "
|
||||
"memray tracker on this actor. Tracker file located at "
|
||||
f'"{memray_file_path}"'
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"RAY_SERVE_ENABLE_MEMORY_PROFILING is enabled, but memray "
|
||||
"is not installed. No memory profiling is happening. "
|
||||
"`pip install memray` to enable memory profiling."
|
||||
)
|
||||
|
||||
|
||||
def get_serve_logs_dir() -> str:
|
||||
"""Get the directory that stores Serve log files.
|
||||
|
||||
If `ray._private.worker._global_node` is None (running outside the context of Ray),
|
||||
then the current working directory with subdirectory of serve is used as the logs
|
||||
directory. Otherwise, the logs directory is determined by the global node's logs
|
||||
directory path.
|
||||
"""
|
||||
if ray._private.worker._global_node is None:
|
||||
return os.path.join(os.getcwd(), "serve")
|
||||
|
||||
return os.path.join(ray._private.worker._global_node.get_logs_dir_path(), "serve")
|
||||
|
||||
|
||||
class LoggingContext:
|
||||
"""
|
||||
Context manager to manage logging behaviors within a particular block, such as:
|
||||
1) Overriding logging level
|
||||
|
||||
Source (python3 official documentation)
|
||||
https://docs.python.org/3/howto/logging-cookbook.html#using-a-context-manager-for-selective-logging # noqa: E501
|
||||
"""
|
||||
|
||||
def __init__(self, logger, level=None):
|
||||
self.logger = logger
|
||||
self.level = level
|
||||
|
||||
def __enter__(self):
|
||||
if self.level is not None:
|
||||
self.old_level = self.logger.level
|
||||
self.logger.setLevel(self.level)
|
||||
|
||||
def __exit__(self, et, ev, tb):
|
||||
if self.level is not None:
|
||||
self.logger.setLevel(self.old_level)
|
||||
@@ -0,0 +1,697 @@
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import time
|
||||
from asyncio import sleep
|
||||
from asyncio.events import AbstractEventLoop
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
DefaultDict,
|
||||
Dict,
|
||||
Iterable,
|
||||
Optional,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
import ray
|
||||
from ray._common.utils import get_or_create_event_loop
|
||||
from ray.serve._private.constants import (
|
||||
DEFAULT_LATENCY_BUCKET_MS,
|
||||
RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve.generated.serve_pb2 import (
|
||||
DeploymentTargetInfo,
|
||||
EndpointInfo as EndpointInfoProto,
|
||||
EndpointSet,
|
||||
LongPollRequest,
|
||||
LongPollResult,
|
||||
UpdatedObject as UpdatedObjectProto,
|
||||
)
|
||||
from ray.util import metrics
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
# Each LongPollClient will send requests to LongPollHost to poll changes
|
||||
# as blocking awaitable. This doesn't scale if we have many client instances
|
||||
# that will slow down, or even block controller actor's event loop if near
|
||||
# its max_concurrency limit. Therefore we timeout a polling request after
|
||||
# a few seconds and let each client retry on their end.
|
||||
# We randomly select a timeout within this range to avoid a "thundering herd"
|
||||
# when there are many clients subscribing at the same time.
|
||||
LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S = (
|
||||
float(os.environ.get("LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_LOWER_BOUND", "30")),
|
||||
float(os.environ.get("LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_UPPER_BOUND", "60")),
|
||||
)
|
||||
|
||||
|
||||
class LongPollNamespace(Enum):
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}.{self.name}"
|
||||
|
||||
DEPLOYMENT_TARGETS = auto()
|
||||
ROUTE_TABLE = auto()
|
||||
GLOBAL_LOGGING_CONFIG = auto()
|
||||
DEPLOYMENT_CONFIG = auto()
|
||||
TARGET_GROUPS = auto()
|
||||
FALLBACK_TARGETS = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdatedObject:
|
||||
object_snapshot: Any
|
||||
# The identifier for the object's version. There is not sequential relation
|
||||
# among different object's snapshot_ids.
|
||||
snapshot_id: int
|
||||
# Timestamp (in seconds since epoch) when notify_changed was called.
|
||||
# Used by clients to measure end-to-end propagation latency.
|
||||
notify_timestamp: float
|
||||
|
||||
|
||||
# Type signature for the update state callbacks. E.g.
|
||||
# async def update_state(updated_object: Any):
|
||||
# do_something(updated_object)
|
||||
UpdateStateCallable = Callable[[Any], None]
|
||||
KeyType = Union[str, LongPollNamespace, Tuple[LongPollNamespace, str]]
|
||||
|
||||
|
||||
def _get_metric_namespace_tag(key: KeyType) -> str:
|
||||
"""Extract the namespace string from a long poll key for metric tags.
|
||||
|
||||
When RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS is enabled, returns only
|
||||
the LongPollNamespace enum name (e.g., "DEPLOYMENT_CONFIG") for tuple keys
|
||||
like (LongPollNamespace.DEPLOYMENT_CONFIG, "deployment_name"), avoiding
|
||||
high-cardinality metric labels that would otherwise include per-deployment
|
||||
identifiers. When disabled (default), returns str(key) preserving
|
||||
deployment-level metric granularity.
|
||||
"""
|
||||
if not RAY_SERVE_COMPACT_LONG_POLL_METRIC_TAGS:
|
||||
return str(key)
|
||||
if isinstance(key, tuple):
|
||||
return key[0].name if isinstance(key[0], LongPollNamespace) else str(key[0])
|
||||
elif isinstance(key, LongPollNamespace):
|
||||
return key.name
|
||||
else:
|
||||
return str(key)
|
||||
|
||||
|
||||
class LongPollState(Enum):
|
||||
TIME_OUT = auto()
|
||||
|
||||
|
||||
class LongPollClient:
|
||||
"""The asynchronous long polling client.
|
||||
|
||||
Args:
|
||||
host_actor: handle to actor embedding LongPollHost.
|
||||
key_listeners: a dictionary mapping keys to
|
||||
callbacks to be called on state update for the corresponding keys.
|
||||
call_in_event_loop: an asyncio event loop
|
||||
to post the callback into.
|
||||
client_id: identifier reported back to the host if this client
|
||||
disables itself.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host_actor: Any,
|
||||
key_listeners: Dict[KeyType, UpdateStateCallable],
|
||||
call_in_event_loop: AbstractEventLoop,
|
||||
client_id: str,
|
||||
) -> None:
|
||||
# We used to allow this to be optional, but due to Ray Client issue
|
||||
# we now enforce all long poll client to post callback to event loop
|
||||
# See https://github.com/ray-project/ray/issues/20971
|
||||
assert call_in_event_loop is not None
|
||||
|
||||
self.host_actor = host_actor
|
||||
self.key_listeners = key_listeners
|
||||
self.event_loop = call_in_event_loop
|
||||
self.client_id = client_id
|
||||
# The initial snapshot id for each key is < 0,
|
||||
# but real snapshot keys in the long poll host are always >= 0,
|
||||
# so this will always trigger an initial update.
|
||||
self.snapshot_ids: Dict[KeyType, int] = dict.fromkeys(
|
||||
self.key_listeners.keys(), -1
|
||||
)
|
||||
self.is_running = True
|
||||
|
||||
# Metric to track end-to-end latency from controller to client
|
||||
self.long_poll_latency_histogram = metrics.Histogram(
|
||||
"serve_long_poll_latency_ms",
|
||||
description=(
|
||||
"The time in milliseconds for updates to propagate from "
|
||||
"controller to clients."
|
||||
),
|
||||
boundaries=DEFAULT_LATENCY_BUCKET_MS,
|
||||
tag_keys=("namespace",),
|
||||
)
|
||||
|
||||
# NOTE(edoakes): we schedule the initial _poll_next call with an empty context
|
||||
# so that Ray will not recursively cancel the underlying `listen_for_change`
|
||||
# task. See: https://github.com/ray-project/ray/issues/52476.
|
||||
self.event_loop.call_soon_threadsafe(
|
||||
self._poll_next, context=contextvars.Context()
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the long poll client after the next RPC returns."""
|
||||
self.is_running = False
|
||||
|
||||
def add_key_listeners(
|
||||
self, key_listeners: Dict[KeyType, UpdateStateCallable]
|
||||
) -> None:
|
||||
"""Add more key listeners to the client.
|
||||
The new listeners will only be included in the *next* long poll request;
|
||||
the current request will continue with the existing listeners.
|
||||
|
||||
If a key is already in the client, the new listener will replace the old one,
|
||||
but the snapshot ID will be preserved, so the new listener will only be called
|
||||
on the *next* update to that key.
|
||||
"""
|
||||
# We need to run the underlying method in the same event loop that runs
|
||||
# the long poll loop, because we need to mutate the mapping of snapshot IDs,
|
||||
# which also needs to be serialized by the long poll's RPC to the
|
||||
# Serve Controller. If those happened concurrently in different threads,
|
||||
# we could get a `RuntimeError: dictionary changed size during iteration`.
|
||||
# See https://github.com/ray-project/ray/pull/52793 for more details.
|
||||
self.event_loop.call_soon_threadsafe(self._add_key_listeners, key_listeners)
|
||||
|
||||
def _add_key_listeners(
|
||||
self, key_listeners: Dict[KeyType, UpdateStateCallable]
|
||||
) -> None:
|
||||
"""Inner method that actually adds the key listeners, to be called
|
||||
via call_soon_threadsafe for thread safety.
|
||||
"""
|
||||
# Only initialize snapshot ids for *new* keys.
|
||||
self.snapshot_ids.update(
|
||||
{key: -1 for key in key_listeners.keys() if key not in self.key_listeners}
|
||||
)
|
||||
self.key_listeners.update(key_listeners)
|
||||
|
||||
def _on_callback_completed(self, trigger_at: int):
|
||||
"""Called after a single callback is completed.
|
||||
|
||||
When the total number of callback completed equals to trigger_at,
|
||||
_poll_next() will be called. This is designed to make sure we only
|
||||
_poll_next() after all the state callbacks completed. This is a
|
||||
way to serialize the callback invocations between object versions.
|
||||
"""
|
||||
self._callbacks_processed_count += 1
|
||||
if self._callbacks_processed_count == trigger_at:
|
||||
self._poll_next()
|
||||
|
||||
def _poll_next(self):
|
||||
"""Poll the update. The callback is expected to scheduler another
|
||||
_poll_next call.
|
||||
"""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
self._callbacks_processed_count = 0
|
||||
self._current_ref = self.host_actor.listen_for_change.remote(self.snapshot_ids)
|
||||
self._current_ref._on_completed(lambda update: self._process_update(update))
|
||||
|
||||
def _schedule_to_event_loop(self, callback):
|
||||
# Schedule the next iteration only if the loop is running.
|
||||
# The event loop might not be running if users used a cached
|
||||
# version across loops.
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
if self.event_loop.is_running():
|
||||
self.event_loop.call_soon_threadsafe(callback)
|
||||
else:
|
||||
reason = "Bound asyncio event loop is not running; controller updates cannot be delivered."
|
||||
logger.error(
|
||||
f"LongPollClient {self.client_id!r} has been disabled: {reason} "
|
||||
f"Keep the loop running for the lifetime of this process."
|
||||
)
|
||||
self.is_running = False
|
||||
# Fire-and-forget notify so the controller logs this client as disabled.
|
||||
try:
|
||||
self.host_actor.notify_long_poll_client_disabled.remote(
|
||||
self.client_id, reason
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to notify host that LongPollClient "
|
||||
f"{self.client_id!r} disabled itself."
|
||||
)
|
||||
|
||||
def _process_update(self, updates: Dict[str, UpdatedObject]):
|
||||
if isinstance(updates, (ray.exceptions.RayActorError)):
|
||||
# This can happen during shutdown where the controller is
|
||||
# intentionally killed, the client should just gracefully
|
||||
# exit.
|
||||
logger.debug("LongPollClient failed to connect to host. Shutting down.")
|
||||
self.is_running = False
|
||||
return
|
||||
|
||||
if isinstance(updates, ConnectionError):
|
||||
logger.warning("LongPollClient connection failed, shutting down.")
|
||||
self.is_running = False
|
||||
return
|
||||
|
||||
if isinstance(updates, (ray.exceptions.RayTaskError)):
|
||||
# Some error happened in the controller. It could be a bug or
|
||||
# some undesired state.
|
||||
logger.error("LongPollHost errored\n" + updates.traceback_str)
|
||||
# We must call this in event loop so it works in Ray Client.
|
||||
# See https://github.com/ray-project/ray/issues/20971
|
||||
self._schedule_to_event_loop(self._poll_next)
|
||||
return
|
||||
|
||||
if updates == LongPollState.TIME_OUT:
|
||||
logger.debug("LongPollClient polling timed out. Retrying.")
|
||||
self._schedule_to_event_loop(self._poll_next)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"LongPollClient {self} received updates for keys: {list(updates.keys())}.",
|
||||
extra={"log_to_stderr": False},
|
||||
)
|
||||
if not updates: # no updates, no callbacks to run, just poll again
|
||||
self._schedule_to_event_loop(self._poll_next)
|
||||
|
||||
# Record latency metrics for received updates.
|
||||
# Skip observations that exceed twice the maximum long poll timeout —
|
||||
# these are catch-up updates received by a client that was offline or
|
||||
# missed several polling cycles, and including them would distort the
|
||||
# metric (e.g. a 10-minute spike just because a new replica connected).
|
||||
max_valid_latency_ms = LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S[1] * 2 * 1000
|
||||
receive_time = time.time()
|
||||
for key, update in updates.items():
|
||||
latency_ms = (receive_time - update.notify_timestamp) * 1000
|
||||
if latency_ms <= max_valid_latency_ms:
|
||||
self.long_poll_latency_histogram.observe(
|
||||
latency_ms,
|
||||
tags={"namespace": _get_metric_namespace_tag(key)},
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"Skipping long poll latency observation of {latency_ms:.0f}ms "
|
||||
f"for key {key} (exceeds threshold {max_valid_latency_ms:.0f}ms)."
|
||||
)
|
||||
|
||||
self.snapshot_ids[key] = update.snapshot_id
|
||||
callback = self.key_listeners[key]
|
||||
|
||||
# Bind the parameters because closures are late-binding.
|
||||
# https://docs.python-guide.org/writing/gotchas/#late-binding-closures # noqa: E501
|
||||
def chained(callback=callback, arg=update.object_snapshot):
|
||||
callback(arg)
|
||||
self._on_callback_completed(trigger_at=len(updates))
|
||||
|
||||
self._schedule_to_event_loop(chained)
|
||||
|
||||
|
||||
class LongPollHost:
|
||||
"""The server side object that manages long pulling requests.
|
||||
|
||||
The desired use case is to embed this in an Ray actor. Client will be
|
||||
expected to call actor.listen_for_change.remote(...). On the host side,
|
||||
you can call host.notify_changed({key: object}) to update the state and
|
||||
potentially notify whoever is polling for these values.
|
||||
|
||||
Internally, we use snapshot_ids for each object to identify client with
|
||||
outdated object and immediately return the result. If the client has the
|
||||
up-to-date version, then the listen_for_change call will only return when
|
||||
the object is updated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
listen_for_change_request_timeout_s: Tuple[
|
||||
int, int
|
||||
] = LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S,
|
||||
):
|
||||
# Map object_key -> int
|
||||
self.snapshot_ids: Dict[KeyType, int] = {}
|
||||
# Map object_key -> object
|
||||
self.object_snapshots: Dict[KeyType, Any] = {}
|
||||
# Map object_key -> set(asyncio.Event waiting for updates)
|
||||
self.notifier_events: DefaultDict[KeyType, Set[asyncio.Event]] = defaultdict(
|
||||
set
|
||||
)
|
||||
# Map object_key -> timestamp when notify_changed was called
|
||||
# Used to track latency for propagating updates to clients
|
||||
self._notify_timestamps: Dict[KeyType, float] = {}
|
||||
# Aggregate count of pending clients per namespace tag. Needed because
|
||||
# multiple keys can map to the same low-cardinality namespace tag, so
|
||||
# we must track the total rather than setting per-key counts.
|
||||
self._pending_clients_by_namespace: DefaultDict[str, int] = defaultdict(int)
|
||||
|
||||
self._listen_for_change_request_timeout_s = listen_for_change_request_timeout_s
|
||||
self.transmission_counter = metrics.Counter(
|
||||
"serve_long_poll_host_transmission_counter",
|
||||
description="The number of times the long poll host transmits data.",
|
||||
tag_keys=("namespace_or_state",),
|
||||
)
|
||||
self.pending_clients_gauge = metrics.Gauge(
|
||||
"serve_long_poll_pending_clients",
|
||||
description=("The number of clients waiting for updates per namespace."),
|
||||
tag_keys=("namespace",),
|
||||
)
|
||||
|
||||
def _get_num_notifier_events(self, key: Optional[KeyType] = None):
|
||||
"""Used for testing."""
|
||||
if key is not None:
|
||||
return len(self.notifier_events[key])
|
||||
else:
|
||||
return sum(len(events) for events in self.notifier_events.values())
|
||||
|
||||
def _get_pending_clients_by_namespace(self, namespace_tag: str) -> int:
|
||||
"""Used for testing. Returns the aggregate pending client count."""
|
||||
return self._pending_clients_by_namespace.get(namespace_tag, 0)
|
||||
|
||||
def notify_client_disabled(self, client_id: str, reason: str) -> None:
|
||||
"""Fire-and-forget hook for clients that are shutting themselves down.
|
||||
|
||||
LongPollClient calls this before flipping ``is_running`` to False when
|
||||
it cannot keep delivering updates (e.g. its bound asyncio loop is no
|
||||
longer running).
|
||||
"""
|
||||
logger.error(
|
||||
f"LongPollClient {client_id!r} disabled itself and will no longer "
|
||||
f"receive controller updates. Reason: {reason}"
|
||||
)
|
||||
|
||||
def _count_send(
|
||||
self, timeout_or_data: Union[LongPollState, Dict[KeyType, UpdatedObject]]
|
||||
):
|
||||
"""Helper method that tracks the data sent by listen_for_change.
|
||||
|
||||
Records number of times long poll host sends data in the
|
||||
ray_serve_long_poll_host_send_counter metric.
|
||||
"""
|
||||
|
||||
if isinstance(timeout_or_data, LongPollState):
|
||||
# The only LongPollState is TIME_OUT– the long poll
|
||||
# connection has timed out.
|
||||
self.transmission_counter.inc(
|
||||
value=1, tags={"namespace_or_state": "TIMEOUT"}
|
||||
)
|
||||
else:
|
||||
data = timeout_or_data
|
||||
for key in data.keys():
|
||||
self.transmission_counter.inc(
|
||||
value=1,
|
||||
tags={"namespace_or_state": _get_metric_namespace_tag(key)},
|
||||
)
|
||||
|
||||
async def listen_for_change(
|
||||
self,
|
||||
keys_to_snapshot_ids: Dict[KeyType, int],
|
||||
) -> Union[LongPollState, Dict[KeyType, UpdatedObject]]:
|
||||
"""Listen for changed objects.
|
||||
|
||||
This method will return a dictionary of updated objects. It returns
|
||||
immediately if any of the snapshot_ids are outdated,
|
||||
otherwise it will block until there's an update.
|
||||
"""
|
||||
# If there are no keys to listen for,
|
||||
# just wait for a short time to provide backpressure,
|
||||
# then return an empty update.
|
||||
if not keys_to_snapshot_ids:
|
||||
await sleep(1)
|
||||
|
||||
updated_objects = {}
|
||||
self._count_send(updated_objects)
|
||||
return updated_objects
|
||||
|
||||
# If there are any keys with outdated snapshot ids,
|
||||
# return their updated values immediately.
|
||||
updated_objects = {}
|
||||
for key, client_snapshot_id in keys_to_snapshot_ids.items():
|
||||
try:
|
||||
existing_id = self.snapshot_ids[key]
|
||||
except KeyError:
|
||||
# The caller may ask for keys that we don't know about (yet),
|
||||
# just ignore them.
|
||||
# This can happen when, for example,
|
||||
# a deployment handle is manually created for an app
|
||||
# that hasn't been deployed yet (by bypassing the safety checks).
|
||||
continue
|
||||
|
||||
if existing_id != client_snapshot_id:
|
||||
updated_objects[key] = UpdatedObject(
|
||||
self.object_snapshots[key],
|
||||
existing_id,
|
||||
self._notify_timestamps[key],
|
||||
)
|
||||
if len(updated_objects) > 0:
|
||||
self._count_send(updated_objects)
|
||||
return updated_objects
|
||||
|
||||
# Otherwise, register asyncio events to be waited.
|
||||
async_task_to_events = {}
|
||||
async_task_to_watched_keys = {}
|
||||
for key in keys_to_snapshot_ids.keys():
|
||||
# Create a new asyncio event for this key.
|
||||
event = asyncio.Event()
|
||||
|
||||
# Make sure future caller of notify_changed will unblock this
|
||||
# asyncio Event.
|
||||
self.notifier_events[key].add(event)
|
||||
|
||||
# Update aggregate pending clients gauge for this namespace
|
||||
namespace_tag = _get_metric_namespace_tag(key)
|
||||
self._pending_clients_by_namespace[namespace_tag] += 1
|
||||
self.pending_clients_gauge.set(
|
||||
self._pending_clients_by_namespace[namespace_tag],
|
||||
tags={"namespace": namespace_tag},
|
||||
)
|
||||
|
||||
task = get_or_create_event_loop().create_task(event.wait())
|
||||
async_task_to_events[task] = event
|
||||
async_task_to_watched_keys[task] = key
|
||||
|
||||
done, not_done = await asyncio.wait(
|
||||
async_task_to_watched_keys.keys(),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
timeout=random.uniform(*self._listen_for_change_request_timeout_s),
|
||||
)
|
||||
|
||||
# Collect per-namespace decrements, flush the gauge once per
|
||||
# unique tag after the loop — a single timed-out poll over many
|
||||
# keys can otherwise do N redundant metric writes for the same
|
||||
# namespace.
|
||||
affected_namespaces = set()
|
||||
for task in not_done:
|
||||
task.cancel()
|
||||
event = async_task_to_events[task]
|
||||
key = async_task_to_watched_keys[task]
|
||||
# .get() avoids resurrecting a defaultdict entry for a key
|
||||
# evicted while we were parked.
|
||||
events_set = self.notifier_events.get(key)
|
||||
if events_set is None:
|
||||
continue
|
||||
try:
|
||||
events_set.remove(event)
|
||||
except KeyError:
|
||||
# FIRST_COMPLETED: a sibling wake may have popped this
|
||||
# event via notify_changed.
|
||||
continue
|
||||
if not events_set:
|
||||
self.notifier_events.pop(key, None)
|
||||
namespace_tag = _get_metric_namespace_tag(key)
|
||||
self._pending_clients_by_namespace[namespace_tag] -= 1
|
||||
affected_namespaces.add(namespace_tag)
|
||||
for namespace_tag in affected_namespaces:
|
||||
self.pending_clients_gauge.set(
|
||||
self._pending_clients_by_namespace[namespace_tag],
|
||||
tags={"namespace": namespace_tag},
|
||||
)
|
||||
|
||||
if len(done) == 0:
|
||||
self._count_send(LongPollState.TIME_OUT)
|
||||
return LongPollState.TIME_OUT
|
||||
else:
|
||||
updated_objects = {}
|
||||
for task in done:
|
||||
updated_object_key = async_task_to_watched_keys[task]
|
||||
# Evicted via remove_keys while parked; skip.
|
||||
if updated_object_key not in self.snapshot_ids:
|
||||
continue
|
||||
updated_objects[updated_object_key] = UpdatedObject(
|
||||
self.object_snapshots[updated_object_key],
|
||||
self.snapshot_ids[updated_object_key],
|
||||
self._notify_timestamps[updated_object_key],
|
||||
)
|
||||
self._count_send(updated_objects)
|
||||
return updated_objects
|
||||
|
||||
async def listen_for_change_java(
|
||||
self,
|
||||
keys_to_snapshot_ids_bytes: bytes,
|
||||
) -> bytes:
|
||||
"""Listen for changed objects. only call by java proxy/router now.
|
||||
|
||||
Args:
|
||||
keys_to_snapshot_ids_bytes: the protobuf bytes of
|
||||
keys_to_snapshot_ids (Dict[str, int]).
|
||||
|
||||
Returns:
|
||||
The serialized protobuf bytes of the update payload.
|
||||
"""
|
||||
request_proto = LongPollRequest.FromString(keys_to_snapshot_ids_bytes)
|
||||
keys_to_snapshot_ids = {
|
||||
self._parse_xlang_key(xlang_key): snapshot_id
|
||||
for xlang_key, snapshot_id in request_proto.keys_to_snapshot_ids.items()
|
||||
}
|
||||
result = await self.listen_for_change(keys_to_snapshot_ids)
|
||||
|
||||
# Java long-poll protocol currently doesn't encode LongPollState.
|
||||
# Convert timeout to an empty update payload so the Java client can retry.
|
||||
if result is LongPollState.TIME_OUT:
|
||||
result = {}
|
||||
|
||||
return self._listen_result_to_proto_bytes(result)
|
||||
|
||||
def _parse_poll_namespace(self, name: str):
|
||||
if name == LongPollNamespace.ROUTE_TABLE.name:
|
||||
return LongPollNamespace.ROUTE_TABLE
|
||||
elif name == LongPollNamespace.DEPLOYMENT_TARGETS.name:
|
||||
return LongPollNamespace.DEPLOYMENT_TARGETS
|
||||
else:
|
||||
return name
|
||||
|
||||
def _parse_xlang_key(self, xlang_key: str) -> KeyType:
|
||||
if xlang_key is None:
|
||||
raise ValueError("func _parse_xlang_key: xlang_key is None")
|
||||
if xlang_key.startswith("(") and xlang_key.endswith(")"):
|
||||
fields = xlang_key[1:-1].split(",")
|
||||
if len(fields) == 2:
|
||||
enum_field = self._parse_poll_namespace(fields[0].strip())
|
||||
if isinstance(enum_field, LongPollNamespace):
|
||||
return enum_field, fields[1].strip()
|
||||
else:
|
||||
return self._parse_poll_namespace(xlang_key)
|
||||
raise ValueError("can not parse key type from xlang_key {}".format(xlang_key))
|
||||
|
||||
def _build_xlang_key(self, key: KeyType) -> str:
|
||||
if isinstance(key, tuple):
|
||||
return "(" + key[0].name + "," + key[1] + ")"
|
||||
elif isinstance(key, LongPollNamespace):
|
||||
return key.name
|
||||
else:
|
||||
return key
|
||||
|
||||
def _object_snapshot_to_proto_bytes(
|
||||
self, key: KeyType, object_snapshot: Any
|
||||
) -> bytes:
|
||||
if key == LongPollNamespace.ROUTE_TABLE:
|
||||
# object_snapshot is Dict[DeploymentID, EndpointInfo]
|
||||
# NOTE(zcin): the endpoint dictionary broadcasted to Java
|
||||
# HTTP proxies should use string as key because Java does
|
||||
# not yet support 2.x or applications
|
||||
xlang_endpoints = {
|
||||
str(endpoint_tag): EndpointInfoProto(route=endpoint_info.route)
|
||||
for endpoint_tag, endpoint_info in object_snapshot.items()
|
||||
}
|
||||
return EndpointSet(endpoints=xlang_endpoints).SerializeToString()
|
||||
elif isinstance(key, tuple) and key[0] == LongPollNamespace.DEPLOYMENT_TARGETS:
|
||||
# object_snapshot.running_replicas is List[RunningReplicaInfo]
|
||||
actor_name_list = [
|
||||
replica_info.replica_id.to_full_id_str()
|
||||
for replica_info in object_snapshot.running_replicas
|
||||
]
|
||||
return DeploymentTargetInfo(
|
||||
replica_names=actor_name_list,
|
||||
is_available=object_snapshot.is_available,
|
||||
).SerializeToString()
|
||||
else:
|
||||
return str.encode(str(object_snapshot))
|
||||
|
||||
def _listen_result_to_proto_bytes(
|
||||
self, keys_to_updated_objects: Dict[KeyType, UpdatedObject]
|
||||
) -> bytes:
|
||||
xlang_keys_to_updated_objects = {
|
||||
self._build_xlang_key(key): UpdatedObjectProto(
|
||||
snapshot_id=updated_object.snapshot_id,
|
||||
object_snapshot=self._object_snapshot_to_proto_bytes(
|
||||
key, updated_object.object_snapshot
|
||||
),
|
||||
)
|
||||
for key, updated_object in keys_to_updated_objects.items()
|
||||
}
|
||||
data = {
|
||||
"updated_objects": xlang_keys_to_updated_objects,
|
||||
}
|
||||
proto = LongPollResult(**data)
|
||||
return proto.SerializeToString()
|
||||
|
||||
def notify_changed(self, updates: Mapping[KeyType, Any]) -> None:
|
||||
"""
|
||||
Update the current snapshot of some objects
|
||||
and notify any long poll clients.
|
||||
"""
|
||||
notify_time = time.time()
|
||||
for object_key, updated_object in updates.items():
|
||||
try:
|
||||
self.snapshot_ids[object_key] += 1
|
||||
except KeyError:
|
||||
# Initial snapshot id must be >= 0, so that the long poll client
|
||||
# can send a negative initial snapshot id to get a fast update.
|
||||
# They should also be randomized; see
|
||||
# https://github.com/ray-project/ray/pull/45881#discussion_r1645243485
|
||||
self.snapshot_ids[object_key] = random.randint(0, 1_000_000)
|
||||
self.object_snapshots[object_key] = updated_object
|
||||
# Record timestamp for latency tracking
|
||||
self._notify_timestamps[object_key] = notify_time
|
||||
logger.debug(f"LongPollHost: Notify change for key {object_key}.")
|
||||
|
||||
events_to_notify = self.notifier_events.pop(object_key, set())
|
||||
if events_to_notify:
|
||||
# Decrement aggregate count by the number of events popped
|
||||
namespace_tag = _get_metric_namespace_tag(object_key)
|
||||
self._pending_clients_by_namespace[namespace_tag] -= len(
|
||||
events_to_notify
|
||||
)
|
||||
self.pending_clients_gauge.set(
|
||||
self._pending_clients_by_namespace[namespace_tag],
|
||||
tags={"namespace": namespace_tag},
|
||||
)
|
||||
for event in events_to_notify:
|
||||
event.set()
|
||||
|
||||
def remove_keys(self, keys: Iterable[KeyType]) -> None:
|
||||
"""Evict per-key state and wake any parked listeners.
|
||||
|
||||
Do NOT evict a key in the same sync call as ``notify_changed``
|
||||
on it — the waiter only runs after the call returns, by which
|
||||
point ``listen_for_change``'s done-branch guard would drop the
|
||||
payload.
|
||||
"""
|
||||
affected_namespaces = set()
|
||||
for key in keys:
|
||||
self.snapshot_ids.pop(key, None)
|
||||
self.object_snapshots.pop(key, None)
|
||||
self._notify_timestamps.pop(key, None)
|
||||
events_to_notify = self.notifier_events.pop(key, set())
|
||||
if events_to_notify:
|
||||
# Decrement before waking: the listen_for_change timeout
|
||||
# cleanup would otherwise skip the decrement on the now-
|
||||
# missing notifier_events entry.
|
||||
namespace_tag = _get_metric_namespace_tag(key)
|
||||
self._pending_clients_by_namespace[namespace_tag] -= len(
|
||||
events_to_notify
|
||||
)
|
||||
affected_namespaces.add(namespace_tag)
|
||||
for event in events_to_notify:
|
||||
event.set()
|
||||
for namespace_tag in affected_namespaces:
|
||||
self.pending_clients_gauge.set(
|
||||
self._pending_clients_by_namespace[namespace_tag],
|
||||
tags={"namespace": namespace_tag},
|
||||
)
|
||||
@@ -0,0 +1,416 @@
|
||||
import asyncio
|
||||
import bisect
|
||||
import logging
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from itertools import chain
|
||||
from typing import (
|
||||
Awaitable,
|
||||
Callable,
|
||||
DefaultDict,
|
||||
Dict,
|
||||
Hashable,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from ray._raylet import (
|
||||
merge_instantaneous_total_cython,
|
||||
time_weighted_average_cython,
|
||||
)
|
||||
from ray.serve._private.common import TimeSeries, TimeStampedValue
|
||||
from ray.serve._private.constants import (
|
||||
METRICS_PUSHER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve.config import AggregationFunction
|
||||
|
||||
QUEUED_REQUESTS_KEY = "queued"
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MetricsTask:
|
||||
task_func: Union[Callable, Callable[[], Awaitable]]
|
||||
interval_s: float
|
||||
|
||||
|
||||
class MetricsPusher:
|
||||
"""Periodically runs registered asyncio tasks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
async_sleep: Optional[Callable[[int], None]] = None,
|
||||
):
|
||||
self._async_sleep = async_sleep or asyncio.sleep
|
||||
self._tasks: Dict[str, _MetricsTask] = dict()
|
||||
self._async_tasks: Dict[str, asyncio.Task] = dict()
|
||||
|
||||
# The event needs to be lazily initialized because this class may be constructed
|
||||
# on the main thread but its methods called on a separate asyncio loop.
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
|
||||
@property
|
||||
def stop_event(self) -> asyncio.Event:
|
||||
if self._stop_event is None:
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
return self._stop_event
|
||||
|
||||
def start(self):
|
||||
self.stop_event.clear()
|
||||
|
||||
async def metrics_task(self, name: str):
|
||||
"""Periodically runs `task_func` every `interval_s` until `stop_event` is set.
|
||||
|
||||
If `task_func` raises an error, an exception will be logged.
|
||||
Supports both sync and async task functions.
|
||||
"""
|
||||
|
||||
wait_for_stop_event = asyncio.create_task(self.stop_event.wait())
|
||||
while True:
|
||||
if wait_for_stop_event.done():
|
||||
return
|
||||
|
||||
try:
|
||||
task_func = self._tasks[name].task_func
|
||||
# Check if the function is a coroutine function
|
||||
if asyncio.iscoroutinefunction(task_func):
|
||||
await task_func()
|
||||
else:
|
||||
task_func()
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to run metrics task '{name}': {e}")
|
||||
|
||||
sleep_task = asyncio.create_task(
|
||||
self._async_sleep(self._tasks[name].interval_s)
|
||||
)
|
||||
await asyncio.wait(
|
||||
[sleep_task, wait_for_stop_event],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if not sleep_task.done():
|
||||
sleep_task.cancel()
|
||||
|
||||
def register_or_update_task(
|
||||
self,
|
||||
name: str,
|
||||
task_func: Union[Callable, Callable[[], Awaitable]],
|
||||
interval_s: int,
|
||||
) -> None:
|
||||
"""Register a sync or async task under the provided name, or update it.
|
||||
|
||||
This method is idempotent - if a task is already registered with
|
||||
the specified name, it will update it with the most recent info.
|
||||
|
||||
Args:
|
||||
name: Unique name for the task.
|
||||
task_func: Either a sync function or async function (coroutine function).
|
||||
interval_s: Interval in seconds between task executions.
|
||||
"""
|
||||
|
||||
self._tasks[name] = _MetricsTask(task_func, interval_s)
|
||||
if name not in self._async_tasks or self._async_tasks[name].done():
|
||||
self._async_tasks[name] = asyncio.create_task(self.metrics_task(name))
|
||||
|
||||
def stop_tasks(self):
|
||||
self.stop_event.set()
|
||||
self._tasks.clear()
|
||||
self._async_tasks.clear()
|
||||
|
||||
async def graceful_shutdown(self):
|
||||
"""Shutdown metrics pusher gracefully.
|
||||
|
||||
This method will ensure idempotency of shutdown call.
|
||||
"""
|
||||
|
||||
self.stop_event.set()
|
||||
if self._async_tasks:
|
||||
await asyncio.wait(
|
||||
list(self._async_tasks.values()),
|
||||
timeout=METRICS_PUSHER_GRACEFUL_SHUTDOWN_TIMEOUT_S,
|
||||
)
|
||||
|
||||
self._tasks.clear()
|
||||
self._async_tasks.clear()
|
||||
|
||||
|
||||
class InMemoryMetricsStore:
|
||||
"""A very simple, in memory time series database"""
|
||||
|
||||
def __init__(self):
|
||||
self.data: DefaultDict[Hashable, TimeSeries] = defaultdict(list)
|
||||
|
||||
def add_metrics_point(self, data_points: Dict[Hashable, float], timestamp: float):
|
||||
"""Push new data points to the store.
|
||||
|
||||
Args:
|
||||
data_points: dictionary containing the metrics values. The
|
||||
key should uniquely identify this time series
|
||||
and to be used to perform aggregation.
|
||||
timestamp: the unix epoch timestamp the metrics are
|
||||
collected at.
|
||||
"""
|
||||
|
||||
for name, value in data_points.items():
|
||||
# Using in-sort to insert while maintaining sorted ordering.
|
||||
bisect.insort(a=self.data[name], x=TimeStampedValue(timestamp, value))
|
||||
|
||||
def prune_keys_and_compact_data(self, start_timestamp_s: float):
|
||||
"""Prune keys and compact data that are outdated.
|
||||
|
||||
For keys that haven't had new data recorded after the timestamp,
|
||||
remove them from the database.
|
||||
For keys that have, compact the datapoints that were recorded
|
||||
before the timestamp.
|
||||
"""
|
||||
for key, datapoints in list(self.data.items()):
|
||||
if len(datapoints) == 0 or datapoints[-1].timestamp < start_timestamp_s:
|
||||
del self.data[key]
|
||||
else:
|
||||
self.data[key] = self._get_datapoints(key, start_timestamp_s)
|
||||
|
||||
def _get_datapoints(
|
||||
self, key: Hashable, window_start_timestamp_s: float
|
||||
) -> TimeSeries:
|
||||
"""Get all data points given key after window_start_timestamp_s"""
|
||||
|
||||
datapoints = self.data[key]
|
||||
|
||||
idx = bisect.bisect(
|
||||
a=datapoints,
|
||||
x=TimeStampedValue(
|
||||
timestamp=window_start_timestamp_s, value=0 # dummy value
|
||||
),
|
||||
)
|
||||
return datapoints[idx:]
|
||||
|
||||
def _aggregate_reduce(
|
||||
self,
|
||||
keys: Iterable[Hashable],
|
||||
aggregate_fn: Callable[[Iterable[float]], float],
|
||||
) -> Tuple[Optional[float], int]:
|
||||
"""Reduce the entire set of timeseries values across the specified keys.
|
||||
|
||||
Args:
|
||||
keys: Iterable of keys to aggregate across.
|
||||
aggregate_fn: Function to apply across all float values, e.g., sum, max.
|
||||
|
||||
Returns:
|
||||
A tuple of (float, int) where the first element is the aggregated value
|
||||
and the second element is the number of valid keys used.
|
||||
Returns (None, 0) if no valid keys have data.
|
||||
|
||||
Example:
|
||||
Suppose the store contains:
|
||||
>>> store = InMemoryMetricsStore()
|
||||
>>> store.data.update({
|
||||
... "a": [TimeStampedValue(0, 1.0), TimeStampedValue(1, 2.0)],
|
||||
... "b": [],
|
||||
... "c": [TimeStampedValue(0, 10.0)],
|
||||
... })
|
||||
|
||||
Using sum across keys:
|
||||
|
||||
>>> store._aggregate_reduce(keys=["a", "b", "c"], aggregate_fn=sum)
|
||||
(13.0, 2)
|
||||
|
||||
Here:
|
||||
- The aggregated value is 1.0 + 2.0 + 10.0 = 13.0
|
||||
- Only keys "a" and "c" contribute values, so report_count = 2
|
||||
"""
|
||||
valid_key_count = 0
|
||||
|
||||
def _values_generator():
|
||||
"""Generator that yields values from valid keys without storing them all in memory."""
|
||||
nonlocal valid_key_count
|
||||
for key in keys:
|
||||
series = self.data.get(key, [])
|
||||
if not series:
|
||||
continue
|
||||
|
||||
valid_key_count += 1
|
||||
for timestamp_value in series:
|
||||
yield timestamp_value.value
|
||||
|
||||
# Create the generator and check if it has any values
|
||||
values_gen = _values_generator()
|
||||
try:
|
||||
first_value = next(values_gen)
|
||||
except StopIteration:
|
||||
# No valid data found
|
||||
return None, 0
|
||||
|
||||
# Apply aggregation to the generator (memory efficient)
|
||||
aggregated_result = aggregate_fn(chain([first_value], values_gen))
|
||||
return aggregated_result, valid_key_count
|
||||
|
||||
def get_latest(
|
||||
self,
|
||||
key: Hashable,
|
||||
) -> Optional[float]:
|
||||
"""Get the latest value for a given key."""
|
||||
if not self.data.get(key, None):
|
||||
return None
|
||||
return self.data[key][-1].value
|
||||
|
||||
def aggregate_sum(
|
||||
self,
|
||||
keys: Iterable[Hashable],
|
||||
) -> Tuple[Optional[float], int]:
|
||||
"""Sum the entire set of timeseries values across the specified keys.
|
||||
Args:
|
||||
keys: Iterable of keys to aggregate across.
|
||||
Returns:
|
||||
A tuple of (float, int) where the first element is the sum across
|
||||
all values found at `keys`, and the second is the number of valid
|
||||
keys used to compute the sum.
|
||||
Returns (None, 0) if no valid keys have data.
|
||||
"""
|
||||
return self._aggregate_reduce(keys, sum)
|
||||
|
||||
def aggregate_avg(
|
||||
self,
|
||||
keys: Iterable[Hashable],
|
||||
) -> Tuple[Optional[float], int]:
|
||||
"""Average the entire set of timeseries values across the specified keys.
|
||||
|
||||
Args:
|
||||
keys: Iterable of keys to aggregate across.
|
||||
Returns:
|
||||
A tuple of (float, int) where the first element is the mean across
|
||||
all values found at `keys`, and the second is the number of valid
|
||||
keys used to compute the mean.
|
||||
Returns (None, 0) if no valid keys have data.
|
||||
"""
|
||||
return self._aggregate_reduce(keys, statistics.mean)
|
||||
|
||||
def timeseries_count(
|
||||
self,
|
||||
key: Hashable,
|
||||
) -> int:
|
||||
"""Count the number of values across all timeseries values at the specified keys."""
|
||||
series = self.data.get(key, [])
|
||||
if not series:
|
||||
return 0
|
||||
return len(series)
|
||||
|
||||
|
||||
def time_weighted_average(
|
||||
step_series: TimeSeries,
|
||||
window_start: Optional[float] = None,
|
||||
window_end: Optional[float] = None,
|
||||
last_window_s: float = 1.0,
|
||||
) -> Optional[float]:
|
||||
"""
|
||||
Compute time-weighted average of a step function over a time interval.
|
||||
|
||||
This function uses a Cython-optimized implementation for improved performance.
|
||||
|
||||
Args:
|
||||
step_series: Step function as list of (timestamp, value) points, sorted by time.
|
||||
Values are right-continuous (constant until next change).
|
||||
window_start: Start of averaging window (inclusive). If None, uses the start of the series.
|
||||
window_end: End of averaging window (exclusive). If None, uses the end of the series.
|
||||
last_window_s: when window_end is None, uses the last_window_s to compute the end of the window.
|
||||
Returns:
|
||||
Time-weighted average over the interval, or None if no data overlaps.
|
||||
"""
|
||||
# Convert None to negative infinity for Cython (C doesn't have None)
|
||||
# Using -inf instead of a specific value like -1.0 ensures any valid float
|
||||
# (including -1.0) can be used as a window boundary.
|
||||
ws = window_start if window_start is not None else float("-inf")
|
||||
we = window_end if window_end is not None else float("-inf")
|
||||
return time_weighted_average_cython(step_series, ws, we, last_window_s)
|
||||
|
||||
|
||||
def aggregate_timeseries(
|
||||
timeseries: TimeSeries,
|
||||
aggregation_function: AggregationFunction,
|
||||
last_window_s: float = 1.0,
|
||||
window_start: Optional[float] = None,
|
||||
) -> Optional[float]:
|
||||
"""Aggregate the values in a timeseries using a specified function."""
|
||||
if aggregation_function == AggregationFunction.MEAN:
|
||||
return time_weighted_average(
|
||||
timeseries, window_start=window_start, last_window_s=last_window_s
|
||||
)
|
||||
elif aggregation_function == AggregationFunction.MAX:
|
||||
values = (
|
||||
ts.value
|
||||
for ts in timeseries
|
||||
if window_start is None or ts.timestamp >= window_start
|
||||
)
|
||||
return max(values, default=None)
|
||||
elif aggregation_function == AggregationFunction.MIN:
|
||||
values = (
|
||||
ts.value
|
||||
for ts in timeseries
|
||||
if window_start is None or ts.timestamp >= window_start
|
||||
)
|
||||
return min(values, default=None)
|
||||
else:
|
||||
raise ValueError(f"Invalid aggregation function: {aggregation_function}")
|
||||
|
||||
|
||||
def merge_instantaneous_total(
|
||||
replicas_timeseries: List[TimeSeries],
|
||||
) -> TimeSeries:
|
||||
"""
|
||||
Merge multiple gauge time series (right-continuous, LOCF) into an
|
||||
instantaneous total time series as a step function.
|
||||
|
||||
This function uses a Cython-optimized implementation for 5-10x performance
|
||||
improvement over pure Python.
|
||||
|
||||
This approach treats each replica's gauge as right-continuous, last-observation-
|
||||
carried-forward (LOCF), which matches gauge semantics. It produces an exact
|
||||
instantaneous total across replicas without bias from arbitrary windowing.
|
||||
|
||||
Uses a k-way merge algorithm for O(n log k) complexity where k is the number
|
||||
of timeseries and n is the total number of events.
|
||||
|
||||
Timestamps are rounded to 10ms precision (2 decimal places) and datapoints
|
||||
with the same rounded timestamp are combined, keeping the most recent value.
|
||||
|
||||
Args:
|
||||
replicas_timeseries: List of time series, one per replica. Each time series
|
||||
is a list of TimeStampedValue objects sorted by timestamp.
|
||||
|
||||
Returns:
|
||||
A list of TimeStampedValue representing the instantaneous total at event times.
|
||||
Between events, the total remains constant (step function). Timestamps are
|
||||
rounded to 10ms precision and duplicate timestamps are combined.
|
||||
"""
|
||||
# Handle trivial cases in Python to avoid type conversion overhead
|
||||
active_series = [series for series in replicas_timeseries if series]
|
||||
if not active_series:
|
||||
return []
|
||||
if len(active_series) == 1:
|
||||
return active_series[0]
|
||||
|
||||
# Cython returns list of (timestamp, value) tuples; convert to TimeStampedValue
|
||||
merged_tuples = merge_instantaneous_total_cython(active_series)
|
||||
return [TimeStampedValue(ts, val) for ts, val in merged_tuples]
|
||||
|
||||
|
||||
def merge_timeseries_dicts(
|
||||
*timeseries_dicts: DefaultDict[Hashable, TimeSeries],
|
||||
) -> DefaultDict[Hashable, TimeSeries]:
|
||||
"""
|
||||
Merge multiple time-series dictionaries using instantaneous merge approach.
|
||||
"""
|
||||
merged: DefaultDict[Hashable, TimeSeries] = defaultdict(list)
|
||||
|
||||
for ts_dict in timeseries_dicts:
|
||||
for key, ts in ts_dict.items():
|
||||
merged[key].append(ts)
|
||||
|
||||
return {key: merge_instantaneous_total(ts_list) for key, ts_list in merged.items()}
|
||||
@@ -0,0 +1,348 @@
|
||||
import heapq
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ray.serve._private.common import RequestProtocol
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT,
|
||||
RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT,
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT,
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
|
||||
RAY_SERVE_PORT_QUARANTINE_S,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class NoAvailablePortError(Exception):
|
||||
def __init__(self, protocol: str, node_id: str):
|
||||
message = f"No available ports on node {node_id} for {protocol} protocol."
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class PortAllocator:
|
||||
"""Manages a pool of ports for a specific protocol (e.g., HTTP or gRPC)."""
|
||||
|
||||
def __init__(self, min_port: int, max_port: int, protocol: str, node_id: str):
|
||||
self._protocol = protocol
|
||||
self._node_id = node_id
|
||||
# TODO(abrar): add a validation here to ensure min_port and max_port dont overlap with
|
||||
# ray params min_worker_port and max_worker_port.
|
||||
self._available_ports = list(range(min_port, max_port))
|
||||
heapq.heapify(self._available_ports)
|
||||
|
||||
self._allocated_ports: Dict[str, int] = {}
|
||||
self._blocked_ports: Set[int] = set()
|
||||
# port -> monotonic deadline at which it leaves quarantine.
|
||||
self._quarantined_ports: Dict[int, float] = {}
|
||||
# Min-heap of (deadline, port) mirroring _quarantined_ports so a no-op drain
|
||||
# is O(1) (peek) instead of O(quarantined). Lazy-deleted (P2).
|
||||
self._quarantine_heap: List[Tuple[float, int]] = []
|
||||
|
||||
def _drain_expired_quarantine(self) -> None:
|
||||
"""Return quarantined ports past their expiry to the available pool.
|
||||
|
||||
Drains via the min-heap keyed on expiry, so when nothing is due this is a
|
||||
single O(1) peek instead of an O(quarantined) dict scan. Lazy deletion: a
|
||||
popped heap entry is honored only if it still matches the live deadline in
|
||||
_quarantined_ports (entries left stale by update_port_if_missing's reclaim
|
||||
pop, or by a re-quarantine, are skipped).
|
||||
"""
|
||||
if not self._quarantine_heap:
|
||||
return
|
||||
now = time.monotonic()
|
||||
while self._quarantine_heap and self._quarantine_heap[0][0] <= now:
|
||||
deadline, port = heapq.heappop(self._quarantine_heap)
|
||||
if self._quarantined_ports.get(port) == deadline:
|
||||
heapq.heappush(self._available_ports, port)
|
||||
del self._quarantined_ports[port]
|
||||
logger.info(
|
||||
f"Released {self._protocol} port {port} from quarantine on "
|
||||
f"node {self._node_id}; returning it to the available pool."
|
||||
)
|
||||
|
||||
def has_pending_quarantine(self) -> bool:
|
||||
"""True if any port is still quarantined (drains expired entries first)."""
|
||||
self._drain_expired_quarantine()
|
||||
return bool(self._quarantined_ports)
|
||||
|
||||
def update_port_if_missing(self, replica_id: str, port: Optional[int]):
|
||||
"""Update port value for a replica."""
|
||||
if replica_id in self._allocated_ports:
|
||||
return
|
||||
# Reclaim trumps quarantine: the port is back in active use.
|
||||
if port is not None:
|
||||
self._quarantined_ports.pop(port, None)
|
||||
assert (
|
||||
port is not None
|
||||
), f"Port is None for {self._protocol} protocol on replica {replica_id} on node {self._node_id}"
|
||||
if self._protocol == RequestProtocol.HTTP:
|
||||
if not (
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT
|
||||
<= port
|
||||
<= RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT
|
||||
):
|
||||
logger.warning(f"HTTP port out of range: {port}")
|
||||
elif self._protocol == RequestProtocol.GRPC:
|
||||
if not (
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT
|
||||
<= port
|
||||
<= RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT
|
||||
):
|
||||
logger.warning(f"GRPC port out of range: {port}")
|
||||
self._allocated_ports[replica_id] = port
|
||||
|
||||
logger.info(
|
||||
f"Recovered {self._protocol} port {port} for replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
return port
|
||||
|
||||
def allocate(self, replica_id: str) -> int:
|
||||
if replica_id in self._allocated_ports:
|
||||
logger.warning(
|
||||
f"{self._protocol} port already allocated for replica {replica_id}"
|
||||
)
|
||||
return self._allocated_ports[replica_id]
|
||||
|
||||
self._drain_expired_quarantine()
|
||||
|
||||
# Recovered replicas live in _allocated_ports but their ports are
|
||||
# still in _available_ports, so guard against re-handing them out.
|
||||
# A recovered port released into quarantine is also still in the heap
|
||||
# (update_port_if_missing never pops it), so guard against handing out
|
||||
# a still-quarantined port too.
|
||||
allocated = set(self._allocated_ports.values())
|
||||
while self._available_ports:
|
||||
port = heapq.heappop(self._available_ports)
|
||||
if (
|
||||
port not in self._blocked_ports
|
||||
and port not in allocated
|
||||
and port not in self._quarantined_ports
|
||||
):
|
||||
self._allocated_ports[replica_id] = port
|
||||
logger.info(
|
||||
f"Allocated {self._protocol} port {port} to replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
return port
|
||||
|
||||
raise NoAvailablePortError(self._protocol, self._node_id)
|
||||
|
||||
def release(self, replica_id: str, port: int, block_port: bool = False):
|
||||
"""
|
||||
Releases a port for a replica.
|
||||
|
||||
Args:
|
||||
replica_id: The ID of the replica to release the port for.
|
||||
port: The port to release.
|
||||
block_port: Whether to block the port from being allocated again. Use this in
|
||||
situations where the port is being released due some other process is using it.
|
||||
"""
|
||||
if replica_id not in self._allocated_ports:
|
||||
raise ValueError(
|
||||
f"{self._protocol} port not allocated for replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
expected_port = self._allocated_ports[replica_id]
|
||||
assert expected_port == port, (
|
||||
f"{self._protocol} port mismatch for replica {replica_id} on node {self._node_id}: "
|
||||
f"expected {expected_port}, got {port}"
|
||||
)
|
||||
|
||||
del self._allocated_ports[replica_id]
|
||||
|
||||
if block_port:
|
||||
# Block is a stronger guarantee than quarantine; skip quarantine.
|
||||
self._blocked_ports.add(port)
|
||||
heapq.heappush(self._available_ports, port)
|
||||
logger.info(
|
||||
f"Released and blocked {self._protocol} port {port} for replica "
|
||||
f"{replica_id} on node {self._node_id}"
|
||||
)
|
||||
elif RAY_SERVE_PORT_QUARANTINE_S > 0:
|
||||
deadline = time.monotonic() + RAY_SERVE_PORT_QUARANTINE_S
|
||||
self._quarantined_ports[port] = deadline
|
||||
heapq.heappush(self._quarantine_heap, (deadline, port))
|
||||
logger.info(
|
||||
f"Quarantined {self._protocol} port {port} for "
|
||||
f"{RAY_SERVE_PORT_QUARANTINE_S}s after releasing for replica "
|
||||
f"{replica_id} on node {self._node_id}"
|
||||
)
|
||||
else:
|
||||
heapq.heappush(self._available_ports, port)
|
||||
logger.info(
|
||||
f"Released {self._protocol} port {port} for replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
|
||||
def prune(self, active_replica_ids: Set[str]):
|
||||
for replica_id in list(self._allocated_ports.keys()):
|
||||
if replica_id not in active_replica_ids:
|
||||
port = self._allocated_ports[replica_id]
|
||||
logger.info(
|
||||
f"Cleaning up {self._protocol} port {port} for stale replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
self.release(replica_id, port)
|
||||
|
||||
def get_port(self, replica_id: str) -> int:
|
||||
if replica_id not in self._allocated_ports:
|
||||
raise ValueError(
|
||||
f"{self._protocol} port not allocated for replica {replica_id} on node {self._node_id}"
|
||||
)
|
||||
return self._allocated_ports[replica_id]
|
||||
|
||||
def is_port_allocated(self, replica_id: str) -> bool:
|
||||
return replica_id in self._allocated_ports
|
||||
|
||||
|
||||
class NodePortManager:
|
||||
"""
|
||||
This class is responsible for managing replica-specific port allocations on a node,
|
||||
and is only used in direct ingress mode, where each Serve replica is exposed individually
|
||||
via a Kubernetes or GCP or AWS Ingress.
|
||||
|
||||
The primary goal of this class is to assign ports in a consistent and efficient manner,
|
||||
minimizing EndpointSlice fragmentation in Kubernetes. It uses a min-heap strategy to
|
||||
allocate ports incrementally, ensuring that all nodes tend to reuse the same port numbers.
|
||||
|
||||
Background:
|
||||
Kubernetes groups endpoints into EndpointSlices based on the set of ports exposed by each Pod.
|
||||
If Pods expose different port combinations (e.g., due to random port assignment), Kubernetes
|
||||
generates separate EndpointSlices per unique port list. This leads to unnecessary fragmentation
|
||||
and increased resource consumption.
|
||||
|
||||
By allocating ports deterministically, we ensure:
|
||||
- Consistent port usage across all nodes
|
||||
- Fewer unique port lists, reducing the number of EndpointSlices created
|
||||
- Improved performance and resource utilization
|
||||
|
||||
Although Kubernetes does not allow users to explicitly configure the ports included in
|
||||
EndpointSlices, maintaining a uniform port layout across nodes is still beneficial.
|
||||
|
||||
Port lifecycle:
|
||||
- Replicas are expected to release their ports when stopped
|
||||
- If a replica crashes without releasing its port, the controller loop will detect and
|
||||
reclaim leaked ports during reconciliation
|
||||
|
||||
Note:
|
||||
Although this strategy is designed with Kubernetes in mind, it is applied uniformly
|
||||
across all platforms for consistency.
|
||||
"""
|
||||
|
||||
_node_managers: Dict[str, "NodePortManager"] = {}
|
||||
|
||||
@classmethod
|
||||
def get_node_manager(cls, node_id: str) -> "NodePortManager":
|
||||
# this doesn't need to be behind a lock because it will already be called from same thread
|
||||
if node_id not in cls._node_managers:
|
||||
logger.info(f"Creating node manager for node {node_id}")
|
||||
cls._node_managers[node_id] = cls(node_id)
|
||||
return cls._node_managers[node_id]
|
||||
|
||||
@classmethod
|
||||
def prune(cls, node_id_to_alive_replica_ids: Dict[str, Set[str]]):
|
||||
# this doesn't need to be behind a lock because it will already be called from same thread
|
||||
for node_id in list(cls._node_managers):
|
||||
manager = cls._node_managers[node_id]
|
||||
alive = node_id_to_alive_replica_ids.get(node_id, set())
|
||||
# Skip the reclaim scan for a node whose set of alive replicas is unchanged
|
||||
# since the last prune. Comparing the whole alive set means a replica that
|
||||
# left, arrived, or moved nodes is still caught; skipping only defers reclaim
|
||||
# and never reuses a live port (allocate() guards that). The emptied-manager
|
||||
# teardown below still runs every tick.
|
||||
if alive == manager._last_pruned_alive:
|
||||
pass
|
||||
else:
|
||||
# Release ports of replicas no longer alive (quarantines them).
|
||||
manager._prune_replica_ports(alive)
|
||||
manager._last_pruned_alive = set(alive)
|
||||
# Keep the manager until its quarantine drains; dropping it early
|
||||
# would discard the deadline and let the port be reused immediately.
|
||||
if not alive and not manager.has_pending_quarantine():
|
||||
logger.info(f"Removing node manager for node {node_id}")
|
||||
del cls._node_managers[node_id]
|
||||
|
||||
@classmethod
|
||||
def update_ports(cls, ingress_replicas_info: List[Tuple[str, str, int, int]]):
|
||||
"""Update port values for ingress replicas."""
|
||||
for node_id, replica_id, http_port, grpc_port in ingress_replicas_info:
|
||||
if node_id is None:
|
||||
continue
|
||||
node_port_manager = cls.get_node_manager(node_id)
|
||||
if http_port is not None:
|
||||
node_port_manager._http_allocator.update_port_if_missing(
|
||||
replica_id,
|
||||
http_port,
|
||||
)
|
||||
if grpc_port is not None:
|
||||
node_port_manager._grpc_allocator.update_port_if_missing(
|
||||
replica_id,
|
||||
grpc_port,
|
||||
)
|
||||
|
||||
def __init__(self, node_id: str):
|
||||
self._node_id = node_id
|
||||
# Set of alive replicas this manager last pruned against; when unchanged, the
|
||||
# reclaim scan is skipped. None means never pruned, so the first prune runs.
|
||||
self._last_pruned_alive: Optional[Set[str]] = None
|
||||
|
||||
self._http_allocator = PortAllocator(
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT,
|
||||
RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT,
|
||||
protocol=RequestProtocol.HTTP,
|
||||
node_id=node_id,
|
||||
)
|
||||
self._grpc_allocator = PortAllocator(
|
||||
RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT,
|
||||
RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT,
|
||||
protocol=RequestProtocol.GRPC,
|
||||
node_id=node_id,
|
||||
)
|
||||
|
||||
def _prune_replica_ports(self, active_replica_ids: Set[str]):
|
||||
self._http_allocator.prune(active_replica_ids)
|
||||
self._grpc_allocator.prune(active_replica_ids)
|
||||
|
||||
def allocate_port(self, replica_id: str, protocol: RequestProtocol) -> int:
|
||||
if protocol == RequestProtocol.HTTP:
|
||||
return self._http_allocator.allocate(replica_id)
|
||||
elif protocol == RequestProtocol.GRPC:
|
||||
return self._grpc_allocator.allocate(replica_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
def release_port(
|
||||
self,
|
||||
replica_id: str,
|
||||
port: int,
|
||||
protocol: RequestProtocol,
|
||||
block_port: bool = False,
|
||||
):
|
||||
if protocol == RequestProtocol.HTTP:
|
||||
self._http_allocator.release(replica_id, port, block_port)
|
||||
elif protocol == RequestProtocol.GRPC:
|
||||
self._grpc_allocator.release(replica_id, port, block_port)
|
||||
else:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
def get_port(self, replica_id: str, protocol: RequestProtocol) -> int:
|
||||
if protocol == RequestProtocol.HTTP:
|
||||
return self._http_allocator.get_port(replica_id)
|
||||
elif protocol == RequestProtocol.GRPC:
|
||||
return self._grpc_allocator.get_port(replica_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
def is_port_allocated(self, replica_id: str, protocol: RequestProtocol) -> bool:
|
||||
if protocol == RequestProtocol.HTTP:
|
||||
return self._http_allocator.is_port_allocated(replica_id)
|
||||
elif protocol == RequestProtocol.GRPC:
|
||||
return self._grpc_allocator.is_port_allocated(replica_id)
|
||||
else:
|
||||
raise ValueError(f"Unsupported protocol: {protocol}")
|
||||
|
||||
def has_pending_quarantine(self) -> bool:
|
||||
"""True if either allocator still holds a quarantined port."""
|
||||
# Evaluate both (no short-circuit) so each allocator drains expired ports.
|
||||
http_pending = self._http_allocator.has_pending_quarantine()
|
||||
grpc_pending = self._grpc_allocator.has_pending_quarantine()
|
||||
return http_pending or grpc_pending
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
import logging
|
||||
import pickle
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncIterator, List, Optional, Tuple, Union
|
||||
|
||||
import grpc
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from ray.serve._private.common import StreamingHTTPRequest, gRPCRequest
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.logging_utils import format_grpc_peer_address
|
||||
from ray.serve._private.tracing_utils import (
|
||||
extract_propagated_context,
|
||||
is_tracing_enabled,
|
||||
set_trace_context,
|
||||
)
|
||||
from ray.serve._private.utils import DEFAULT
|
||||
from ray.serve.grpc_util import RayServegRPCContext
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class gRPCStreamingType(str, Enum):
|
||||
"""Enum representing the gRPC streaming type."""
|
||||
|
||||
UNARY_UNARY = "unary_unary" # Single request, single response
|
||||
UNARY_STREAM = (
|
||||
"unary_stream" # Single request, streaming response (server streaming)
|
||||
)
|
||||
STREAM_UNARY = (
|
||||
"stream_unary" # Streaming request, single response (client streaming)
|
||||
)
|
||||
STREAM_STREAM = (
|
||||
"stream_stream" # Streaming request, streaming response (bidirectional)
|
||||
)
|
||||
|
||||
|
||||
class ProxyRequest(ABC):
|
||||
"""Base ProxyRequest class to use in the common interface among proxies"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def request_type(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def method(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def route_path(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_route_request(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_health_request(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def client(self) -> str:
|
||||
return ""
|
||||
|
||||
@abstractmethod
|
||||
def populate_tracing_context(self):
|
||||
"""Implement this method to populate tracing context so the parent and
|
||||
child spans will be connected into a single trace."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ASGIProxyRequest(ProxyRequest):
|
||||
"""ProxyRequest implementation to wrap ASGI scope, receive, and send."""
|
||||
|
||||
def __init__(self, scope: Scope, receive: Receive, send: Send):
|
||||
self.scope = scope
|
||||
self.receive = receive
|
||||
self.send = send
|
||||
|
||||
@property
|
||||
def request_type(self) -> str:
|
||||
return self.scope.get("type", "")
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
# WebSocket messages don't have a 'method' field.
|
||||
return self.scope.get("method", "WS").upper()
|
||||
|
||||
@property
|
||||
def route_path(self) -> str:
|
||||
return self.scope.get("path", "")[len(self.root_path) :]
|
||||
|
||||
@property
|
||||
def is_route_request(self) -> bool:
|
||||
return self.route_path == "/-/routes"
|
||||
|
||||
@property
|
||||
def is_health_request(self) -> bool:
|
||||
return self.route_path == "/-/healthz"
|
||||
|
||||
@property
|
||||
def client(self) -> str:
|
||||
return self.scope.get("client", "")
|
||||
|
||||
@property
|
||||
def root_path(self) -> str:
|
||||
return self.scope.get("root_path", "")
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self.scope.get("path", "")
|
||||
|
||||
@property
|
||||
def headers(self) -> List[Tuple[bytes, bytes]]:
|
||||
return self.scope.get("headers", [])
|
||||
|
||||
def set_path(self, path: str):
|
||||
self.scope["path"] = path
|
||||
|
||||
def set_root_path(self, root_path: str):
|
||||
self.scope["root_path"] = root_path
|
||||
|
||||
def serialized_replica_arg(self, proxy_actor_name: str) -> bytes:
|
||||
# NOTE(edoakes): it's important that the request is sent as raw bytes to
|
||||
# skip the Ray cloudpickle serialization codepath for performance.
|
||||
return pickle.dumps(
|
||||
StreamingHTTPRequest(
|
||||
asgi_scope=self.scope,
|
||||
proxy_actor_name=proxy_actor_name,
|
||||
)
|
||||
)
|
||||
|
||||
def populate_tracing_context(self):
|
||||
"""Populate tracing context for ASGI requests.
|
||||
|
||||
This method extracts the "traceparent" header from the request headers and sets
|
||||
the tracing context from it.
|
||||
"""
|
||||
if not is_tracing_enabled():
|
||||
return
|
||||
|
||||
for key, value in self.headers:
|
||||
if key.decode() == "traceparent":
|
||||
trace_context = extract_propagated_context(
|
||||
{key.decode(): value.decode()}
|
||||
)
|
||||
set_trace_context(trace_context)
|
||||
|
||||
|
||||
class gRPCProxyRequest(ProxyRequest):
|
||||
"""ProxyRequest implementation to wrap gRPC request protobuf and metadata."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_proto: Any,
|
||||
context: grpc._cython.cygrpc._ServicerContext,
|
||||
service_method: str,
|
||||
stream: bool,
|
||||
*,
|
||||
streaming_type: gRPCStreamingType = None,
|
||||
request_iterator: Optional[AsyncIterator[Any]] = None,
|
||||
):
|
||||
self._request_proto = request_proto
|
||||
self._request_iterator = request_iterator
|
||||
self.context = context
|
||||
self.service_method = service_method
|
||||
self.stream = stream
|
||||
# Determine streaming type based on parameters
|
||||
if streaming_type is not None:
|
||||
self.streaming_type = streaming_type
|
||||
elif request_iterator is not None:
|
||||
# Has input stream
|
||||
self.streaming_type = (
|
||||
gRPCStreamingType.STREAM_STREAM
|
||||
if stream
|
||||
else gRPCStreamingType.STREAM_UNARY
|
||||
)
|
||||
else:
|
||||
# No input stream
|
||||
self.streaming_type = (
|
||||
gRPCStreamingType.UNARY_STREAM
|
||||
if stream
|
||||
else gRPCStreamingType.UNARY_UNARY
|
||||
)
|
||||
self.app_name = ""
|
||||
self.request_id = None
|
||||
self.method_name = "__call__"
|
||||
self.multiplexed_model_id = DEFAULT.VALUE
|
||||
self.session_id = DEFAULT.VALUE
|
||||
# ray_serve_grpc_context is a class implemented by us to be able to serialize
|
||||
# the object and pass it into the deployment.
|
||||
self.ray_serve_grpc_context = RayServegRPCContext(context)
|
||||
self.setup_variables()
|
||||
|
||||
def setup_variables(self):
|
||||
if not self.is_route_request and not self.is_health_request:
|
||||
service_method_split = self.service_method.split("/")
|
||||
self.method_name = service_method_split[-1]
|
||||
for key, value in self.context.invocation_metadata():
|
||||
if key == "application":
|
||||
self.app_name = value
|
||||
elif key == "request_id":
|
||||
self.request_id = value
|
||||
elif key == "multiplexed_model_id":
|
||||
self.multiplexed_model_id = value
|
||||
elif key == "session_id":
|
||||
self.session_id = value
|
||||
|
||||
@property
|
||||
def request_type(self) -> str:
|
||||
return "grpc"
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
return self.service_method
|
||||
|
||||
@property
|
||||
def route_path(self) -> str:
|
||||
return self.app_name
|
||||
|
||||
@property
|
||||
def is_route_request(self) -> bool:
|
||||
return self.service_method == "/ray.serve.RayServeAPIService/ListApplications"
|
||||
|
||||
@property
|
||||
def is_health_request(self) -> bool:
|
||||
return self.service_method == "/ray.serve.RayServeAPIService/Healthz"
|
||||
|
||||
@property
|
||||
def client(self) -> str:
|
||||
return format_grpc_peer_address(self.context.peer())
|
||||
|
||||
@property
|
||||
def has_input_stream(self) -> bool:
|
||||
"""Returns True if this request has a streaming input (client/bidi streaming)."""
|
||||
return self.streaming_type in (
|
||||
gRPCStreamingType.STREAM_UNARY,
|
||||
gRPCStreamingType.STREAM_STREAM,
|
||||
)
|
||||
|
||||
@property
|
||||
def request_iterator(self) -> Optional[AsyncIterator[Any]]:
|
||||
"""Returns the request iterator for client/bidi streaming, or None."""
|
||||
return self._request_iterator
|
||||
|
||||
def send_request_id(self, request_id: str):
|
||||
# Setting the trailing metadata on the ray_serve_grpc_context object, so it's
|
||||
# not overriding the ones set from the user and will be sent back to the
|
||||
# client altogether.
|
||||
self.ray_serve_grpc_context.set_trailing_metadata([("request_id", request_id)])
|
||||
|
||||
def serialized_replica_arg(self) -> bytes:
|
||||
# NOTE(edoakes): it's important that the request is sent as raw bytes to
|
||||
# skip the Ray cloudpickle serialization codepath for performance.
|
||||
return pickle.dumps(gRPCRequest(user_request_proto=self._request_proto))
|
||||
|
||||
def populate_tracing_context(self):
|
||||
"""Populate tracing context for gRPC requests.
|
||||
|
||||
This method extracts the "traceparent" metadata from the request headers and
|
||||
sets the tracing context from it.
|
||||
"""
|
||||
if not is_tracing_enabled():
|
||||
return
|
||||
|
||||
for key, value in self.context.invocation_metadata():
|
||||
if key == "traceparent":
|
||||
trace_context = extract_propagated_context({key: value})
|
||||
set_trace_context(trace_context)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResponseStatus:
|
||||
code: Union[str, grpc.StatusCode] # Must be convertible to a string.
|
||||
is_error: bool = False
|
||||
message: str = ""
|
||||
|
||||
|
||||
# Yields protocol-specific messages followed by a final `ResponseStatus`.
|
||||
ResponseGenerator = AsyncIterator[Union[Any, ResponseStatus]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HandlerMetadata:
|
||||
application_name: str = ""
|
||||
deployment_name: str = ""
|
||||
route: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResponseHandlerInfo:
|
||||
response_generator: ResponseGenerator
|
||||
metadata: HandlerMetadata
|
||||
should_record_access_log: bool
|
||||
should_increment_ongoing_requests: bool
|
||||
@@ -0,0 +1,181 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio.tasks import FIRST_COMPLETED
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.utils import calculate_remaining_timeout
|
||||
from ray.serve.exceptions import RequestCancelledError
|
||||
from ray.serve.handle import DeploymentResponse, DeploymentResponseGenerator
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class _ProxyResponseGeneratorBase(ABC):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
timeout_s: Optional[float] = None,
|
||||
disconnected_task: Optional[asyncio.Task] = None,
|
||||
result_callback: Optional[Callable[[Any], Any]] = None,
|
||||
):
|
||||
"""Implements a generator wrapping a deployment response.
|
||||
|
||||
Args:
|
||||
timeout_s: an end-to-end timeout for the request. If this expires and the
|
||||
response is not completed, the request will be cancelled. If `None`,
|
||||
there's no timeout.
|
||||
disconnected_task: a task whose completion signals that the client has
|
||||
disconnected. When this happens, the request will be cancelled. If `None`,
|
||||
disconnects will not be detected.
|
||||
result_callback: will be called on each result before it's returned. If
|
||||
`None`, the unmodified result is returned.
|
||||
"""
|
||||
self._timeout_s = timeout_s
|
||||
self._start_time_s = time.time()
|
||||
self._disconnected_task = disconnected_task
|
||||
self._result_callback = result_callback
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
async def __anext__(self):
|
||||
"""Return the next message in the stream.
|
||||
|
||||
Raises:
|
||||
TimeoutError: On timeout.
|
||||
asyncio.CancelledError: On disconnect.
|
||||
StopAsyncIteration: When the stream is completed.
|
||||
"""
|
||||
pass
|
||||
|
||||
def stop_checking_for_disconnect(self):
|
||||
"""Once this is called, the disconnected_task will be ignored."""
|
||||
self._disconnected_task = None
|
||||
|
||||
|
||||
def swallow_cancelled(task: asyncio.Task):
|
||||
try:
|
||||
task.result()
|
||||
except (RequestCancelledError, asyncio.CancelledError):
|
||||
# We expect RequestCancelledError to be raised because for disconnect or
|
||||
# timeouts, we explicitly call resp.cancel(). To avoid "Task exception
|
||||
# was never retrieved" errors from spamming the proxy logs, swallow
|
||||
# them here.
|
||||
pass
|
||||
except Exception:
|
||||
# For all other exceptions, do not catch and instead re-raise here so that
|
||||
# they will be logged properly.
|
||||
raise
|
||||
|
||||
|
||||
class ProxyResponseGenerator(_ProxyResponseGeneratorBase):
|
||||
"""Wraps a unary DeploymentResponse or streaming DeploymentResponseGenerator.
|
||||
|
||||
In the case of a unary DeploymentResponse, __anext__ will only ever return one
|
||||
result.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
response: Union[DeploymentResponse, DeploymentResponseGenerator],
|
||||
*,
|
||||
timeout_s: Optional[float] = None,
|
||||
disconnected_task: Optional[asyncio.Task] = None,
|
||||
result_callback: Optional[Callable[[Any], Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
timeout_s=timeout_s,
|
||||
disconnected_task=disconnected_task,
|
||||
result_callback=result_callback,
|
||||
)
|
||||
self._done = False
|
||||
self._response = response
|
||||
|
||||
def cancelled(self) -> bool:
|
||||
return self._response.cancelled()
|
||||
|
||||
async def __anext__(self):
|
||||
if self._done:
|
||||
raise StopAsyncIteration
|
||||
|
||||
try:
|
||||
if isinstance(self._response, DeploymentResponseGenerator):
|
||||
result = await self._get_next_streaming_result()
|
||||
else:
|
||||
result = await self._get_unary_result()
|
||||
self._done = True
|
||||
|
||||
if self._result_callback is not None:
|
||||
result = self._result_callback(result)
|
||||
except asyncio.CancelledError as e:
|
||||
# This is specifically for gRPC. The cancellation can happen from client
|
||||
# dropped connection before the request is completed. If self._response is
|
||||
# not already cancelled, we want to explicitly cancel the task, so it
|
||||
# doesn't waste cluster resource in this case and can be terminated
|
||||
# gracefully.
|
||||
if not self._response.cancelled():
|
||||
self._response.cancel()
|
||||
self._done = True
|
||||
|
||||
raise e from None
|
||||
except Exception as e:
|
||||
self._done = True
|
||||
raise e from None
|
||||
|
||||
return result
|
||||
|
||||
async def _await_response_anext(self) -> Any:
|
||||
return await self._response.__anext__()
|
||||
|
||||
async def _get_next_streaming_result(self) -> Any:
|
||||
next_result_task = asyncio.create_task(self._await_response_anext())
|
||||
tasks = [next_result_task]
|
||||
if self._disconnected_task is not None:
|
||||
tasks.append(self._disconnected_task)
|
||||
|
||||
done, _ = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=FIRST_COMPLETED,
|
||||
timeout=calculate_remaining_timeout(
|
||||
timeout_s=self._timeout_s,
|
||||
start_time_s=self._start_time_s,
|
||||
curr_time_s=time.time(),
|
||||
),
|
||||
)
|
||||
if next_result_task in done:
|
||||
return next_result_task.result()
|
||||
elif self._disconnected_task in done:
|
||||
next_result_task.cancel()
|
||||
next_result_task.add_done_callback(swallow_cancelled)
|
||||
self._response.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
else:
|
||||
next_result_task.cancel()
|
||||
next_result_task.add_done_callback(swallow_cancelled)
|
||||
self._response.cancel()
|
||||
raise TimeoutError()
|
||||
|
||||
async def _await_response(self) -> Any:
|
||||
return await self._response
|
||||
|
||||
async def _get_unary_result(self) -> Any:
|
||||
result_task = asyncio.create_task(self._await_response())
|
||||
tasks = [result_task]
|
||||
if self._disconnected_task is not None:
|
||||
tasks.append(self._disconnected_task)
|
||||
|
||||
done, _ = await asyncio.wait(
|
||||
tasks, return_when=FIRST_COMPLETED, timeout=self._timeout_s
|
||||
)
|
||||
if result_task in done:
|
||||
return result_task.result()
|
||||
elif self._disconnected_task in done:
|
||||
self._response.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
else:
|
||||
self._response.cancel()
|
||||
raise TimeoutError()
|
||||
@@ -0,0 +1,252 @@
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Route
|
||||
from starlette.types import Scope
|
||||
|
||||
from ray.serve._private.common import ApplicationName, DeploymentID, EndpointInfo
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.thirdparty.get_asgi_route_name import (
|
||||
RoutePattern,
|
||||
get_asgi_route_name,
|
||||
)
|
||||
from ray.serve.handle import DeploymentHandle
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
NO_ROUTES_MESSAGE = "Route table is not populated yet."
|
||||
NO_REPLICAS_MESSAGE = "No replicas are available yet."
|
||||
|
||||
|
||||
class ProxyRouter:
|
||||
"""Router interface for the proxy to use."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
get_handle: Callable[[str, str], DeploymentHandle],
|
||||
):
|
||||
# Function to get a handle given a name. Used to mock for testing.
|
||||
self._get_handle = get_handle
|
||||
# Contains a ServeHandle for each endpoint.
|
||||
self.handles: Dict[DeploymentID, DeploymentHandle] = dict()
|
||||
# Flipped to `True` once the route table has been updated at least once.
|
||||
# The proxy router is not ready for traffic until the route table is populated
|
||||
self._route_table_populated = False
|
||||
|
||||
# Info used for HTTP proxy
|
||||
# Routes sorted in order of decreasing length.
|
||||
self.sorted_routes: List[str] = list()
|
||||
# Endpoints associated with the routes.
|
||||
self.route_info: Dict[str, DeploymentID] = dict()
|
||||
# Map of application name to is_cross_language.
|
||||
self.app_to_is_cross_language: Dict[ApplicationName, bool] = dict()
|
||||
|
||||
# Info used for gRPC proxy
|
||||
# Endpoints info associated with endpoints.
|
||||
self.endpoints: Dict[DeploymentID, EndpointInfo] = dict()
|
||||
|
||||
# Map of route prefix to list of route patterns for that endpoint
|
||||
# Used to match incoming requests to ASGI route patterns for metrics
|
||||
# Route patterns are tuples of (methods, path) where methods can be None
|
||||
self.route_patterns: Dict[str, List[RoutePattern]] = dict()
|
||||
# Cache of mock Starlette apps for route pattern matching
|
||||
# Key: route prefix, Value: pre-built Starlette app with routes
|
||||
self._route_pattern_apps: Dict[str, Any] = dict()
|
||||
|
||||
def ready_for_traffic(self, is_head: bool) -> Tuple[bool, str]:
|
||||
"""Whether the proxy router is ready to serve traffic.
|
||||
|
||||
The first return value will be false if any of the following hold:
|
||||
- The route table has not been populated yet with a non-empty set of routes
|
||||
- The route table has been populated, but none of the handles
|
||||
have received running replicas yet AND it lives on a worker node.
|
||||
|
||||
Otherwise, the first return value will be true.
|
||||
"""
|
||||
|
||||
if not self._route_table_populated:
|
||||
return False, NO_ROUTES_MESSAGE
|
||||
|
||||
# NOTE(zcin): For the proxy on the head node, even if none of its handles have
|
||||
# been populated with running replicas yet, we MUST mark the proxy as ready for
|
||||
# traffic. This is to handle the case when all deployments have scaled to zero.
|
||||
# If the deployments (more precisely, ingress deployments) have all scaled down
|
||||
# to zero, at least one proxy needs to be able to receive incoming requests to
|
||||
# trigger upscale.
|
||||
if is_head:
|
||||
return True, ""
|
||||
|
||||
for handle in self.handles.values():
|
||||
if handle.running_replicas_populated():
|
||||
return True, ""
|
||||
|
||||
return False, NO_REPLICAS_MESSAGE
|
||||
|
||||
def update_routes(self, endpoints: Dict[DeploymentID, EndpointInfo]):
|
||||
logger.info(
|
||||
f"Got updated endpoints: {endpoints}.", extra={"log_to_stderr": True}
|
||||
)
|
||||
if endpoints:
|
||||
self._route_table_populated = True
|
||||
|
||||
self.endpoints = endpoints
|
||||
|
||||
existing_handles = set(self.handles.keys())
|
||||
routes = []
|
||||
route_info = {}
|
||||
app_to_is_cross_language = {}
|
||||
route_patterns = {}
|
||||
for endpoint, info in endpoints.items():
|
||||
routes.append(info.route)
|
||||
route_info[info.route] = endpoint
|
||||
app_to_is_cross_language[endpoint.app_name] = info.app_is_cross_language
|
||||
if info.route_patterns:
|
||||
route_patterns[info.route] = info.route_patterns
|
||||
if endpoint in self.handles:
|
||||
existing_handles.remove(endpoint)
|
||||
else:
|
||||
self.handles[endpoint] = self._get_handle(endpoint, info)
|
||||
|
||||
# Clean up any handles that are no longer used.
|
||||
if len(existing_handles) > 0:
|
||||
logger.info(
|
||||
f"Deleting {len(existing_handles)} unused handles.",
|
||||
extra={"log_to_stderr": False},
|
||||
)
|
||||
for endpoint in existing_handles:
|
||||
del self.handles[endpoint]
|
||||
|
||||
# Routes are sorted in order of decreasing length to enable longest
|
||||
# prefix matching.
|
||||
self.sorted_routes = sorted(routes, key=lambda x: len(x), reverse=True)
|
||||
self.route_info = route_info
|
||||
self.app_to_is_cross_language = app_to_is_cross_language
|
||||
self.route_patterns = route_patterns
|
||||
# Invalidate cached mock apps when route patterns change
|
||||
self._route_pattern_apps.clear()
|
||||
|
||||
def match_route(
|
||||
self, target_route: str
|
||||
) -> Optional[Tuple[str, DeploymentHandle, bool]]:
|
||||
"""Return the longest prefix match among existing routes for the route.
|
||||
Args:
|
||||
target_route: route to match against.
|
||||
Returns:
|
||||
(route, handle, is_cross_language) if found, else None.
|
||||
"""
|
||||
|
||||
for route in self.sorted_routes:
|
||||
if target_route.startswith(route):
|
||||
matched = False
|
||||
# If the route we matched on ends in a '/', then so does the
|
||||
# target route and this must be a match.
|
||||
if route.endswith("/"):
|
||||
matched = True
|
||||
# If the route we matched on doesn't end in a '/', we need to
|
||||
# do another check to ensure that either this is an exact match
|
||||
# or the next character in the target route is a '/'. This is
|
||||
# to guard against the scenario where we have '/route' as a
|
||||
# prefix and there's a request to '/routesuffix'. In this case,
|
||||
# it should *not* be a match.
|
||||
elif len(target_route) == len(route) or target_route[len(route)] == "/":
|
||||
matched = True
|
||||
|
||||
if matched:
|
||||
endpoint = self.route_info[route]
|
||||
return (
|
||||
route,
|
||||
self.handles[endpoint],
|
||||
self.app_to_is_cross_language[endpoint.app_name],
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def get_handle_for_endpoint(
|
||||
self, target_app_name: str
|
||||
) -> Optional[Tuple[str, DeploymentHandle, bool]]:
|
||||
"""Return the handle that matches with endpoint.
|
||||
|
||||
Args:
|
||||
target_app_name: app_name to match against.
|
||||
Returns:
|
||||
(route, handle, is_cross_language) for the single app if there
|
||||
is only one, else find the app and handle for exact match. Else return None.
|
||||
"""
|
||||
for endpoint_tag, handle in self.handles.items():
|
||||
# If the target_app_name matches with the endpoint or if
|
||||
# there is only one endpoint.
|
||||
if target_app_name == endpoint_tag.app_name or len(self.handles) == 1:
|
||||
endpoint_info = self.endpoints[endpoint_tag]
|
||||
return (
|
||||
endpoint_info.route,
|
||||
handle,
|
||||
endpoint_info.app_is_cross_language,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def match_route_pattern(self, route_prefix: str, asgi_scope: Scope) -> str:
|
||||
"""Match an incoming request to a specific route pattern.
|
||||
|
||||
This attempts to match the request path to a route pattern (e.g., /api/{user_id})
|
||||
rather than just the route prefix. This provides more granular metrics.
|
||||
|
||||
The mock Starlette app is cached per route_prefix for performance, avoiding
|
||||
the overhead of recreating the app and routes on every request.
|
||||
|
||||
Args:
|
||||
route_prefix: The matched route prefix from match_route()
|
||||
asgi_scope: The ASGI scope containing the request path and method
|
||||
|
||||
Returns:
|
||||
The matched route pattern if available, otherwise the route_prefix
|
||||
"""
|
||||
# If we don't have route patterns for this prefix, return the prefix
|
||||
if route_prefix not in self.route_patterns:
|
||||
return route_prefix
|
||||
|
||||
patterns = self.route_patterns[route_prefix]
|
||||
if not patterns:
|
||||
return route_prefix
|
||||
|
||||
# Get or create the cached mock app for this route_prefix
|
||||
mock_app = self._route_pattern_apps.get(route_prefix)
|
||||
if mock_app is None:
|
||||
try:
|
||||
# Create routes from patterns
|
||||
# We use a dummy endpoint since we only need pattern matching
|
||||
async def dummy_endpoint(request: Request):
|
||||
pass
|
||||
|
||||
routes = [
|
||||
Route(pattern.path, dummy_endpoint, methods=pattern.methods)
|
||||
for pattern in patterns
|
||||
]
|
||||
mock_app = Starlette(routes=routes)
|
||||
|
||||
# Cache the mock app for future requests
|
||||
self._route_pattern_apps[route_prefix] = mock_app
|
||||
except Exception:
|
||||
# If app creation fails, fall back to route prefix
|
||||
logger.debug(
|
||||
f"Failed to create mock app for route pattern matching: {route_prefix}",
|
||||
exc_info=True,
|
||||
)
|
||||
return route_prefix
|
||||
|
||||
# Use the cached mock app to match the route pattern
|
||||
try:
|
||||
matched = get_asgi_route_name(mock_app, asgi_scope)
|
||||
if matched:
|
||||
return matched
|
||||
except Exception:
|
||||
# If matching fails for any reason, fall back to route prefix
|
||||
logger.debug(
|
||||
f"Failed to match route pattern for {route_prefix}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Fall back to route prefix if no pattern matched
|
||||
return route_prefix
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import ray
|
||||
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
|
||||
from ray.actor import ActorHandle
|
||||
from ray.serve._private.broker import Broker
|
||||
from ray.serve._private.common import (
|
||||
AsyncInferenceTaskQueueMetricReport,
|
||||
DeploymentID,
|
||||
)
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_ASYNC_INFERENCE_TASK_QUEUE_METRIC_PUSH_INTERVAL_S,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.metrics_utils import MetricsPusher
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
# Actor name prefix for QueueMonitor actors
|
||||
QUEUE_MONITOR_ACTOR_PREFIX = "QUEUE_MONITOR::"
|
||||
|
||||
|
||||
def get_queue_monitor_actor_name(deployment_id: DeploymentID) -> str:
|
||||
"""Get the Ray actor name for a deployment's QueueMonitor.
|
||||
|
||||
Args:
|
||||
deployment_id: ID of the deployment (contains app_name and name)
|
||||
|
||||
Returns:
|
||||
The full actor name in format "QUEUE_MONITOR::<app_name>#<deployment_name>#"
|
||||
"""
|
||||
return f"{QUEUE_MONITOR_ACTOR_PREFIX}{deployment_id.app_name}#{deployment_id.name}#"
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
class QueueMonitorActor:
|
||||
"""
|
||||
Actor that monitors queue length by directly querying the broker.
|
||||
|
||||
Returns pending tasks in the queue.
|
||||
|
||||
Uses native broker clients:
|
||||
- Redis: Uses redis-py library with LLEN command
|
||||
- RabbitMQ: Uses HTTP management API
|
||||
|
||||
Periodically pushes queue length metrics to the controller for autoscaling.
|
||||
"""
|
||||
|
||||
PUSH_METRICS_TO_CONTROLLER_TASK_NAME = "push_metrics_to_controller"
|
||||
|
||||
async def __init__(
|
||||
self,
|
||||
broker_url: str,
|
||||
queue_name: str,
|
||||
deployment_id: DeploymentID,
|
||||
controller_handle: ActorHandle,
|
||||
rabbitmq_http_url: str = "http://guest:guest@localhost:15672/api/",
|
||||
):
|
||||
self._broker_url = broker_url
|
||||
self._queue_name = queue_name
|
||||
self._deployment_id = deployment_id
|
||||
self._controller_handle = controller_handle
|
||||
self._rabbitmq_http_url = rabbitmq_http_url
|
||||
|
||||
self._broker = Broker(self._broker_url, http_api=self._rabbitmq_http_url)
|
||||
|
||||
self._metrics_pusher = MetricsPusher()
|
||||
self._start_metrics_pusher()
|
||||
|
||||
def _start_metrics_pusher(self):
|
||||
"""Start the metrics pusher to periodically push metrics to the controller."""
|
||||
self._metrics_pusher.register_or_update_task(
|
||||
self.PUSH_METRICS_TO_CONTROLLER_TASK_NAME,
|
||||
self._push_metrics_to_controller,
|
||||
RAY_SERVE_ASYNC_INFERENCE_TASK_QUEUE_METRIC_PUSH_INTERVAL_S,
|
||||
)
|
||||
self._metrics_pusher.start()
|
||||
|
||||
def __ray_shutdown__(self):
|
||||
# Note: This must be synchronous (not async) because Ray's core code
|
||||
# in _raylet.pyx calls __ray_shutdown__() without awaiting.
|
||||
if self._metrics_pusher is not None:
|
||||
self._metrics_pusher.stop_tasks()
|
||||
self._metrics_pusher = None
|
||||
if self._broker is not None:
|
||||
self._broker.close()
|
||||
self._broker = None
|
||||
|
||||
async def get_queue_length(self) -> int:
|
||||
"""
|
||||
Fetch queue length from the broker.
|
||||
|
||||
Returns:
|
||||
Number of pending tasks in the queue.
|
||||
|
||||
Raises:
|
||||
ValueError: If queue is not found in broker response or
|
||||
if queue data is missing the 'messages' field.
|
||||
"""
|
||||
queues = await self._broker.queues([self._queue_name])
|
||||
if queues is not None:
|
||||
for q in queues:
|
||||
if q.get("name") == self._queue_name:
|
||||
queue_length = q.get("messages")
|
||||
if queue_length is None:
|
||||
raise ValueError(
|
||||
f"Queue '{self._queue_name}' is missing 'messages' field"
|
||||
)
|
||||
return queue_length
|
||||
|
||||
raise ValueError(f"Queue '{self._queue_name}' not found in broker response")
|
||||
|
||||
async def _push_metrics_to_controller(self) -> None:
|
||||
"""Push queue length metrics to the controller for autoscaling."""
|
||||
try:
|
||||
queue_length = await self.get_queue_length()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[{self._deployment_id}] Failed to get queue length for metrics push: {e}"
|
||||
)
|
||||
raise e
|
||||
|
||||
report = AsyncInferenceTaskQueueMetricReport(
|
||||
deployment_id=self._deployment_id,
|
||||
queue_length=queue_length,
|
||||
timestamp_s=time.time(),
|
||||
)
|
||||
# Fire-and-forget push to controller
|
||||
self._controller_handle.record_autoscaling_metrics_from_async_inference_task_queue.remote(
|
||||
report
|
||||
)
|
||||
|
||||
|
||||
def create_queue_monitor_actor(
|
||||
deployment_id: DeploymentID,
|
||||
broker_url: str,
|
||||
queue_name: str,
|
||||
controller_handle: ActorHandle,
|
||||
rabbitmq_http_url: str = "http://guest:guest@localhost:15672/api/",
|
||||
namespace: str = "serve",
|
||||
) -> ray.actor.ActorHandle:
|
||||
"""
|
||||
Create a named QueueMonitor Ray actor.
|
||||
|
||||
Args:
|
||||
deployment_id: ID of the deployment (contains name and app_name)
|
||||
broker_url: URL of the message broker
|
||||
queue_name: Name of the queue to monitor
|
||||
controller_handle: Handle to the Serve controller for pushing metrics
|
||||
rabbitmq_http_url: HTTP API URL for RabbitMQ management (only for RabbitMQ)
|
||||
namespace: Ray namespace for the actor
|
||||
|
||||
Returns:
|
||||
ActorHandle for the QueueMonitor actor
|
||||
"""
|
||||
try:
|
||||
existing = get_queue_monitor_actor(deployment_id, namespace=namespace)
|
||||
logger.info(
|
||||
f"QueueMonitor actor for deployment '{deployment_id}' already exists, reusing"
|
||||
)
|
||||
return existing
|
||||
except ValueError:
|
||||
actor_name = get_queue_monitor_actor_name(deployment_id)
|
||||
actor = QueueMonitorActor.options(
|
||||
name=actor_name,
|
||||
namespace=namespace,
|
||||
max_restarts=-1,
|
||||
max_task_retries=-1,
|
||||
resources={HEAD_NODE_RESOURCE_NAME: 0.001},
|
||||
).remote(
|
||||
broker_url=broker_url,
|
||||
queue_name=queue_name,
|
||||
deployment_id=deployment_id,
|
||||
controller_handle=controller_handle,
|
||||
rabbitmq_http_url=rabbitmq_http_url,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Created QueueMonitor actor '{actor_name}' in namespace '{namespace}'"
|
||||
)
|
||||
return actor
|
||||
|
||||
|
||||
def get_queue_monitor_actor(
|
||||
deployment_id: DeploymentID,
|
||||
namespace: str = "serve",
|
||||
) -> ray.actor.ActorHandle:
|
||||
"""
|
||||
Get an existing QueueMonitor actor by name.
|
||||
|
||||
Args:
|
||||
deployment_id: ID of the deployment (contains app_name and name)
|
||||
namespace: Ray namespace
|
||||
|
||||
Returns:
|
||||
ActorHandle for the QueueMonitor actor
|
||||
|
||||
Raises:
|
||||
ValueError: If actor doesn't exist
|
||||
"""
|
||||
actor_name = get_queue_monitor_actor_name(deployment_id)
|
||||
return ray.get_actor(actor_name, namespace=namespace)
|
||||
|
||||
|
||||
def kill_queue_monitor_actor(
|
||||
deployment_id: DeploymentID,
|
||||
namespace: str = "serve",
|
||||
) -> None:
|
||||
"""
|
||||
Delete a QueueMonitor actor by name.
|
||||
|
||||
Args:
|
||||
deployment_id: ID of the deployment (contains app_name and name)
|
||||
namespace: Ray namespace
|
||||
|
||||
Raises:
|
||||
ValueError: If actor doesn't exist
|
||||
"""
|
||||
actor_name = get_queue_monitor_actor_name(deployment_id)
|
||||
actor = get_queue_monitor_actor(deployment_id, namespace=namespace)
|
||||
|
||||
ray.kill(actor, no_restart=True)
|
||||
logger.info(f"Deleted QueueMonitor actor '{actor_name}'")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,91 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, AsyncGenerator, Callable, Optional
|
||||
|
||||
from ray.serve._private.proxy_response_generator import (
|
||||
_ProxyResponseGeneratorBase,
|
||||
swallow_cancelled,
|
||||
)
|
||||
from ray.serve._private.utils import calculate_remaining_timeout
|
||||
|
||||
|
||||
class ReplicaResponseGenerator(_ProxyResponseGeneratorBase):
|
||||
"""Generic wrapper that adds disconnect detection to any async generator.
|
||||
|
||||
This can be used to wrap any async generator and add timeout and disconnect
|
||||
detection capabilities. When a disconnect is detected, the generator will
|
||||
raise asyncio.CancelledError.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
async_generator: AsyncGenerator[Any, None],
|
||||
*,
|
||||
timeout_s: Optional[float] = None,
|
||||
disconnected_task: Optional[asyncio.Task] = None,
|
||||
result_callback: Optional[Callable[[Any], Any]] = None,
|
||||
):
|
||||
super().__init__(
|
||||
timeout_s=timeout_s,
|
||||
disconnected_task=disconnected_task,
|
||||
result_callback=result_callback,
|
||||
)
|
||||
self._async_generator = async_generator
|
||||
self._done = False
|
||||
|
||||
async def __anext__(self):
|
||||
if self._done:
|
||||
raise StopAsyncIteration
|
||||
|
||||
try:
|
||||
result = await self._get_next_result()
|
||||
if self._result_callback is not None:
|
||||
result = self._result_callback(result)
|
||||
return result
|
||||
except (StopAsyncIteration, asyncio.CancelledError) as e:
|
||||
self._done = True
|
||||
raise e from None
|
||||
except Exception as e:
|
||||
self._done = True
|
||||
raise e from None
|
||||
|
||||
async def _await_response_anext(self) -> Any:
|
||||
return await self._async_generator.__anext__()
|
||||
|
||||
async def _get_next_result(self) -> Any:
|
||||
"""Get the next result from the async generator with disconnect detection."""
|
||||
# If there's no disconnect detection needed, use direct await to preserve
|
||||
# cancellation propagation (important for gRPC cancellation)
|
||||
remaining_timeout = calculate_remaining_timeout(
|
||||
timeout_s=self._timeout_s,
|
||||
start_time_s=self._start_time_s,
|
||||
curr_time_s=time.time(),
|
||||
)
|
||||
if self._disconnected_task is None:
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._await_response_anext(), timeout=remaining_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError()
|
||||
# Otherwise use asyncio.wait for disconnect detection
|
||||
next_result_task = asyncio.create_task(self._await_response_anext())
|
||||
tasks = [next_result_task, self._disconnected_task]
|
||||
|
||||
done, _ = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
timeout=remaining_timeout,
|
||||
)
|
||||
|
||||
if next_result_task in done:
|
||||
return next_result_task.result()
|
||||
elif self._disconnected_task in done:
|
||||
next_result_task.cancel()
|
||||
next_result_task.add_done_callback(swallow_cancelled)
|
||||
raise asyncio.CancelledError()
|
||||
else:
|
||||
# Timeout occurred
|
||||
next_result_task.cancel()
|
||||
next_result_task.add_done_callback(swallow_cancelled)
|
||||
raise TimeoutError()
|
||||
@@ -0,0 +1,590 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import inspect
|
||||
import logging
|
||||
import pickle
|
||||
import threading
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from asyncio import run_coroutine_threadsafe
|
||||
from functools import wraps
|
||||
from typing import Any, AsyncIterator, Callable, Coroutine, Iterator, Optional, Union
|
||||
|
||||
import grpc
|
||||
|
||||
import ray
|
||||
from ray.exceptions import ActorUnavailableError, RayTaskError, TaskCancelledError
|
||||
from ray.serve._private.common import (
|
||||
OBJ_REF_NOT_SUPPORTED_ERROR,
|
||||
ReplicaQueueLengthInfo,
|
||||
RequestMetadata,
|
||||
)
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.http_util import MessageQueue
|
||||
from ray.serve._private.serialization import RPCSerializer
|
||||
from ray.serve._private.utils import calculate_remaining_timeout, generate_request_id
|
||||
from ray.serve.exceptions import RequestCancelledError
|
||||
from ray.serve.generated.serve_pb2 import ASGIResponse
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
def is_running_in_asyncio_loop() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
class ReplicaResult(ABC):
|
||||
@abstractmethod
|
||||
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get(self, timeout_s: Optional[float]):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_async(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def __next__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def __anext__(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def add_done_callback(self, callback: Callable):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def cancel(self):
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def to_object_ref_async(self) -> ray.ObjectRef:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
|
||||
# NOTE(edoakes): there is only a sync version of this method because it
|
||||
# does not block like `to_object_ref` (so there's also no timeout argument).
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class ActorReplicaResult(ReplicaResult):
|
||||
def __init__(
|
||||
self,
|
||||
obj_ref_or_gen: Union[ray.ObjectRef, ray.ObjectRefGenerator],
|
||||
metadata: RequestMetadata,
|
||||
*,
|
||||
with_rejection: bool = False,
|
||||
):
|
||||
self._obj_ref: Optional[ray.ObjectRef] = None
|
||||
self._obj_ref_gen: Optional[ray.ObjectRefGenerator] = None
|
||||
self._is_streaming: bool = metadata.is_streaming
|
||||
self._request_id: str = metadata.request_id
|
||||
self._object_ref_or_gen_sync_lock = threading.Lock()
|
||||
self._with_rejection = with_rejection
|
||||
self._rejection_response = None
|
||||
|
||||
if isinstance(obj_ref_or_gen, ray.ObjectRefGenerator):
|
||||
self._obj_ref_gen = obj_ref_or_gen
|
||||
else:
|
||||
self._obj_ref = obj_ref_or_gen
|
||||
|
||||
if self._is_streaming:
|
||||
assert (
|
||||
self._obj_ref_gen is not None
|
||||
), "An ObjectRefGenerator must be passed for streaming requests."
|
||||
|
||||
request_context = ray.serve.context._get_serve_request_context()
|
||||
if request_context.cancel_on_parent_request_cancel:
|
||||
# Keep track of in-flight requests.
|
||||
self._response_id = generate_request_id()
|
||||
ray.serve.context._add_in_flight_request(
|
||||
request_context._internal_request_id, self._response_id, self
|
||||
)
|
||||
self.add_done_callback(
|
||||
lambda _: ray.serve.context._remove_in_flight_request(
|
||||
request_context._internal_request_id, self._response_id
|
||||
)
|
||||
)
|
||||
|
||||
def _process_response(f: Union[Callable, Coroutine]):
|
||||
@wraps(f)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return f(self, *args, **kwargs)
|
||||
except ray.exceptions.TaskCancelledError:
|
||||
raise RequestCancelledError(self._request_id)
|
||||
|
||||
@wraps(f)
|
||||
async def async_wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
return await f(self, *args, **kwargs)
|
||||
except ray.exceptions.TaskCancelledError:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
if inspect.iscoroutinefunction(f):
|
||||
return async_wrapper
|
||||
else:
|
||||
return wrapper
|
||||
|
||||
@_process_response
|
||||
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
|
||||
"""Get the queue length info from the replica to handle rejection."""
|
||||
assert (
|
||||
self._with_rejection and self._obj_ref_gen is not None
|
||||
), "get_rejection_response() can only be called when request rejection is enabled."
|
||||
|
||||
try:
|
||||
if self._rejection_response is None:
|
||||
response = await (await self._obj_ref_gen.__anext__())
|
||||
self._rejection_response = pickle.loads(response)
|
||||
|
||||
return self._rejection_response
|
||||
except asyncio.CancelledError as e:
|
||||
# HTTP client disconnected or request was explicitly canceled.
|
||||
logger.info(
|
||||
"Cancelling request that has already been assigned to a replica."
|
||||
)
|
||||
self.cancel()
|
||||
raise e from None
|
||||
except TaskCancelledError:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
@_process_response
|
||||
def get(self, timeout_s: Optional[float]):
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "get() can only be called on a unary ActorReplicaResult."
|
||||
|
||||
start_time_s = time.time()
|
||||
object_ref = self.to_object_ref(timeout_s=timeout_s)
|
||||
remaining_timeout_s = calculate_remaining_timeout(
|
||||
timeout_s=timeout_s,
|
||||
start_time_s=start_time_s,
|
||||
curr_time_s=time.time(),
|
||||
)
|
||||
return ray.get(object_ref, timeout=remaining_timeout_s)
|
||||
|
||||
@_process_response
|
||||
async def get_async(self):
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "get_async() can only be called on a unary ActorReplicaResult."
|
||||
|
||||
return await (await self.to_object_ref_async())
|
||||
|
||||
@_process_response
|
||||
def __next__(self):
|
||||
assert (
|
||||
self._is_streaming
|
||||
), "next() can only be called on a streaming ActorReplicaResult."
|
||||
|
||||
next_obj_ref = self._obj_ref_gen.__next__()
|
||||
return ray.get(next_obj_ref)
|
||||
|
||||
@_process_response
|
||||
async def __anext__(self):
|
||||
assert (
|
||||
self._is_streaming
|
||||
), "__anext__() can only be called on a streaming ActorReplicaResult."
|
||||
|
||||
next_obj_ref = await self._obj_ref_gen.__anext__()
|
||||
return await next_obj_ref
|
||||
|
||||
def add_done_callback(self, callback: Callable):
|
||||
if self._obj_ref_gen is not None:
|
||||
self._obj_ref_gen.completed()._on_completed(callback)
|
||||
else:
|
||||
self._obj_ref._on_completed(callback)
|
||||
|
||||
def cancel(self):
|
||||
if self._obj_ref_gen is not None:
|
||||
ray.cancel(self._obj_ref_gen)
|
||||
else:
|
||||
ray.cancel(self._obj_ref)
|
||||
|
||||
def to_object_ref(self, *, timeout_s: Optional[float] = None) -> ray.ObjectRef:
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "to_object_ref can only be called on a unary ReplicaActorResult."
|
||||
|
||||
# NOTE(edoakes): this section needs to be guarded with a lock and the resulting
|
||||
# object ref cached in order to avoid calling `__next__()` to
|
||||
# resolve to the underlying object ref more than once.
|
||||
# See: https://github.com/ray-project/ray/issues/43879.
|
||||
with self._object_ref_or_gen_sync_lock:
|
||||
if self._obj_ref is None:
|
||||
obj_ref = self._obj_ref_gen._next_sync(timeout_s=timeout_s)
|
||||
if obj_ref.is_nil():
|
||||
raise TimeoutError("Timed out resolving to ObjectRef.")
|
||||
|
||||
self._obj_ref = obj_ref
|
||||
|
||||
return self._obj_ref
|
||||
|
||||
async def to_object_ref_async(self) -> ray.ObjectRef:
|
||||
assert (
|
||||
not self._is_streaming
|
||||
), "to_object_ref_async can only be called on a unary ReplicaActorResult."
|
||||
|
||||
# NOTE(edoakes): this section needs to be guarded with a lock and the resulting
|
||||
# object ref cached in order to avoid calling `__anext__()` to
|
||||
# resolve to the underlying object ref more than once.
|
||||
# See: https://github.com/ray-project/ray/issues/43879.
|
||||
#
|
||||
# IMPORTANT: We use a threading lock instead of asyncio.Lock because this method
|
||||
# can be called from multiple event loops concurrently:
|
||||
# 1. From the user's code (on the replica's event loop) when awaiting a response
|
||||
# 2. From the router's event loop when resolving a DeploymentResponse argument
|
||||
# asyncio.Lock is NOT thread-safe and NOT designed for cross-loop usage, which
|
||||
# causes deadlocks.
|
||||
#
|
||||
# We use a non-blocking acquire pattern to avoid blocking the event loop:
|
||||
# - Try to acquire the lock without blocking
|
||||
# - If already held, yield and retry (allows other async tasks to run)
|
||||
# - Once acquired, check if result is already available (double-check pattern)
|
||||
while True:
|
||||
# Fast path: already computed
|
||||
if self._obj_ref is not None:
|
||||
return self._obj_ref
|
||||
|
||||
acquired = self._object_ref_or_gen_sync_lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
try:
|
||||
# Double-check under lock
|
||||
if self._obj_ref is None:
|
||||
self._obj_ref = await self._obj_ref_gen.__anext__()
|
||||
return self._obj_ref
|
||||
finally:
|
||||
self._object_ref_or_gen_sync_lock.release()
|
||||
else:
|
||||
# Lock is held by another task/thread, yield and retry
|
||||
await asyncio.sleep(0)
|
||||
|
||||
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
|
||||
assert (
|
||||
self._is_streaming
|
||||
), "to_object_ref_gen can only be called on a streaming ReplicaActorResult."
|
||||
|
||||
return self._obj_ref_gen
|
||||
|
||||
|
||||
class gRPCReplicaResult(ReplicaResult):
|
||||
def __init__(
|
||||
self,
|
||||
call: grpc.aio.Call,
|
||||
metadata: RequestMetadata,
|
||||
actor_id: ray.ActorID,
|
||||
loop: asyncio.AbstractEventLoop = None,
|
||||
*,
|
||||
with_rejection: bool = False,
|
||||
):
|
||||
self._call: grpc.aio.Call = call
|
||||
self._actor_id: ray.ActorID = actor_id
|
||||
self._metadata: RequestMetadata = metadata # Store metadata for serialization
|
||||
self._result_queue: MessageQueue = MessageQueue()
|
||||
# This is the asyncio event loop that the gRPC Call object is attached to
|
||||
self._grpc_call_loop = loop or asyncio._get_running_loop()
|
||||
self._is_streaming = metadata.is_streaming
|
||||
self._with_rejection = with_rejection
|
||||
self._rejection_response = None
|
||||
|
||||
self._gen = None
|
||||
self._fut = None
|
||||
|
||||
# NOTE(zcin): for now, these two concepts will be synonymous.
|
||||
# In other words, using a queue means the router is running on
|
||||
# a separate thread/event loop, and vice versa not using a queue
|
||||
# means the router is running on the main event loop, where the
|
||||
# DeploymentHandle lives.
|
||||
self._calling_from_same_loop = not metadata._on_separate_loop
|
||||
if hasattr(self._call, "__aiter__"):
|
||||
self._gen = self._call.__aiter__()
|
||||
# If the grpc call IS streaming, AND it was created on a
|
||||
# a separate loop, then use a queue to fetch the objects
|
||||
self._use_queue = metadata._on_separate_loop
|
||||
else:
|
||||
self._use_queue = False
|
||||
|
||||
# Start a background task that continuously fetches from the
|
||||
# streaming grpc call. This way callbacks will actually be
|
||||
# called when the request finishes even without the user
|
||||
# explicitly consuming the response.
|
||||
self._consume_task = None
|
||||
if self._use_queue:
|
||||
self._consume_task = self._grpc_call_loop.create_task(
|
||||
self.consume_messages_from_gen()
|
||||
)
|
||||
|
||||
# Keep track of in-flight requests.
|
||||
self._response_id = generate_request_id()
|
||||
request_context = ray.serve.context._get_serve_request_context()
|
||||
ray.serve.context._add_in_flight_request(
|
||||
request_context._internal_request_id, self._response_id, self
|
||||
)
|
||||
self.add_done_callback(
|
||||
lambda _: ray.serve.context._remove_in_flight_request(
|
||||
request_context._internal_request_id, self._response_id
|
||||
)
|
||||
)
|
||||
|
||||
def _process_grpc_response(f: Union[Callable, Coroutine]):
|
||||
def deserialize_or_raise_error(
|
||||
grpc_response: ASGIResponse,
|
||||
metadata: RequestMetadata,
|
||||
):
|
||||
# Create serializer with options from metadata
|
||||
serializer = RPCSerializer(
|
||||
metadata.request_serialization,
|
||||
metadata.response_serialization,
|
||||
)
|
||||
|
||||
if grpc_response.is_error:
|
||||
err = serializer.loads_response(grpc_response.serialized_message)
|
||||
if isinstance(err, RayTaskError):
|
||||
raise err.as_instanceof_cause()
|
||||
else:
|
||||
raise err
|
||||
else:
|
||||
# If it's an HTTP request, then the proxy response generator is
|
||||
# expecting a pickled dictionary, so we return result directly
|
||||
# without deserializing. Otherwise, we deserialize the result.
|
||||
if ray.serve.context._get_serve_request_context().is_http_request:
|
||||
return grpc_response.serialized_message
|
||||
else:
|
||||
return serializer.loads_response(grpc_response.serialized_message)
|
||||
|
||||
@wraps(f)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
grpc_response = f(self, *args, **kwargs)
|
||||
except grpc.aio.AioRpcError as e:
|
||||
if e.code() == grpc.StatusCode.UNAVAILABLE:
|
||||
raise ActorUnavailableError(
|
||||
"Actor is unavailable.",
|
||||
self._actor_id.binary(),
|
||||
)
|
||||
raise
|
||||
except concurrent.futures.CancelledError:
|
||||
raise RequestCancelledError from None
|
||||
|
||||
return deserialize_or_raise_error(grpc_response, self._metadata)
|
||||
|
||||
@wraps(f)
|
||||
async def async_wrapper(self, *args, **kwargs):
|
||||
try:
|
||||
grpc_response = await f(self, *args, **kwargs)
|
||||
except grpc.aio.AioRpcError as e:
|
||||
if e.code() == grpc.StatusCode.UNAVAILABLE:
|
||||
raise ActorUnavailableError(
|
||||
"Actor is unavailable.",
|
||||
self._actor_id.binary(),
|
||||
)
|
||||
raise
|
||||
|
||||
return deserialize_or_raise_error(grpc_response, self._metadata)
|
||||
|
||||
if inspect.iscoroutinefunction(f):
|
||||
return async_wrapper
|
||||
else:
|
||||
return wrapper
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[Any]:
|
||||
return self
|
||||
|
||||
def __iter__(self) -> Iterator[Any]:
|
||||
return self
|
||||
|
||||
async def consume_messages_from_gen(self):
|
||||
try:
|
||||
async for resp in self._gen:
|
||||
self._result_queue.put_nowait(resp)
|
||||
except BaseException as e:
|
||||
self._result_queue.set_error(e)
|
||||
finally:
|
||||
self._result_queue.close()
|
||||
|
||||
async def _get_internal(self):
|
||||
"""Gets the result from the gRPC call object.
|
||||
|
||||
If the call object is a UnaryUnaryCall, we await the call.
|
||||
Otherwise the call object is a UnaryStreamCall.
|
||||
- If the request was sent on a separate loop, then the
|
||||
streamed results are being consumed and put onto the in-memory
|
||||
queue, so we read from that queue.
|
||||
- Otherwise the request was sent on the current loop, so we
|
||||
fetch the next object from the async generator.
|
||||
"""
|
||||
|
||||
if self._gen is None:
|
||||
return await self._call
|
||||
elif self._use_queue:
|
||||
return await self._result_queue.get_one_message()
|
||||
else:
|
||||
return await self._gen.__anext__()
|
||||
|
||||
async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]:
|
||||
"""Get the queue length info from the replica to handle rejection."""
|
||||
assert (
|
||||
self._with_rejection
|
||||
), "get_rejection_response() can only be called when request rejection is enabled."
|
||||
|
||||
try:
|
||||
if self._rejection_response is None:
|
||||
# NOTE(edoakes): this is required for gRPC to raise an AioRpcError if something
|
||||
# goes wrong establishing the connection (for example, a bug in our code).
|
||||
await self._call.wait_for_connection()
|
||||
metadata = await self._call.initial_metadata()
|
||||
|
||||
accepted = metadata.get("accepted", None)
|
||||
num_ongoing_requests = metadata.get("num_ongoing_requests", None)
|
||||
if accepted is None or num_ongoing_requests is None:
|
||||
code = await self._call.code()
|
||||
details = await self._call.details()
|
||||
raise RuntimeError(f"Unexpected error ({code}): {details}.")
|
||||
|
||||
self._rejection_response = ReplicaQueueLengthInfo(
|
||||
accepted=bool(int(accepted)),
|
||||
num_ongoing_requests=int(num_ongoing_requests),
|
||||
)
|
||||
|
||||
return self._rejection_response
|
||||
except asyncio.CancelledError as e:
|
||||
# HTTP client disconnected or request was explicitly canceled.
|
||||
logger.info(
|
||||
"Cancelling request that has already been assigned to a replica."
|
||||
)
|
||||
self.cancel()
|
||||
raise e from None
|
||||
except grpc.aio.AioRpcError as e:
|
||||
# If we received an `UNAVAILABLE` grpc error, that is
|
||||
# equivalent to `RayActorError`, although we don't know
|
||||
# whether it's `ActorDiedError` or `ActorUnavailableError`.
|
||||
# Conservatively, we assume it is `ActorUnavailableError`,
|
||||
# and we raise it here so that it goes through the unified
|
||||
# code path for handling RayActorErrors.
|
||||
# The router will retry scheduling the request with the
|
||||
# cache invalidated, at which point if the actor is actually
|
||||
# dead, the router will realize through active probing.
|
||||
if not self._is_streaming:
|
||||
# In UnaryUnary calls, initial metadata is sent back with the request
|
||||
# response, so we can't determine if the request was accepted until
|
||||
# after the request is handled. If the replica crashed while handling
|
||||
# the request, we can still get initial metadata via the AioRpcError,
|
||||
# since the server sets the metadata before handling the request.
|
||||
# If there is no metadata, we know the replica was already unavailable
|
||||
# prior to the request being sent. We only raise an ActorUnavailableError
|
||||
# (and thus retry the request) if the request was rejected or if the
|
||||
# replica was already unavailable.
|
||||
metadata = e.initial_metadata()
|
||||
accepted = metadata.get("accepted", None)
|
||||
if accepted is not None and bool(int(accepted)):
|
||||
num_ongoing_requests = metadata.get("num_ongoing_requests", None)
|
||||
if num_ongoing_requests is None:
|
||||
raise RuntimeError(
|
||||
f"Unexpected error ({e.code()}): {e.details()}."
|
||||
)
|
||||
|
||||
return ReplicaQueueLengthInfo(
|
||||
accepted=True,
|
||||
num_ongoing_requests=int(num_ongoing_requests),
|
||||
)
|
||||
|
||||
# Peer-sent CANCELLED means the replica's gRPC server cancelled
|
||||
# the call before the handler ran (graceful-shutdown window).
|
||||
# Without `accepted` metadata (checked above) the request never
|
||||
# executed, so it is safe to retry, same as UNAVAILABLE. A local
|
||||
# cancellation raises asyncio.CancelledError, not AioRpcError.
|
||||
if e.code() in (
|
||||
grpc.StatusCode.UNAVAILABLE,
|
||||
grpc.StatusCode.CANCELLED,
|
||||
):
|
||||
raise ActorUnavailableError(
|
||||
"Actor is unavailable.",
|
||||
self._actor_id.binary(),
|
||||
)
|
||||
|
||||
raise e from None
|
||||
|
||||
@_process_grpc_response
|
||||
def get(self, timeout_s: Optional[float]):
|
||||
if is_running_in_asyncio_loop():
|
||||
raise RuntimeError(
|
||||
"Sync method `get()` should not be called from within an `asyncio` "
|
||||
"event loop. Use `get_async()` instead."
|
||||
)
|
||||
|
||||
if self._fut is None:
|
||||
self._fut = run_coroutine_threadsafe(
|
||||
self._get_internal(), self._grpc_call_loop
|
||||
)
|
||||
|
||||
try:
|
||||
return self._fut.result(timeout=timeout_s)
|
||||
except concurrent.futures.TimeoutError:
|
||||
raise TimeoutError("Timed out waiting for result.") from None
|
||||
|
||||
@_process_grpc_response
|
||||
async def get_async(self):
|
||||
if self._fut is None:
|
||||
if self._calling_from_same_loop:
|
||||
return await self._get_internal()
|
||||
else:
|
||||
self._fut = run_coroutine_threadsafe(
|
||||
self._get_internal(), self._grpc_call_loop
|
||||
)
|
||||
|
||||
return await asyncio.wrap_future(self._fut)
|
||||
|
||||
@_process_grpc_response
|
||||
def __next__(self):
|
||||
if is_running_in_asyncio_loop():
|
||||
raise RuntimeError(
|
||||
"Sync method `__next__()` should not be called from within an "
|
||||
"`asyncio` event loop. Use `__anext__()` instead."
|
||||
)
|
||||
|
||||
fut = run_coroutine_threadsafe(self._get_internal(), loop=self._grpc_call_loop)
|
||||
try:
|
||||
return fut.result()
|
||||
except StopAsyncIteration:
|
||||
# We need to raise the synchronous version, StopIteration
|
||||
raise StopIteration
|
||||
|
||||
@_process_grpc_response
|
||||
async def __anext__(self):
|
||||
if self._calling_from_same_loop:
|
||||
return await self._get_internal()
|
||||
else:
|
||||
fut = run_coroutine_threadsafe(
|
||||
self._get_internal(), loop=self._grpc_call_loop
|
||||
)
|
||||
return await asyncio.wrap_future(fut)
|
||||
|
||||
def add_done_callback(self, callback: Callable):
|
||||
self._call.add_done_callback(callback)
|
||||
|
||||
def cancel(self):
|
||||
self._call.cancel()
|
||||
|
||||
def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef:
|
||||
raise OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
|
||||
async def to_object_ref_async(self) -> ray.ObjectRef:
|
||||
raise OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
|
||||
def to_object_ref_gen(self) -> ray.ObjectRefGenerator:
|
||||
raise OBJ_REF_NOT_SUPPORTED_ERROR
|
||||
@@ -0,0 +1,180 @@
|
||||
from typing import Dict
|
||||
|
||||
from ray.serve._private.common import RequestProtocol
|
||||
from ray.serve._private.constants import REQUEST_LATENCY_BUCKETS_MS
|
||||
from ray.util import metrics
|
||||
|
||||
|
||||
class RequestIngressMetrics:
|
||||
"""E2E request metrics shared by the proxies and direct-ingress replicas.
|
||||
|
||||
Defines and emits the standard ``serve_num_{protocol}_requests`` family of
|
||||
metrics. Both the proxy (which sees all proxy-routed traffic) and a
|
||||
direct-ingress replica (which sees traffic that bypasses the proxy) record
|
||||
them, so the series are disjoint at runtime.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
protocol: RequestProtocol,
|
||||
*,
|
||||
source: str,
|
||||
node_id: str,
|
||||
node_ip_address: str,
|
||||
):
|
||||
"""Create the metric objects.
|
||||
|
||||
Args:
|
||||
protocol: Request protocol these metrics describe (e.g. HTTP).
|
||||
source: Human-readable origin of the metrics, "proxy" or "ingress".
|
||||
Only affects the metric descriptions so that they remain
|
||||
accurate for each call site.
|
||||
node_id: Default tag value for the ongoing-requests gauge.
|
||||
node_ip_address: Default tag value for the ongoing-requests gauge.
|
||||
"""
|
||||
protocol_name = protocol.lower()
|
||||
|
||||
self.request_counter = metrics.Counter(
|
||||
f"serve_num_{protocol_name}_requests",
|
||||
description=f"The number of {protocol.value} requests processed.",
|
||||
tag_keys=("route", "method", "application", "status_code"),
|
||||
)
|
||||
|
||||
self.request_error_counter = metrics.Counter(
|
||||
f"serve_num_{protocol_name}_error_requests",
|
||||
description=f"The number of errored {protocol.value} responses.",
|
||||
tag_keys=(
|
||||
"route",
|
||||
"error_code",
|
||||
"method",
|
||||
"application",
|
||||
),
|
||||
)
|
||||
|
||||
self.deployment_request_error_counter = metrics.Counter(
|
||||
f"serve_num_deployment_{protocol_name}_error_requests",
|
||||
description=(
|
||||
f"The number of errored {protocol.value} "
|
||||
"responses returned by each deployment."
|
||||
),
|
||||
tag_keys=(
|
||||
"deployment",
|
||||
"error_code",
|
||||
"method",
|
||||
"route",
|
||||
"application",
|
||||
),
|
||||
)
|
||||
|
||||
self.processing_latency_tracker = metrics.Histogram(
|
||||
f"serve_{protocol_name}_request_latency_ms",
|
||||
description=(
|
||||
f"The end-to-end latency of {protocol.value} requests "
|
||||
f"(measured from the Serve {protocol.value} {source})."
|
||||
),
|
||||
boundaries=REQUEST_LATENCY_BUCKETS_MS,
|
||||
tag_keys=(
|
||||
"method",
|
||||
"route",
|
||||
"application",
|
||||
"status_code",
|
||||
),
|
||||
)
|
||||
|
||||
self.num_ongoing_requests_gauge = metrics.Gauge(
|
||||
name=f"serve_num_ongoing_{protocol_name}_requests",
|
||||
description=(
|
||||
f"The number of ongoing requests in this {protocol.value} {source}."
|
||||
),
|
||||
tag_keys=("node_id", "node_ip_address"),
|
||||
).set_default_tags(
|
||||
{
|
||||
"node_id": node_id,
|
||||
"node_ip_address": node_ip_address,
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def request_tags(
|
||||
*, route: str, method: str, application: str, status_code: str
|
||||
) -> Dict[str, str]:
|
||||
"""Tags for the request counter and processing-latency tracker."""
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"application": application,
|
||||
"status_code": status_code,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def request_error_tags(
|
||||
*, route: str, method: str, application: str, status_code: str
|
||||
) -> Dict[str, str]:
|
||||
"""Tags for the request error counter."""
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"application": application,
|
||||
"error_code": status_code,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def deployment_error_tags(
|
||||
*,
|
||||
route: str,
|
||||
method: str,
|
||||
application: str,
|
||||
status_code: str,
|
||||
deployment: str,
|
||||
) -> Dict[str, str]:
|
||||
"""Tags for the per-deployment request error counter."""
|
||||
return {
|
||||
"route": route,
|
||||
"method": method,
|
||||
"application": application,
|
||||
"error_code": status_code,
|
||||
"deployment": deployment,
|
||||
}
|
||||
|
||||
def record_request(
|
||||
self,
|
||||
*,
|
||||
route: str,
|
||||
method: str,
|
||||
application: str,
|
||||
status_code: str,
|
||||
latency_ms: float,
|
||||
is_error: bool,
|
||||
deployment_name: str,
|
||||
):
|
||||
"""Emit the per-request metrics directly (no batching)."""
|
||||
request_tags = self.request_tags(
|
||||
route=route,
|
||||
method=method,
|
||||
application=application,
|
||||
status_code=status_code,
|
||||
)
|
||||
self.request_counter.inc(tags=request_tags)
|
||||
self.processing_latency_tracker.observe(latency_ms, tags=request_tags)
|
||||
if is_error:
|
||||
self.request_error_counter.inc(
|
||||
tags=self.request_error_tags(
|
||||
route=route,
|
||||
method=method,
|
||||
application=application,
|
||||
status_code=status_code,
|
||||
)
|
||||
)
|
||||
self.deployment_request_error_counter.inc(
|
||||
tags=self.deployment_error_tags(
|
||||
route=route,
|
||||
method=method,
|
||||
application=application,
|
||||
status_code=status_code,
|
||||
deployment=deployment_name,
|
||||
)
|
||||
)
|
||||
|
||||
def set_num_ongoing_requests(self, num_ongoing_requests: int):
|
||||
"""Set the ongoing-requests gauge."""
|
||||
self.num_ongoing_requests_gauge.set(num_ongoing_requests)
|
||||
@@ -0,0 +1,11 @@
|
||||
from ray.serve._private.request_router.common import PendingRequest # noqa: F401
|
||||
from ray.serve._private.request_router.pow_2_router import ( # noqa: F401
|
||||
PowerOfTwoChoicesRequestRouter,
|
||||
)
|
||||
from ray.serve._private.request_router.replica_wrapper import ( # noqa: F401
|
||||
ReplicaSelection,
|
||||
RunningReplica,
|
||||
)
|
||||
from ray.serve._private.request_router.request_router import ( # noqa: F401
|
||||
RequestRouter,
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from ray.serve._private.common import ReplicaID, RequestMetadata
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
@dataclass()
|
||||
class RequestRoutingContext:
|
||||
multiplexed_start_matching_time: Optional[float] = None
|
||||
tried_fewest_multiplexed_models: bool = False
|
||||
tried_first_multiplexed_models: bool = False
|
||||
tried_same_node: bool = False
|
||||
tried_same_az: bool = False
|
||||
should_backoff: bool = False
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
@dataclass
|
||||
class PendingRequest:
|
||||
"""A request that is pending execution by a replica."""
|
||||
|
||||
args: List[Any]
|
||||
"""Positional arguments for the request."""
|
||||
|
||||
kwargs: Dict[Any, Any]
|
||||
"""Keyword arguments for the request."""
|
||||
|
||||
metadata: RequestMetadata
|
||||
"""Metadata for the request, including request ID and whether it's streaming."""
|
||||
|
||||
created_at: float = field(default_factory=lambda: time.time())
|
||||
"""Timestamp when the request was created."""
|
||||
|
||||
future: asyncio.Future = field(default_factory=lambda: asyncio.Future())
|
||||
"""An asyncio Future that will be set when the request is routed."""
|
||||
|
||||
routing_context: RequestRoutingContext = field(
|
||||
default_factory=RequestRoutingContext
|
||||
)
|
||||
"""Context for request routing, used to track routing attempts and backoff."""
|
||||
|
||||
resolved: bool = False
|
||||
"""Whether the arguments have been resolved."""
|
||||
|
||||
def reset_future(self):
|
||||
"""Reset the `asyncio.Future`, must be called if this request is re-used."""
|
||||
self.future = asyncio.Future()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplicaQueueLengthCacheEntry:
|
||||
queue_len: int
|
||||
timestamp: float
|
||||
|
||||
|
||||
class ReplicaQueueLengthCache:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
staleness_timeout_s: float = RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S,
|
||||
get_curr_time_s: Optional[Callable[[], float]] = None,
|
||||
):
|
||||
self._cache: Dict[ReplicaID, ReplicaQueueLengthCacheEntry] = {}
|
||||
self._staleness_timeout_s = staleness_timeout_s
|
||||
self._get_curr_time_s = (
|
||||
get_curr_time_s if get_curr_time_s is not None else lambda: time.time()
|
||||
)
|
||||
|
||||
def _is_timed_out(self, timestamp_s: int) -> bool:
|
||||
return self._get_curr_time_s() - timestamp_s > self._staleness_timeout_s
|
||||
|
||||
def get(self, replica_id: ReplicaID) -> Optional[int]:
|
||||
"""Get the queue length for a replica.
|
||||
|
||||
Returns `None` if the replica ID is not present or the entry is timed out.
|
||||
"""
|
||||
entry = self._cache.get(replica_id)
|
||||
if entry is None or self._is_timed_out(entry.timestamp):
|
||||
return None
|
||||
|
||||
return entry.queue_len
|
||||
|
||||
def update(self, replica_id: ReplicaID, queue_len: int):
|
||||
"""Set (or update) the queue length for a replica ID."""
|
||||
self._cache[replica_id] = ReplicaQueueLengthCacheEntry(
|
||||
queue_len, self._get_curr_time_s()
|
||||
)
|
||||
|
||||
def invalidate_key(self, replica_id: ReplicaID):
|
||||
self._cache.pop(replica_id, None)
|
||||
|
||||
def remove_inactive_replicas(self, *, active_replica_ids: Set[ReplicaID]):
|
||||
"""Removes entries for all replica IDs not in the provided active set."""
|
||||
# NOTE: the size of the cache dictionary changes during this loop.
|
||||
for replica_id in list(self._cache.keys()):
|
||||
if replica_id not in active_replica_ids:
|
||||
self._cache.pop(replica_id)
|
||||
@@ -0,0 +1,106 @@
|
||||
import logging
|
||||
import random
|
||||
from typing import (
|
||||
List,
|
||||
Optional,
|
||||
)
|
||||
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.request_router.common import (
|
||||
PendingRequest,
|
||||
)
|
||||
from ray.serve._private.request_router.replica_wrapper import (
|
||||
RunningReplica,
|
||||
)
|
||||
from ray.serve._private.request_router.request_router import (
|
||||
FIFOMixin,
|
||||
LocalityMixin,
|
||||
MultiplexMixin,
|
||||
RequestRouter,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class PowerOfTwoChoicesRequestRouter(
|
||||
FIFOMixin, LocalityMixin, MultiplexMixin, RequestRouter
|
||||
):
|
||||
"""Chooses a replica for each request using the "power of two choices" procedure.
|
||||
|
||||
Requests are routed in FIFO order.
|
||||
|
||||
When a request comes in, two candidate replicas are chosen randomly. Each replica
|
||||
is sent a control message to fetch its queue length.
|
||||
|
||||
The replica responds with two items: (queue_len, accepted). Only replicas that
|
||||
accept the request are considered; between those, the one with the lower queue
|
||||
length is chosen.
|
||||
|
||||
In the case when neither replica accepts the request (e.g., their queues are full),
|
||||
the procedure is repeated with backoff. This backoff repeats indefinitely until a
|
||||
replica is chosen, so the caller should use timeouts and cancellation to avoid
|
||||
hangs.
|
||||
|
||||
Each request being routed may spawn an independent task that runs the routing
|
||||
procedure concurrently. This task will not necessarily satisfy the request that
|
||||
started it (in order to maintain the FIFO order). The total number of tasks is
|
||||
capped at (2 * num_replicas).
|
||||
"""
|
||||
|
||||
async def choose_replicas(
|
||||
self,
|
||||
candidate_replicas: List[RunningReplica],
|
||||
pending_request: Optional[PendingRequest] = None,
|
||||
) -> List[List[RunningReplica]]:
|
||||
"""One iteration of the power of two choices procedure that chooses
|
||||
(at most) two random available replicas.
|
||||
|
||||
For multiplexing, this will first attempt to choose replicas that have the
|
||||
requested model ID for a configured timeout. If no replicas with the matching
|
||||
model ID are available after that timeout, it will fall back to the regular
|
||||
procedure.
|
||||
"""
|
||||
if (
|
||||
pending_request is not None
|
||||
and pending_request.metadata.multiplexed_model_id
|
||||
):
|
||||
# Get candidates for multiplexed model ID.
|
||||
candidate_replica_ids = self.apply_multiplex_routing(
|
||||
pending_request=pending_request,
|
||||
)
|
||||
else:
|
||||
# Get candidates for locality preference.
|
||||
candidate_replica_ids = self.apply_locality_routing(
|
||||
pending_request=pending_request,
|
||||
)
|
||||
|
||||
if not candidate_replica_ids:
|
||||
return []
|
||||
|
||||
# Optimized selection: use direct randrange for k=2 instead of random.sample.
|
||||
# This is ~1.9x faster for the common case of selecting 2 replicas.
|
||||
#
|
||||
# Correctness proof: We pick i uniformly from [0, n), then j uniformly from
|
||||
# [0, n-1) and shift j up if j >= i. Every ordered pair (i, j) with i != j
|
||||
# has probability: Pr(i,j) = 1/n * 1/(n-1) = 1/(n(n-1))
|
||||
# This matches random.sample(k=2): uniform among all 2-permutations.
|
||||
candidates = tuple(candidate_replica_ids)
|
||||
n = len(candidates)
|
||||
if n == 1:
|
||||
chosen_ids = [candidates[0]]
|
||||
elif n == 2:
|
||||
# Randomize order to ensure fair selection when queue lengths are equal
|
||||
if random.getrandbits(1):
|
||||
chosen_ids = [candidates[0], candidates[1]]
|
||||
else:
|
||||
chosen_ids = [candidates[1], candidates[0]]
|
||||
else:
|
||||
i = random.randrange(n)
|
||||
j = random.randrange(n - 1)
|
||||
if j >= i:
|
||||
j += 1
|
||||
chosen_ids = [candidates[i], candidates[j]]
|
||||
|
||||
return [[self._replicas[chosen_id] for chosen_id in chosen_ids]]
|
||||
@@ -0,0 +1,477 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import pickle
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional, Set, Tuple
|
||||
|
||||
import grpc
|
||||
|
||||
import ray
|
||||
from ray.actor import ActorHandle
|
||||
from ray.serve._private.common import (
|
||||
DeploymentID,
|
||||
ReplicaID,
|
||||
ReplicaQueueLengthInfo,
|
||||
RequestMetadata,
|
||||
RunningReplicaInfo,
|
||||
)
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_REPLICA_GRPC_MAX_MESSAGE_LENGTH,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve._private.replica_result import (
|
||||
ActorReplicaResult,
|
||||
ReplicaResult,
|
||||
gRPCReplicaResult,
|
||||
)
|
||||
from ray.serve._private.request_router.common import PendingRequest
|
||||
from ray.serve._private.serialization import RPCSerializer
|
||||
from ray.serve._private.utils import JavaActorHandleProxy
|
||||
from ray.serve.generated.serve_pb2 import (
|
||||
ASGIRequest,
|
||||
RequestMetadata as RequestMetadataProto,
|
||||
)
|
||||
from ray.serve.generated.serve_pb2_grpc import ASGIServiceStub
|
||||
from ray.util.annotations import PublicAPI
|
||||
from ray.util.tracing.tracing_helper import (
|
||||
_DictPropagator,
|
||||
_is_tracing_enabled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class ReplicaWrapper(ABC):
|
||||
"""This is used to abstract away details of the transport layer
|
||||
when communicating with the replica.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def send_request_java(self, pr: PendingRequest) -> ReplicaResult:
|
||||
"""Send request to Java replica."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def send_request_python(
|
||||
self, pr: PendingRequest, *, with_rejection: bool
|
||||
) -> ReplicaResult:
|
||||
"""Send request to Python replica.
|
||||
|
||||
If sending request with rejection, the replica will yield a
|
||||
system message (ReplicaQueueLengthInfo) before executing the
|
||||
actual request. This can cause it to reject the request. The
|
||||
result will *always* be a generator, so for non-streaming
|
||||
requests it's up to the caller to resolve it to its first (and
|
||||
only) ObjectRef.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ActorReplicaWrapper(ReplicaWrapper):
|
||||
def __init__(self, actor_handle):
|
||||
self._actor_handle = actor_handle
|
||||
|
||||
def send_request_java(self, pr: PendingRequest) -> ActorReplicaResult:
|
||||
"""Send the request to a Java replica.
|
||||
Does not currently support streaming.
|
||||
"""
|
||||
if pr.metadata.is_streaming:
|
||||
raise RuntimeError("Streaming not supported for Java.")
|
||||
|
||||
if len(pr.args) != 1:
|
||||
raise ValueError("Java handle calls only support a single argument.")
|
||||
|
||||
return ActorReplicaResult(
|
||||
self._actor_handle.handle_request.remote(
|
||||
RequestMetadataProto(
|
||||
request_id=pr.metadata.request_id,
|
||||
# Default call method in java is "call," not "__call__" like Python.
|
||||
call_method="call"
|
||||
if pr.metadata.call_method == "__call__"
|
||||
else pr.metadata.call_method,
|
||||
).SerializeToString(),
|
||||
pr.args,
|
||||
),
|
||||
pr.metadata,
|
||||
)
|
||||
|
||||
def send_request_python(
|
||||
self, pr: PendingRequest, *, with_rejection: bool
|
||||
) -> ActorReplicaResult:
|
||||
"""Send the request to a Python replica."""
|
||||
if with_rejection:
|
||||
# Call a separate handler that may reject the request.
|
||||
# This handler is *always* a streaming call and the first message will
|
||||
# be a system message that accepts or rejects.
|
||||
method = self._actor_handle.handle_request_with_rejection.options(
|
||||
num_returns="streaming"
|
||||
)
|
||||
elif pr.metadata.is_streaming:
|
||||
method = self._actor_handle.handle_request_streaming.options(
|
||||
num_returns="streaming"
|
||||
)
|
||||
else:
|
||||
method = self._actor_handle.handle_request
|
||||
|
||||
obj_ref_gen = method.remote(pickle.dumps(pr.metadata), *pr.args, **pr.kwargs)
|
||||
return ActorReplicaResult(
|
||||
obj_ref_gen, pr.metadata, with_rejection=with_rejection
|
||||
)
|
||||
|
||||
|
||||
class gRPCReplicaWrapper(ReplicaWrapper):
|
||||
def __init__(self, stub, actor_id):
|
||||
self._stub = stub
|
||||
self._actor_id = actor_id
|
||||
self._loop = asyncio.get_running_loop()
|
||||
|
||||
def send_request_java(self, pr: PendingRequest):
|
||||
raise RuntimeError("gRPC requests not supported for Java.")
|
||||
|
||||
def send_request_python(
|
||||
self, pr: PendingRequest, *, with_rejection: bool
|
||||
) -> gRPCReplicaResult:
|
||||
"""Send the request to a Python replica."""
|
||||
|
||||
# Get serialization options from request metadata
|
||||
request_serialization = pr.metadata.request_serialization
|
||||
response_serialization = pr.metadata.response_serialization
|
||||
|
||||
# Get cached serializer for this request to avoid per-request instantiation overhead
|
||||
serializer = RPCSerializer.get_cached_serializer(
|
||||
request_serialization, response_serialization
|
||||
)
|
||||
|
||||
# When using gRPC transport, requests go over the network rather than through
|
||||
# Ray's actor RPC. Ray's tracing decorators inject _ray_trace_ctx for actor
|
||||
# calls, but that doesn't apply here. We must manually inject the current
|
||||
# trace context so it propagates to the replica (matching the actor path).
|
||||
if _is_tracing_enabled():
|
||||
pr.kwargs["_ray_trace_ctx"] = _DictPropagator.inject_current_context()
|
||||
|
||||
asgi_request = ASGIRequest(
|
||||
pickled_request_metadata=pickle.dumps(pr.metadata),
|
||||
request_args=serializer.dumps_request(pr.args),
|
||||
request_kwargs=serializer.dumps_request(pr.kwargs),
|
||||
)
|
||||
if with_rejection and pr.metadata.is_streaming:
|
||||
# Call a separate handler that may reject the request.
|
||||
# This handler is *always* a streaming call and the first message will
|
||||
# be a system message that accepts or rejects.
|
||||
call = self._stub.HandleRequestWithRejectionStreaming(asgi_request)
|
||||
elif with_rejection and not pr.metadata.is_streaming:
|
||||
# Call a separate handler that may reject the request.
|
||||
# This handler is *always* a unary call and the first message will
|
||||
# be a system message that accepts or rejects.
|
||||
call = self._stub.HandleRequestWithRejection(asgi_request)
|
||||
elif pr.metadata.is_streaming:
|
||||
call = self._stub.HandleRequestStreaming(asgi_request)
|
||||
else:
|
||||
call = self._stub.HandleRequest(asgi_request)
|
||||
|
||||
return gRPCReplicaResult(
|
||||
call,
|
||||
pr.metadata,
|
||||
self._actor_id,
|
||||
loop=self._loop,
|
||||
with_rejection=with_rejection,
|
||||
)
|
||||
|
||||
|
||||
@PublicAPI(stability="alpha")
|
||||
class RunningReplica:
|
||||
"""Contains info on a running replica.
|
||||
Also defines the interface for a request router to talk to a replica.
|
||||
"""
|
||||
|
||||
def __init__(self, replica_info: RunningReplicaInfo):
|
||||
self._replica_info = replica_info
|
||||
self._multiplexed_model_ids = set(replica_info.multiplexed_model_ids)
|
||||
|
||||
# Fetch and cache the actor handle once per RunningReplica instance.
|
||||
# This avoids the borrower-of-borrower pattern while minimizing GCS lookups.
|
||||
actor_handle = replica_info.get_actor_handle()
|
||||
if replica_info.is_cross_language:
|
||||
self._actor_handle = JavaActorHandleProxy(actor_handle)
|
||||
else:
|
||||
self._actor_handle = actor_handle
|
||||
|
||||
# Lazily created
|
||||
self._channel = None
|
||||
self._stub = None
|
||||
|
||||
# Replica wrappers
|
||||
self._actor_replica_wrapper = ActorReplicaWrapper(self._actor_handle)
|
||||
self._grpc_replica_wrapper = None
|
||||
|
||||
def update_replica_info(self, replica_info: RunningReplicaInfo) -> None:
|
||||
"""Update mutable fields from a new RunningReplicaInfo.
|
||||
|
||||
Called when reusing an existing wrapper in _update_running_replicas.
|
||||
Replicas dynamically load/unload models via record_multiplexed_model_ids,
|
||||
which triggers a broadcast with updated RunningReplicaInfo. Without this
|
||||
update, the router would use stale multiplexed_model_ids and break
|
||||
multiplexed model routing.
|
||||
|
||||
Because we reassign _replica_info, any property that reads from it
|
||||
(including max_ongoing_requests, node_id, availability_zone, etc.)
|
||||
will reflect the new values. Fields that are cached separately
|
||||
(e.g., _actor_handle) are NOT refreshed here because they are tied
|
||||
to the replica's identity and should never change for a live replica.
|
||||
"""
|
||||
self._replica_info = replica_info
|
||||
self._multiplexed_model_ids = set(replica_info.multiplexed_model_ids)
|
||||
|
||||
@property
|
||||
def replica_id(self) -> ReplicaID:
|
||||
"""ID of this replica."""
|
||||
return self._replica_info.replica_id
|
||||
|
||||
@property
|
||||
def actor_id(self) -> ray.ActorID:
|
||||
"""Actor ID of this replica."""
|
||||
return self._actor_handle._actor_id
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
"""Node ID of the node this replica is running on."""
|
||||
return self._replica_info.node_id
|
||||
|
||||
@property
|
||||
def availability_zone(self) -> Optional[str]:
|
||||
"""Availability zone of the node this replica is running on."""
|
||||
return self._replica_info.availability_zone
|
||||
|
||||
@property
|
||||
def multiplexed_model_ids(self) -> Set[str]:
|
||||
"""Set of model IDs on this replica."""
|
||||
return self._multiplexed_model_ids
|
||||
|
||||
@property
|
||||
def routing_stats(self) -> Dict[str, Any]:
|
||||
"""Dictionary of routing stats."""
|
||||
return self._replica_info.routing_stats
|
||||
|
||||
@property
|
||||
def replica_metadata(self) -> Dict[str, Any]:
|
||||
"""Static per-replica metadata captured once when the replica became ready."""
|
||||
# Return a copy so callers can't mutate the RunningReplicaInfo's dict.
|
||||
return self._replica_info.replica_metadata.copy()
|
||||
|
||||
@property
|
||||
def max_ongoing_requests(self) -> int:
|
||||
"""Max concurrent requests that can be sent to this replica."""
|
||||
return self._replica_info.max_ongoing_requests
|
||||
|
||||
@property
|
||||
def is_cross_language(self) -> bool:
|
||||
"""Whether this replica is cross-language (Java)."""
|
||||
return self._replica_info.is_cross_language
|
||||
|
||||
@property
|
||||
def backend_http_endpoint(self) -> Optional[Tuple[str, int]]:
|
||||
"""Return (host, port) of the replica's backend HTTP server."""
|
||||
port = self._replica_info.backend_http_port
|
||||
host = self._replica_info.node_ip
|
||||
if host is not None and port is not None:
|
||||
return (host, port)
|
||||
return None
|
||||
|
||||
@property
|
||||
def stub(self):
|
||||
if self._stub is None:
|
||||
self._channel = grpc.aio.insecure_channel(
|
||||
f"{self._replica_info.node_ip}:{self._replica_info.port}",
|
||||
options=[
|
||||
(
|
||||
"grpc.max_receive_message_length",
|
||||
RAY_SERVE_REPLICA_GRPC_MAX_MESSAGE_LENGTH,
|
||||
)
|
||||
],
|
||||
)
|
||||
self._stub = ASGIServiceStub(self._channel)
|
||||
|
||||
return self._stub
|
||||
|
||||
def _get_replica_wrapper(self, pr: PendingRequest) -> ReplicaWrapper:
|
||||
if self._grpc_replica_wrapper is None:
|
||||
self._grpc_replica_wrapper = gRPCReplicaWrapper(
|
||||
self.stub, self._actor_handle._actor_id
|
||||
)
|
||||
|
||||
return (
|
||||
self._actor_replica_wrapper
|
||||
if pr.metadata._by_reference
|
||||
else self._grpc_replica_wrapper
|
||||
)
|
||||
|
||||
def push_proxy_handle(self, handle: ActorHandle):
|
||||
"""When on proxy, push proxy's self handle to replica"""
|
||||
self._actor_handle.push_proxy_handle.remote(handle)
|
||||
|
||||
async def get_queue_len(self, *, deadline_s: float) -> int:
|
||||
"""Returns current queue len for the replica.
|
||||
`deadline_s` is passed to verify backoff for testing.
|
||||
"""
|
||||
# NOTE(edoakes): the `get_num_ongoing_requests` method name is shared by
|
||||
# the Python and Java replica implementations. If you change it, you need to
|
||||
# change both (or introduce a branch here).
|
||||
obj_ref = self._actor_handle.get_num_ongoing_requests.remote()
|
||||
try:
|
||||
return await obj_ref
|
||||
except asyncio.CancelledError:
|
||||
ray.cancel(obj_ref)
|
||||
raise
|
||||
|
||||
def try_send_request(
|
||||
self, pr: PendingRequest, with_rejection: bool
|
||||
) -> ReplicaResult:
|
||||
"""Try to send the request to this replica. It may be rejected."""
|
||||
wrapper = self._get_replica_wrapper(pr)
|
||||
if self._replica_info.is_cross_language:
|
||||
assert not with_rejection, "Request rejection not supported for Java."
|
||||
return wrapper.send_request_java(pr)
|
||||
|
||||
return wrapper.send_request_python(pr, with_rejection=with_rejection)
|
||||
|
||||
async def reserve_slot(
|
||||
self, request_metadata: RequestMetadata
|
||||
) -> Tuple[str, ReplicaQueueLengthInfo]:
|
||||
"""Reserve a slot on this replica for an upcoming request.
|
||||
|
||||
Returns a unique token that can be used to release the slot later.
|
||||
This is used in the choose_replica/dispatch pattern to track
|
||||
reservations that haven't been dispatched yet.
|
||||
"""
|
||||
if self._replica_info.is_cross_language:
|
||||
raise RuntimeError("Slot reservation not supported for Java.")
|
||||
|
||||
slot_token = str(uuid.uuid4())
|
||||
obj_ref = self._actor_handle.reserve_slot.remote(request_metadata, slot_token)
|
||||
try:
|
||||
accepted, num_ongoing_requests = await obj_ref
|
||||
except asyncio.CancelledError:
|
||||
ray.cancel(obj_ref)
|
||||
self._actor_handle.release_slot.remote(slot_token)
|
||||
raise
|
||||
except Exception:
|
||||
# The actor may have reserved the slot before the reply was lost
|
||||
# (e.g. ActorUnavailableError). `release_slot` is idempotent for unknown
|
||||
# tokens, so this is safe even when the reservation never actually happened.
|
||||
self._actor_handle.release_slot.remote(slot_token)
|
||||
raise
|
||||
|
||||
return slot_token, ReplicaQueueLengthInfo(
|
||||
accepted=accepted,
|
||||
num_ongoing_requests=num_ongoing_requests,
|
||||
)
|
||||
|
||||
async def release_slot(self, slot_token: str) -> int:
|
||||
"""Release a previously reserved slot.
|
||||
|
||||
This should be called if a request is not dispatched after
|
||||
reserving a slot (e.g., due to an error or cancellation).
|
||||
|
||||
Returns the replica's reported num_ongoing_requests after the release.
|
||||
"""
|
||||
if self._replica_info.is_cross_language:
|
||||
raise RuntimeError("Slot reservation not supported for Java.")
|
||||
|
||||
_, num_ongoing_requests = await self._actor_handle.release_slot.remote(
|
||||
slot_token
|
||||
)
|
||||
return num_ongoing_requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplicaSelection:
|
||||
"""Represents a selected replica, holding information for dispatch or coordination.
|
||||
|
||||
This class is returned by the choose_replica() context manager.
|
||||
The slot reservation lifecycle is managed by the context manager.
|
||||
"""
|
||||
|
||||
# Public, user-accessible fields
|
||||
replica_id: str
|
||||
"""Unique identifier for the selected replica."""
|
||||
|
||||
node_ip: str
|
||||
"""IP address of the node running this replica."""
|
||||
|
||||
port: Optional[int]
|
||||
"""Port number for direct communication (if configured)."""
|
||||
|
||||
node_id: str
|
||||
"""Ray node ID where the replica is running."""
|
||||
|
||||
availability_zone: Optional[str]
|
||||
"""Cloud availability zone of the replica's node."""
|
||||
|
||||
replica_metadata: Dict[str, Any]
|
||||
"""Static, immutable per-replica metadata published by the deployment's
|
||||
``record_replica_metadata`` hook (captured once when the replica became
|
||||
ready). Empty dict if the deployment does not define the hook."""
|
||||
|
||||
# Internal fields (not part of public API)
|
||||
_replica: RunningReplica
|
||||
_deployment_id: Optional[DeploymentID]
|
||||
_request_metadata: RequestMetadata
|
||||
_method_name: str
|
||||
# Token to be used for replica reservation;
|
||||
# Can be None when created via the pick-only path
|
||||
_slot_token: Optional[str]
|
||||
_dispatched: bool = field(
|
||||
default=False, init=False
|
||||
) # Tracks if dispatch was called
|
||||
|
||||
# Set by dispatch once the result's done-callback is wired up. Read by
|
||||
# choose_replica's finally to decide whether to fire on_request_completed
|
||||
# manually (only one of the two paths should fire it).
|
||||
_completion_callback_registered: bool = field(default=False, init=False)
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
"""Returns the replica address in host:port format."""
|
||||
if self.port:
|
||||
return f"{self.node_ip}:{self.port}"
|
||||
return self.node_ip
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize public fields to a dictionary."""
|
||||
return {
|
||||
"replica_id": self.replica_id,
|
||||
"node_ip": self.node_ip,
|
||||
"port": self.port,
|
||||
"node_id": self.node_id,
|
||||
"availability_zone": self.availability_zone,
|
||||
"replica_metadata": self.replica_metadata,
|
||||
}
|
||||
|
||||
def _mark_dispatched(self) -> None:
|
||||
"""Internal: Mark this selection as dispatched (slot consumed).
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the selection has already been dispatched.
|
||||
"""
|
||||
if self._dispatched:
|
||||
raise RuntimeError(
|
||||
f"ReplicaSelection for {self.replica_id} has already been dispatched. "
|
||||
"Each selection can only be dispatched once."
|
||||
)
|
||||
self._dispatched = True
|
||||
|
||||
async def _release_slot(self, *, force: bool = False) -> Optional[int]:
|
||||
"""Internal: Release the reserved slot.
|
||||
|
||||
Returns the replica's reported num_ongoing_requests after the release,
|
||||
or None if dispatch already consumed the slot (and ``force`` is False),
|
||||
or None if this selection was created without a reservation.
|
||||
"""
|
||||
if self._slot_token is None:
|
||||
return
|
||||
if self._dispatched and not force:
|
||||
return
|
||||
|
||||
return await self._replica.release_slot(self._slot_token)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,288 @@
|
||||
import threading
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
|
||||
class _ThreadBuckets:
|
||||
"""Per-thread bucket storage for rolling window.
|
||||
|
||||
Each thread gets its own instance to avoid lock contention on the hot path.
|
||||
"""
|
||||
|
||||
# This is a performance optimization to avoid creating a dictionary for the instance.
|
||||
__slots__ = ("buckets", "current_bucket_idx", "last_rotation_time")
|
||||
|
||||
def __init__(self, num_buckets: int):
|
||||
self.buckets = [0.0] * num_buckets
|
||||
self.current_bucket_idx = 0
|
||||
self.last_rotation_time = time.time()
|
||||
|
||||
|
||||
class _ThreadLocalRef(threading.local):
|
||||
"""Thread-local reference to the thread's _ThreadBuckets instance."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# by using threading.local, each thread gets its own instance of _ThreadBuckets.
|
||||
self.data: _ThreadBuckets = None
|
||||
|
||||
|
||||
class _RollingWindowBase:
|
||||
"""Base class for rolling window trackers.
|
||||
|
||||
Provides the shared infrastructure: bucketing, rotation, thread-local
|
||||
storage, and thread registration. Subclasses define how values are
|
||||
recorded into buckets and how buckets are aggregated.
|
||||
|
||||
Uses bucketing for memory efficiency - divides the window into N buckets
|
||||
and rotates them as time passes. This allows efficient tracking of values
|
||||
over a sliding window without storing individual data points.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
window_duration_s: float,
|
||||
num_buckets: int = 60,
|
||||
):
|
||||
if window_duration_s <= 0:
|
||||
raise ValueError(
|
||||
f"window_duration_s must be positive, got {window_duration_s}"
|
||||
)
|
||||
if num_buckets <= 0:
|
||||
raise ValueError(f"num_buckets must be positive, got {num_buckets}")
|
||||
|
||||
self._window_duration_s = window_duration_s
|
||||
self._num_buckets = num_buckets
|
||||
self._bucket_duration_s = window_duration_s / num_buckets
|
||||
|
||||
# Thread-local reference to per-thread bucket data
|
||||
self._local = _ThreadLocalRef()
|
||||
|
||||
# Track all per-thread bucket instances for aggregation
|
||||
self._all_thread_data: List[_ThreadBuckets] = []
|
||||
self._registry_lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def window_duration_s(self) -> float:
|
||||
"""The total duration of the rolling window in seconds."""
|
||||
return self._window_duration_s
|
||||
|
||||
@property
|
||||
def num_buckets(self) -> int:
|
||||
"""The number of buckets in the rolling window."""
|
||||
return self._num_buckets
|
||||
|
||||
@property
|
||||
def bucket_duration_s(self) -> float:
|
||||
"""The duration of each bucket in seconds."""
|
||||
return self._bucket_duration_s
|
||||
|
||||
def _ensure_initialized(self) -> _ThreadBuckets:
|
||||
"""Ensure thread-local storage is initialized for the current thread.
|
||||
|
||||
This is called on every add() but the fast path (already initialized)
|
||||
is just a single attribute check with no locking.
|
||||
|
||||
Returns:
|
||||
The _ThreadBuckets instance for the current thread.
|
||||
"""
|
||||
data = self._local.data
|
||||
if data is not None:
|
||||
return data
|
||||
|
||||
# Slow path: first call from this thread
|
||||
data = _ThreadBuckets(self._num_buckets)
|
||||
self._local.data = data
|
||||
|
||||
# Register for aggregation (only happens once per thread)
|
||||
with self._registry_lock:
|
||||
self._all_thread_data.append(data)
|
||||
|
||||
return data
|
||||
|
||||
def _rotate_buckets_if_needed(self, data: _ThreadBuckets) -> None:
|
||||
"""Rotate buckets for the given thread's storage.
|
||||
|
||||
Advances the current bucket index and clears old buckets as time passes.
|
||||
"""
|
||||
now = time.time()
|
||||
elapsed = now - data.last_rotation_time
|
||||
buckets_to_advance = int(elapsed / self._bucket_duration_s)
|
||||
|
||||
if buckets_to_advance > 0:
|
||||
if buckets_to_advance >= self._num_buckets:
|
||||
# All buckets have expired, reset everything
|
||||
data.buckets = [0.0] * self._num_buckets
|
||||
data.current_bucket_idx = 0
|
||||
else:
|
||||
# Clear old buckets as we advance
|
||||
for _ in range(buckets_to_advance):
|
||||
data.current_bucket_idx = (
|
||||
data.current_bucket_idx + 1
|
||||
) % self._num_buckets
|
||||
data.buckets[data.current_bucket_idx] = 0.0
|
||||
|
||||
data.last_rotation_time = now
|
||||
|
||||
def get_num_registered_threads(self) -> int:
|
||||
"""Get the number of threads that have called add().
|
||||
|
||||
Useful for debugging and testing.
|
||||
|
||||
Returns:
|
||||
The number of threads registered with this accumulator.
|
||||
"""
|
||||
with self._registry_lock:
|
||||
return len(self._all_thread_data)
|
||||
|
||||
|
||||
class RollingWindowAccumulator(_RollingWindowBase):
|
||||
"""Tracks cumulative values over a rolling time window.
|
||||
|
||||
Uses thread-local storage for lock-free writes on the hot path (add()).
|
||||
Only get_total() requires synchronization to aggregate across threads.
|
||||
|
||||
Example:
|
||||
# Create a 10-minute rolling window with 60 buckets (10s each)
|
||||
accumulator = RollingWindowAccumulator(
|
||||
window_duration_s=600.0,
|
||||
num_buckets=60,
|
||||
)
|
||||
|
||||
# Add values (lock-free, safe from multiple threads)
|
||||
accumulator.add(100.0)
|
||||
accumulator.add(50.0)
|
||||
|
||||
# Get total (aggregates across all threads)
|
||||
total = accumulator.get_total()
|
||||
|
||||
Thread Safety:
|
||||
- add() is lock-free after the first call from each thread
|
||||
- get_total() acquires a lock to aggregate across threads
|
||||
- Safe to call from multiple threads concurrently
|
||||
"""
|
||||
|
||||
def add(self, value: float) -> None:
|
||||
"""Add a value to the current bucket.
|
||||
|
||||
This operation is lock-free for the calling thread after the first call.
|
||||
Safe to call from multiple threads concurrently.
|
||||
|
||||
Args:
|
||||
value: The value to add to the accumulator.
|
||||
"""
|
||||
# Fast path: just check if initialized (no lock)
|
||||
data = self._ensure_initialized()
|
||||
|
||||
# Lock-free: only touches thread-local data
|
||||
self._rotate_buckets_if_needed(data)
|
||||
data.buckets[data.current_bucket_idx] += value
|
||||
|
||||
def get_total(self) -> float:
|
||||
"""Get total value across all buckets in the window.
|
||||
|
||||
This aggregates values from all threads that have called add().
|
||||
Expired buckets (older than window_duration_s) are not included.
|
||||
|
||||
Note: We are accepting some inaccuracy in the total value to avoid the overhead of a lock.
|
||||
This is acceptable because we are only using this for utilization metrics, which are not
|
||||
critical for the overall system. Given that the default window duration is 600s and the
|
||||
default report interval is 10s, the inaccuracy is less than 0.16%.
|
||||
|
||||
Returns:
|
||||
The sum of all non-expired values in the rolling window.
|
||||
"""
|
||||
total = 0.0
|
||||
now = time.time()
|
||||
|
||||
with self._registry_lock:
|
||||
for data in self._all_thread_data:
|
||||
# Calculate which buckets are still valid for this thread's data
|
||||
elapsed = now - data.last_rotation_time
|
||||
buckets_expired = int(elapsed / self._bucket_duration_s)
|
||||
|
||||
if buckets_expired >= self._num_buckets:
|
||||
# All buckets have expired for this thread
|
||||
continue
|
||||
|
||||
# Sum buckets that haven't expired
|
||||
# Buckets are arranged in a circular buffer, with current_bucket_idx
|
||||
# being the most recent. We need to skip buckets that have expired.
|
||||
for i in range(self._num_buckets - buckets_expired):
|
||||
# Go backwards from current bucket
|
||||
idx = (data.current_bucket_idx - i) % self._num_buckets
|
||||
total += data.buckets[idx]
|
||||
|
||||
return total
|
||||
|
||||
|
||||
class RollingWindowMax(_RollingWindowBase):
|
||||
"""Tracks the maximum value over a rolling time window.
|
||||
|
||||
Uses the same bucketed rolling window approach as RollingWindowAccumulator,
|
||||
but each bucket stores the maximum observed value instead of a cumulative
|
||||
sum. Querying returns the max across all non-expired buckets.
|
||||
|
||||
Example:
|
||||
# Create a 30-second rolling window with 6 buckets (5s each)
|
||||
tracker = RollingWindowMax(
|
||||
window_duration_s=30.0,
|
||||
num_buckets=6,
|
||||
)
|
||||
|
||||
# Record values (lock-free, safe from multiple threads)
|
||||
tracker.add(100.0)
|
||||
tracker.add(500.0)
|
||||
tracker.add(50.0)
|
||||
|
||||
# Get max in the window (aggregates across all threads)
|
||||
maximum = tracker.get_max() # returns 500.0
|
||||
|
||||
Thread Safety:
|
||||
- add() is lock-free after the first call from each thread
|
||||
- get_max() acquires a lock to aggregate across threads
|
||||
- Safe to call from multiple threads concurrently
|
||||
"""
|
||||
|
||||
def add(self, value: float) -> None:
|
||||
"""Record a value, updating the current bucket's max if exceeded.
|
||||
|
||||
This operation is lock-free for the calling thread after the first call.
|
||||
Safe to call from multiple threads concurrently.
|
||||
|
||||
Args:
|
||||
value: The value to record.
|
||||
"""
|
||||
data = self._ensure_initialized()
|
||||
|
||||
self._rotate_buckets_if_needed(data)
|
||||
if value > data.buckets[data.current_bucket_idx]:
|
||||
data.buckets[data.current_bucket_idx] = value
|
||||
|
||||
def get_max(self) -> float:
|
||||
"""Get max value across all non-expired buckets in the window.
|
||||
|
||||
This aggregates values from all threads that have called add().
|
||||
Expired buckets (older than window_duration_s) are not included.
|
||||
|
||||
Returns:
|
||||
The maximum value observed in the rolling window, or 0.0
|
||||
if no values have been recorded.
|
||||
"""
|
||||
result = 0.0
|
||||
now = time.time()
|
||||
|
||||
with self._registry_lock:
|
||||
for data in self._all_thread_data:
|
||||
elapsed = now - data.last_rotation_time
|
||||
buckets_expired = int(elapsed / self._bucket_duration_s)
|
||||
|
||||
if buckets_expired >= self._num_buckets:
|
||||
continue
|
||||
|
||||
for i in range(self._num_buckets - buckets_expired):
|
||||
idx = (data.current_bucket_idx - i) % self._num_buckets
|
||||
if data.buckets[idx] > result:
|
||||
result = data.buckets[idx]
|
||||
|
||||
return result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
import logging
|
||||
import pickle
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ray import cloudpickle
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
|
||||
try:
|
||||
import orjson
|
||||
except ImportError:
|
||||
orjson = None
|
||||
try:
|
||||
import ormsgpack
|
||||
except ImportError:
|
||||
ormsgpack = None
|
||||
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class SerializationMethod:
|
||||
"""Available serialization methods for RPC communication."""
|
||||
|
||||
CLOUDPICKLE = "cloudpickle"
|
||||
PICKLE = "pickle"
|
||||
MSGPACK = "msgpack"
|
||||
ORJSON = "orjson"
|
||||
NOOP = "noop"
|
||||
|
||||
|
||||
# Global cache for serializer instances to avoid per-request instantiation overhead
|
||||
_serializer_cache: Dict[Tuple[str, str], "RPCSerializer"] = {}
|
||||
|
||||
|
||||
class RPCSerializer:
|
||||
"""Serializer for RPC communication with configurable serialization methods."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request_method: str = SerializationMethod.CLOUDPICKLE,
|
||||
response_method: str = SerializationMethod.CLOUDPICKLE,
|
||||
):
|
||||
self.request_method = request_method.lower()
|
||||
self.response_method = response_method.lower()
|
||||
self._validate_methods()
|
||||
self._setup_serializers()
|
||||
|
||||
@classmethod
|
||||
def get_cached_serializer(
|
||||
cls,
|
||||
request_method: str = SerializationMethod.CLOUDPICKLE,
|
||||
response_method: str = SerializationMethod.CLOUDPICKLE,
|
||||
) -> "RPCSerializer":
|
||||
"""Get a cached serializer instance to avoid per-request instantiation overhead.
|
||||
|
||||
This method maintains a cache of serializer instances based on
|
||||
(request_method, response_method) pairs, significantly reducing overhead
|
||||
in high-throughput systems.
|
||||
"""
|
||||
# Normalize method names
|
||||
req_method = request_method.lower()
|
||||
resp_method = response_method.lower()
|
||||
cache_key = (req_method, resp_method)
|
||||
|
||||
if cache_key not in _serializer_cache:
|
||||
_serializer_cache[cache_key] = cls(req_method, resp_method)
|
||||
|
||||
return _serializer_cache[cache_key]
|
||||
|
||||
def _validate_methods(self):
|
||||
"""Validate that the serialization methods are supported."""
|
||||
valid_methods = {
|
||||
SerializationMethod.CLOUDPICKLE,
|
||||
SerializationMethod.PICKLE,
|
||||
SerializationMethod.MSGPACK,
|
||||
SerializationMethod.ORJSON,
|
||||
SerializationMethod.NOOP,
|
||||
}
|
||||
|
||||
if self.request_method not in valid_methods:
|
||||
raise ValueError(
|
||||
f"Unsupported request serialization method: {self.request_method}. "
|
||||
f"Valid options: {valid_methods}"
|
||||
)
|
||||
|
||||
if self.response_method not in valid_methods:
|
||||
raise ValueError(
|
||||
f"Unsupported response serialization method: {self.response_method}. "
|
||||
f"Valid options: {valid_methods}"
|
||||
)
|
||||
|
||||
def _setup_serializers(self):
|
||||
"""Setup the serialization functions based on the selected methods."""
|
||||
self._request_dumps, self._request_loads = self._get_serializer_funcs(
|
||||
self.request_method
|
||||
)
|
||||
self._response_dumps, self._response_loads = self._get_serializer_funcs(
|
||||
self.response_method
|
||||
)
|
||||
|
||||
def _get_serializer_funcs(self, method: str) -> Tuple[Any, Any]:
|
||||
"""Get dumps and loads functions for a given serialization method."""
|
||||
if method == SerializationMethod.CLOUDPICKLE:
|
||||
return cloudpickle.dumps, cloudpickle.loads
|
||||
elif method == SerializationMethod.PICKLE:
|
||||
return self._get_pickle_funcs()
|
||||
elif method == SerializationMethod.MSGPACK:
|
||||
return self._get_msgpack_funcs()
|
||||
elif method == SerializationMethod.ORJSON:
|
||||
return self._get_orjson_funcs()
|
||||
elif method == SerializationMethod.NOOP:
|
||||
return self._get_noop_funcs()
|
||||
|
||||
def _get_noop_funcs(self) -> Tuple[Any, Any]:
|
||||
"""Get no-op serialization functions for binary data."""
|
||||
|
||||
def _noop_dumps(obj: Any) -> bytes:
|
||||
if not isinstance(obj, bytes):
|
||||
raise TypeError(
|
||||
f"a bytes-like object is required, got {type(obj).__name__}. "
|
||||
"Use a different serialization method for non-binary data."
|
||||
)
|
||||
return obj
|
||||
|
||||
def _noop_loads(data: bytes) -> Any:
|
||||
if not isinstance(data, bytes):
|
||||
raise TypeError(
|
||||
f"a bytes-like object is required, got {type(data).__name__}. "
|
||||
"Use a different serialization method for non-binary data."
|
||||
)
|
||||
return data
|
||||
|
||||
return _noop_dumps, _noop_loads
|
||||
|
||||
def _get_pickle_funcs(self) -> Tuple[Any, Any]:
|
||||
"""Get pickle serialization functions with highest protocol."""
|
||||
|
||||
def _pickle_dumps(obj: Any) -> bytes:
|
||||
return pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
|
||||
def _pickle_loads(data: bytes) -> Any:
|
||||
return pickle.loads(data)
|
||||
|
||||
return _pickle_dumps, _pickle_loads
|
||||
|
||||
def _get_msgpack_funcs(self) -> Tuple[Any, Any]:
|
||||
"""Get msgpack serialization functions."""
|
||||
|
||||
if ormsgpack is None:
|
||||
raise ImportError(
|
||||
"ormsgpack is not installed. Please install it with `pip install ormsgpack`."
|
||||
)
|
||||
|
||||
# Configure ormsgpack with appropriate options
|
||||
def _msgpack_dumps(obj: Any) -> bytes:
|
||||
return ormsgpack.packb(obj)
|
||||
|
||||
def _msgpack_loads(data: bytes) -> Any:
|
||||
return ormsgpack.unpackb(data)
|
||||
|
||||
return _msgpack_dumps, _msgpack_loads
|
||||
|
||||
def _get_orjson_funcs(self) -> Tuple[Any, Any]:
|
||||
"""Get orjson serialization functions."""
|
||||
|
||||
if orjson is None:
|
||||
raise ImportError(
|
||||
"orjson is not installed. Please install it with `pip install orjson`."
|
||||
)
|
||||
|
||||
# orjson only supports JSON-serializable types
|
||||
def _orjson_dumps(obj: Any) -> bytes:
|
||||
try:
|
||||
return orjson.dumps(obj)
|
||||
except TypeError as e:
|
||||
raise TypeError(
|
||||
f"orjson serialization failed: {e}. "
|
||||
"Only JSON-serializable types are supported with orjson. "
|
||||
"Consider using 'cloudpickle' or 'pickle' for complex objects."
|
||||
)
|
||||
|
||||
def _orjson_loads(data: bytes) -> Any:
|
||||
return orjson.loads(data)
|
||||
|
||||
return _orjson_dumps, _orjson_loads
|
||||
|
||||
def dumps_request(self, obj: Any) -> bytes:
|
||||
"""Serialize a request object to bytes."""
|
||||
return self._request_dumps(obj)
|
||||
|
||||
def loads_request(self, data: bytes) -> Any:
|
||||
"""Deserialize bytes to a request object."""
|
||||
return self._request_loads(data)
|
||||
|
||||
def dumps_response(self, obj: Any) -> bytes:
|
||||
"""Serialize a response object to bytes."""
|
||||
return self._response_dumps(obj)
|
||||
|
||||
def loads_response(self, data: bytes) -> Any:
|
||||
"""Deserialize bytes to a response object."""
|
||||
return self._response_loads(data)
|
||||
|
||||
|
||||
def clear_serializer_cache():
|
||||
"""Clear the cached serializer instances. Useful for testing or memory management."""
|
||||
global _serializer_cache
|
||||
_serializer_cache.clear()
|
||||
@@ -0,0 +1,116 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import ray
|
||||
import ray.serve._private.constants as serve_constants
|
||||
from ray._raylet import GcsClient
|
||||
from ray.serve._private.storage.kv_store_base import KVStoreBase
|
||||
|
||||
logger = logging.getLogger(serve_constants.SERVE_LOGGER_NAME)
|
||||
|
||||
SERVE_INTERNAL_KV_NAMESPACE = b"serve"
|
||||
|
||||
|
||||
def get_storage_key(namespace: str, storage_key: str) -> str:
|
||||
"""In case we need to access kvstore"""
|
||||
return "{ns}-{key}".format(ns=namespace, key=storage_key)
|
||||
|
||||
|
||||
class KVStoreError(Exception):
|
||||
def __init__(self, rpc_code):
|
||||
self.rpc_code = rpc_code
|
||||
|
||||
|
||||
class RayInternalKVStore(KVStoreBase):
|
||||
"""Wraps ray's internal_kv with a namespace to avoid collisions.
|
||||
|
||||
Supports string keys and bytes values, caller must handle serialization.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
namespace: Optional[str] = None,
|
||||
gcs_client: Optional[GcsClient] = None,
|
||||
):
|
||||
if namespace is not None and not isinstance(namespace, str):
|
||||
raise TypeError("namespace must a string, got: {}.".format(type(namespace)))
|
||||
if gcs_client is not None:
|
||||
self.gcs_client = gcs_client
|
||||
else:
|
||||
self.gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
|
||||
self.timeout = serve_constants.RAY_SERVE_KV_TIMEOUT_S
|
||||
self.namespace = namespace or ""
|
||||
|
||||
def get_storage_key(self, key: str) -> str:
|
||||
return "{ns}-{key}".format(ns=self.namespace, key=key)
|
||||
|
||||
def put(self, key: str, val: bytes) -> bool:
|
||||
"""Put the key-value pair into the store.
|
||||
|
||||
Args:
|
||||
key: The key to store.
|
||||
val: The value to store.
|
||||
|
||||
Returns:
|
||||
True if the value was newly inserted, False if it overwrote an
|
||||
existing value.
|
||||
"""
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("key must be a string, got: {}.".format(type(key)))
|
||||
if not isinstance(val, bytes):
|
||||
raise TypeError("val must be bytes, got: {}.".format(type(val)))
|
||||
|
||||
try:
|
||||
return self.gcs_client.internal_kv_put(
|
||||
self.get_storage_key(key).encode(),
|
||||
val,
|
||||
overwrite=True,
|
||||
namespace=SERVE_INTERNAL_KV_NAMESPACE,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except ray.exceptions.RpcError as e:
|
||||
raise KVStoreError(e.rpc_code)
|
||||
|
||||
def get(self, key: str) -> Optional[bytes]:
|
||||
"""Get the value associated with the given key from the store.
|
||||
|
||||
Args:
|
||||
key: The key to retrieve.
|
||||
|
||||
Returns:
|
||||
Optional[bytes]: The bytes value. If the key wasn't found, returns None.
|
||||
"""
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("key must be a string, got: {}.".format(type(key)))
|
||||
|
||||
try:
|
||||
return self.gcs_client.internal_kv_get(
|
||||
self.get_storage_key(key).encode(),
|
||||
namespace=SERVE_INTERNAL_KV_NAMESPACE,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except ray.exceptions.RpcError as e:
|
||||
raise KVStoreError(e.rpc_code)
|
||||
|
||||
def delete(self, key: str):
|
||||
"""Delete the value associated with the given key from the store.
|
||||
|
||||
Args:
|
||||
key: The key to delete.
|
||||
|
||||
Returns:
|
||||
The number of keys deleted (0 if the key did not exist).
|
||||
"""
|
||||
|
||||
if not isinstance(key, str):
|
||||
raise TypeError("key must be a string, got: {}.".format(type(key)))
|
||||
|
||||
try:
|
||||
return self.gcs_client.internal_kv_del(
|
||||
self.get_storage_key(key).encode(),
|
||||
False,
|
||||
namespace=SERVE_INTERNAL_KV_NAMESPACE,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except ray.exceptions.RpcError as e:
|
||||
raise KVStoreError(e.rpc_code)
|
||||
@@ -0,0 +1,60 @@
|
||||
import abc
|
||||
from abc import abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
class KVStoreBase(metaclass=abc.ABCMeta):
|
||||
"""Abstract class for KVStore defining APIs needed for ray serve
|
||||
use cases, currently (8/6/2021) controller state checkpointing.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_storage_key(self, key: str) -> str:
|
||||
"""Get internal key for storage.
|
||||
|
||||
Args:
|
||||
key: User provided key
|
||||
|
||||
Returns:
|
||||
storage_key: Formatted key for storage, usually by
|
||||
prepending namespace.
|
||||
"""
|
||||
raise NotImplementedError("get_storage_key() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def put(self, key: str, val: bytes) -> bool:
|
||||
"""Put object into kv store, bytes only.
|
||||
|
||||
Args:
|
||||
key: Key for object to be stored.
|
||||
val: Byte value of object.
|
||||
|
||||
Returns:
|
||||
True if the value was newly inserted, False if it overwrote an
|
||||
existing value.
|
||||
"""
|
||||
raise NotImplementedError("put() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str) -> Optional[bytes]:
|
||||
"""Get object from storage.
|
||||
|
||||
Args:
|
||||
key: Key for object to be retrieved.
|
||||
|
||||
Returns:
|
||||
val: Byte value of object from storage.
|
||||
"""
|
||||
raise NotImplementedError("get() has to be implemented")
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> None:
|
||||
"""Delete an object.
|
||||
|
||||
Args:
|
||||
key: Key for object to be deleted.
|
||||
"""
|
||||
raise NotImplementedError("delete() has to be implemented")
|
||||
@@ -0,0 +1,12 @@
|
||||
from abc import ABC
|
||||
|
||||
|
||||
class TaskConsumerWrapper(ABC):
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def initialize_callable(self, consumer_concurrency: int):
|
||||
pass
|
||||
|
||||
def __del__(self):
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,241 @@
|
||||
# This code is adapted from:
|
||||
# https://github.com/elastic/apm-agent-python/blob/f570e8c2b68a8714628acac815aebcc3518b44c7/elasticapm/contrib/starlette/__init__.py
|
||||
#
|
||||
# BSD 3-Clause License
|
||||
#
|
||||
# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details
|
||||
# Copyright (c) 2019, Elasticsearch BV
|
||||
# All rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms, with or without
|
||||
# modification, are permitted provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice, this
|
||||
# list of conditions and the following disclaimer.
|
||||
#
|
||||
# * Redistributions in binary form must reproduce the above copyright notice,
|
||||
# this list of conditions and the following disclaimer in the documentation
|
||||
# and/or other materials provided with the distribution.
|
||||
#
|
||||
# * Neither the name of the copyright holder nor the names of its
|
||||
# contributors may be used to endorse or promote products derived from
|
||||
# this software without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from starlette.routing import Match, Mount, Route
|
||||
from starlette.types import ASGIApp, Scope
|
||||
|
||||
try:
|
||||
# `_IncludedRouter` only exists on FastAPI >= 0.137, where routes registered
|
||||
# via `include_router` are nested under it instead of being flattened into
|
||||
# the parent's `routes` list. It is `None` on older versions.
|
||||
from fastapi.routing import _IncludedRouter
|
||||
except ImportError: # FastAPI < 0.137
|
||||
_IncludedRouter = None
|
||||
|
||||
|
||||
def _included_router_prefix(route: "Route") -> str:
|
||||
"""Return the URL prefix an ``_IncludedRouter`` node applies to its routes.
|
||||
|
||||
Accesses ``_IncludedRouter``'s private attributes directly (rather than
|
||||
defensively) so that a structural change in a future FastAPI version raises
|
||||
loudly instead of silently returning a wrong route name. Both callers are
|
||||
wrapped in ``try``/``except`` that fall back to the route prefix and log the
|
||||
error, so a failure here degrades gracefully while staying visible.
|
||||
"""
|
||||
return route.include_context.prefix
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutePattern:
|
||||
"""Represents a route pattern with optional HTTP method restrictions.
|
||||
|
||||
Attributes:
|
||||
methods: List of HTTP methods (e.g., ["GET", "POST"]), or None if the route
|
||||
accepts all methods (e.g., WebSocket routes, ASGI apps).
|
||||
path: The route path pattern (e.g., "/", "/users/{user_id}").
|
||||
"""
|
||||
|
||||
methods: Optional[List[str]]
|
||||
path: str
|
||||
|
||||
|
||||
def _get_route_name(
|
||||
scope: Scope, routes: List[Route], *, route_name: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
for route in routes:
|
||||
if _IncludedRouter is not None and isinstance(route, _IncludedRouter):
|
||||
# FastAPI >= 0.137: routes added via `include_router` live on
|
||||
# `route.original_router.routes` with paths relative to the include
|
||||
# prefix. Unlike `Mount`, `_IncludedRouter.matches()` doesn't return
|
||||
# a prefix-stripped child scope, so we strip it ourselves and prepend
|
||||
# the prefix back to the resolved child name. This mirrors the Mount
|
||||
# handling below and works for both HTTP and WebSocket routes.
|
||||
prefix = _included_router_prefix(route)
|
||||
path = scope.get("path", "")
|
||||
if prefix:
|
||||
if not path.startswith(prefix):
|
||||
continue
|
||||
child_scope = {**scope, "path": path[len(prefix) :]}
|
||||
else:
|
||||
child_scope = scope
|
||||
child_route_name = _get_route_name(
|
||||
child_scope, route.original_router.routes
|
||||
)
|
||||
if child_route_name is not None:
|
||||
return prefix + child_route_name
|
||||
continue
|
||||
|
||||
match, child_scope = route.matches(scope)
|
||||
if match == Match.FULL:
|
||||
route_name = route.path
|
||||
child_scope = {**scope, **child_scope}
|
||||
if isinstance(route, Mount) and route.routes:
|
||||
child_route_name = _get_route_name(
|
||||
child_scope, route.routes, route_name=route_name
|
||||
)
|
||||
if child_route_name is None:
|
||||
route_name = None
|
||||
else:
|
||||
route_name += child_route_name
|
||||
return route_name
|
||||
elif match == Match.PARTIAL and route_name is None:
|
||||
route_name = route.path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_asgi_route_name(app: ASGIApp, scope: Scope) -> Optional[str]:
|
||||
"""Gets route name for given request taking mounts into account."""
|
||||
|
||||
routes = app.routes
|
||||
route_name = _get_route_name(scope, routes)
|
||||
|
||||
# Starlette magically redirects requests if the path matches a route name
|
||||
# with a trailing slash appended or removed. To not spam the transaction
|
||||
# names list, we do the same here and put these redirects all in the
|
||||
# same "redirect trailing slashes" transaction name.
|
||||
if not route_name and app.router.redirect_slashes and scope["path"] != "/":
|
||||
redirect_scope = dict(scope)
|
||||
if scope["path"].endswith("/"):
|
||||
redirect_scope["path"] = scope["path"][:-1]
|
||||
trim = True
|
||||
else:
|
||||
redirect_scope["path"] = scope["path"] + "/"
|
||||
trim = False
|
||||
|
||||
route_name = _get_route_name(redirect_scope, routes)
|
||||
if route_name is not None:
|
||||
route_name = route_name + "/" if trim else route_name[:-1]
|
||||
|
||||
if route_name:
|
||||
root_path = scope.get("root_path", "")
|
||||
if root_path:
|
||||
route_name = root_path.rstrip("/") + "/" + route_name.lstrip("/")
|
||||
|
||||
return route_name
|
||||
|
||||
|
||||
def extract_route_patterns(app: ASGIApp) -> List[RoutePattern]:
|
||||
"""Extracts all route patterns from an ASGI app.
|
||||
|
||||
This function recursively traverses the app's routes (including mounted apps)
|
||||
and returns a list of all route patterns. This is used to communicate available
|
||||
routes from build time to proxies for accurate metrics tagging.
|
||||
|
||||
Args:
|
||||
app: The ASGI application (typically FastAPI or Starlette)
|
||||
|
||||
Returns:
|
||||
List of RoutePattern objects. Examples:
|
||||
- RoutePattern(methods=["GET", "POST"], path="/"): GET and POST to root
|
||||
- RoutePattern(methods=["GET"], path="/users/{id}"): GET to users endpoint
|
||||
- RoutePattern(methods=None, path="/websocket"): No method restrictions
|
||||
"""
|
||||
# Use a dict to store path -> set of methods mapping
|
||||
# This allows us to track which methods apply to each path
|
||||
# Use None as a sentinel value to indicate "no method restrictions"
|
||||
path_methods: Dict[str, Optional[Set[str]]] = {}
|
||||
|
||||
def _extract_from_routes(routes: List[Route], prefix: str = "") -> None:
|
||||
for route in routes:
|
||||
if _IncludedRouter is not None and isinstance(route, _IncludedRouter):
|
||||
# FastAPI >= 0.137: recurse into routes registered via
|
||||
# `include_router`, applying the include prefix (see
|
||||
# `_get_route_name` for details).
|
||||
_extract_from_routes(
|
||||
route.original_router.routes,
|
||||
prefix + _included_router_prefix(route),
|
||||
)
|
||||
continue
|
||||
|
||||
route_path = prefix + route.path
|
||||
|
||||
if isinstance(route, Mount):
|
||||
# Recursively extract patterns from mounted apps
|
||||
if hasattr(route, "routes") and route.routes:
|
||||
_extract_from_routes(route.routes, route_path)
|
||||
else:
|
||||
# Mount without sub-routes - no method restrictions
|
||||
if route_path not in path_methods:
|
||||
path_methods[route_path] = None
|
||||
else:
|
||||
# Regular route - extract methods if available
|
||||
if hasattr(route, "methods") and route.methods:
|
||||
# Route has specific methods
|
||||
if route_path not in path_methods:
|
||||
path_methods[route_path] = set()
|
||||
# Only add methods if we haven't already marked this path as "all methods"
|
||||
if path_methods[route_path] is not None:
|
||||
path_methods[route_path].update(route.methods)
|
||||
else:
|
||||
# Route has no method restrictions (accepts all methods)
|
||||
# Mark this path as accepting all methods (None)
|
||||
path_methods[route_path] = None
|
||||
|
||||
try:
|
||||
if hasattr(app, "routes"):
|
||||
_extract_from_routes(app.routes)
|
||||
|
||||
# Handle root_path if present
|
||||
if hasattr(app, "root_path") and app.root_path:
|
||||
root_path = app.root_path.rstrip("/")
|
||||
adjusted_path_methods = {}
|
||||
for path, methods in path_methods.items():
|
||||
adjusted_path = (
|
||||
root_path + "/" + path.lstrip("/")
|
||||
if path != "/"
|
||||
else root_path + path
|
||||
)
|
||||
adjusted_path_methods[adjusted_path] = methods
|
||||
path_methods = adjusted_path_methods
|
||||
except Exception:
|
||||
# If extraction fails for any reason, return empty list
|
||||
# This shouldn't break the system
|
||||
return []
|
||||
|
||||
# Convert path_methods dict to list of RoutePattern objects
|
||||
patterns: List[RoutePattern] = []
|
||||
for path, methods in path_methods.items():
|
||||
if methods is None:
|
||||
# No method restrictions
|
||||
patterns.append(RoutePattern(methods=None, path=path))
|
||||
else:
|
||||
# Convert set to sorted list for consistent ordering
|
||||
methods_list = sorted(methods)
|
||||
patterns.append(RoutePattern(methods=methods_list, path=path))
|
||||
|
||||
# Sort by path for consistent ordering
|
||||
return sorted(patterns, key=lambda x: x.path)
|
||||
@@ -0,0 +1,509 @@
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
from contextvars import ContextVar, Token
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ray._common.utils import import_attr
|
||||
from ray.serve._private.constants import (
|
||||
DEFAULT_TRACING_EXPORTER_IMPORT_PATH,
|
||||
RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH,
|
||||
RAY_SERVE_TRACING_SAMPLING_RATIO,
|
||||
)
|
||||
|
||||
try:
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.context import attach, detach, get_current
|
||||
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
|
||||
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatio
|
||||
from opentelemetry.semconv.trace import SpanAttributes
|
||||
from opentelemetry.trace import SpanKind
|
||||
from opentelemetry.trace.propagation import set_span_in_context
|
||||
from opentelemetry.trace.propagation.tracecontext import (
|
||||
TraceContextTextMapPropagator,
|
||||
)
|
||||
from opentelemetry.trace.status import Status, StatusCode
|
||||
|
||||
except ImportError:
|
||||
SpanProcessor = None
|
||||
ConsoleSpanExporter = None
|
||||
SimpleSpanProcessor = None
|
||||
trace = None
|
||||
SpanKind = None
|
||||
TracerProvider = None
|
||||
TraceIdRatioBased = None
|
||||
Status = None
|
||||
StatusCode = None
|
||||
set_span_in_context = None
|
||||
TraceContextTextMapPropagator = None
|
||||
get_current = None
|
||||
attach = None
|
||||
detach = None
|
||||
SpanAttributes = None
|
||||
ParentBasedTraceIdRatio = None
|
||||
|
||||
|
||||
TRACE_STACK: ContextVar[List[Any]] = ContextVar(
|
||||
"trace_stack"
|
||||
) # Create tracer once at module level
|
||||
|
||||
_tracer = None
|
||||
_tracer_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_tracer():
|
||||
global _tracer
|
||||
if _tracer is None:
|
||||
with _tracer_lock:
|
||||
if _tracer is None:
|
||||
_tracer = trace.get_tracer(__name__)
|
||||
return _tracer
|
||||
|
||||
|
||||
# Default tracing exporter needs to map to DEFAULT_TRACING_EXPORTER_IMPORT_PATH
|
||||
# defined in "python/ray/serve/_private/constants.py"
|
||||
def default_tracing_exporter(tracing_file_name):
|
||||
from ray.serve._private.logging_utils import get_serve_logs_dir
|
||||
|
||||
serve_logs_dir = get_serve_logs_dir()
|
||||
spans_dir = os.path.join(serve_logs_dir, "spans")
|
||||
os.makedirs(spans_dir, exist_ok=True)
|
||||
spans_file = os.path.join(spans_dir, tracing_file_name)
|
||||
|
||||
out_file = open(spans_file, "a")
|
||||
|
||||
class FileConsoleSpanExporter(ConsoleSpanExporter):
|
||||
def shutdown(self):
|
||||
if not out_file.closed:
|
||||
out_file.flush()
|
||||
out_file.close()
|
||||
|
||||
return [SimpleSpanProcessor(FileConsoleSpanExporter(out=out_file))]
|
||||
|
||||
|
||||
class TraceContextManager:
|
||||
def __init__(
|
||||
self, trace_name, span_kind=None, trace_context: Optional[Dict[str, str]] = None
|
||||
):
|
||||
self.span = None
|
||||
self.trace_name = trace_name
|
||||
self.span_kind = span_kind
|
||||
self.trace_context = trace_context
|
||||
|
||||
self.is_tracing_enabled = is_tracing_enabled()
|
||||
|
||||
def __enter__(self):
|
||||
if self.is_tracing_enabled:
|
||||
self.span_kind = self.span_kind or SpanKind.SERVER
|
||||
|
||||
tracer = get_tracer()
|
||||
ctx = self.trace_context if self.trace_context else get_trace_context()
|
||||
|
||||
self.span = tracer.start_span(
|
||||
self.trace_name,
|
||||
kind=self.span_kind,
|
||||
context=ctx,
|
||||
)
|
||||
if not self.span.get_span_context().trace_flags.sampled:
|
||||
return self
|
||||
new_ctx = set_span_in_context(self.span)
|
||||
set_trace_context(new_ctx)
|
||||
_append_trace_stack(self.span)
|
||||
set_span_name(self.trace_name)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if self.is_tracing_enabled and self.span is not None:
|
||||
# if exc_type is not None, we have made a explicit decision
|
||||
# to not set the span status to error. This is because
|
||||
# errors are spans internal to Ray Serve and should not
|
||||
# be reported as errors in the trace. They cause noise
|
||||
# in the trace and are not meaningful to the user.
|
||||
self.span.end()
|
||||
_pop_trace_stack()
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class BatchTraceContextManager:
|
||||
"""Attach/detach a tracing context around a block to scope the span of a batch."""
|
||||
|
||||
def __init__(self, trace_context: Optional[object]):
|
||||
self._enabled = is_tracing_enabled() and trace_context is not None
|
||||
self._trace_context = trace_context
|
||||
self._token: Optional[Token] = None
|
||||
|
||||
def __enter__(self):
|
||||
if self._enabled:
|
||||
self._token = set_trace_context(self._trace_context)
|
||||
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self._enabled and self._token is not None:
|
||||
detach_trace_context(self._token)
|
||||
return False
|
||||
|
||||
|
||||
def tracing_decorator_factory(
|
||||
trace_name: str, span_kind: Optional[Any] = None
|
||||
) -> Callable:
|
||||
"""
|
||||
Factory function to create a tracing decorator for instrumenting functions/methods
|
||||
with distributed tracing.
|
||||
|
||||
Args:
|
||||
trace_name: The name of the trace.
|
||||
span_kind: The kind of span to create
|
||||
(e.g., SERVER, CLIENT). Defaults to trace.SpanKind.SERVER.
|
||||
|
||||
Returns:
|
||||
Callable: A decorator function that can be used to wrap
|
||||
functions/methods with distributed tracing.
|
||||
|
||||
Example Usage:
|
||||
```python
|
||||
@tracing_decorator_factory(
|
||||
"my_trace",
|
||||
span_kind=trace.SpanKind.CLIENT,
|
||||
)
|
||||
def my_function(obj):
|
||||
# Function implementation
|
||||
```
|
||||
"""
|
||||
|
||||
def tracing_decorator(func):
|
||||
if not is_tracing_enabled():
|
||||
# if tracing is not enabled, we don't want to wrap the function
|
||||
# with the tracing decorator.
|
||||
return func
|
||||
|
||||
@wraps(func)
|
||||
def synchronous_wrapper(*args, **kwargs):
|
||||
with TraceContextManager(trace_name, span_kind):
|
||||
result = func(*args, **kwargs)
|
||||
|
||||
return result
|
||||
|
||||
@wraps(func)
|
||||
def generator_wrapper(*args, **kwargs):
|
||||
with TraceContextManager(trace_name, span_kind):
|
||||
for item in func(*args, **kwargs):
|
||||
yield item
|
||||
|
||||
@wraps(func)
|
||||
async def asynchronous_wrapper(*args, **kwargs):
|
||||
with TraceContextManager(trace_name, span_kind):
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
|
||||
@wraps(func)
|
||||
async def asyc_generator_wrapper(*args, **kwargs):
|
||||
with TraceContextManager(trace_name, span_kind):
|
||||
async for item in func(*args, **kwargs):
|
||||
yield item
|
||||
|
||||
is_generator = _is_generator_function(func)
|
||||
is_async = _is_async_function(func)
|
||||
if is_generator and is_async:
|
||||
return asyc_generator_wrapper
|
||||
elif is_async:
|
||||
return asynchronous_wrapper
|
||||
elif is_generator:
|
||||
return generator_wrapper
|
||||
else:
|
||||
return synchronous_wrapper
|
||||
|
||||
return tracing_decorator
|
||||
|
||||
|
||||
def setup_tracing(
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
component_type: Optional["ServeComponentType"] = None, # noqa: F821
|
||||
tracing_exporter_import_path: Optional[
|
||||
str
|
||||
] = RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH,
|
||||
tracing_sampling_ratio: Optional[float] = RAY_SERVE_TRACING_SAMPLING_RATIO,
|
||||
) -> bool:
|
||||
"""
|
||||
Set up tracing for a specific Serve component.
|
||||
|
||||
Args:
|
||||
component_name: The name of the component.
|
||||
component_id: The unique identifier of the component.
|
||||
component_type: The type of the component.
|
||||
tracing_exporter_import_path: Path to tracing exporter function.
|
||||
tracing_sampling_ratio: Sampling ratio for traces (0.0 to 1.0).
|
||||
|
||||
Returns:
|
||||
bool: True if tracing setup is successful, False otherwise.
|
||||
"""
|
||||
if tracing_exporter_import_path == "":
|
||||
return False
|
||||
|
||||
# Check dependencies
|
||||
if not trace:
|
||||
raise ImportError(
|
||||
"You must `pip install opentelemetry-api` and "
|
||||
"`pip install opentelemetry-sdk` "
|
||||
"to enable tracing on Ray Serve."
|
||||
)
|
||||
|
||||
from ray.serve._private.utils import get_component_file_name
|
||||
|
||||
tracing_file_name = get_component_file_name(
|
||||
component_name=component_name,
|
||||
component_id=component_id,
|
||||
component_type=component_type,
|
||||
suffix="_tracing.json",
|
||||
)
|
||||
|
||||
span_processors = _load_span_processors(
|
||||
tracing_exporter_import_path, tracing_file_name
|
||||
)
|
||||
|
||||
# Intialize tracing
|
||||
# Sets the tracer_provider. This is only allowed once~ per execution
|
||||
# context and will log a warning if attempted multiple times.
|
||||
# use ParentBasedTraceIdRatio to respect the parent span's sampling decision
|
||||
# and sample probabilistically based on the tracing_sampling_ratio
|
||||
sampler = ParentBasedTraceIdRatio(tracing_sampling_ratio)
|
||||
|
||||
trace.set_tracer_provider(TracerProvider(sampler=sampler))
|
||||
|
||||
for span_processor in span_processors:
|
||||
trace.get_tracer_provider().add_span_processor(span_processor)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_propagated_context() -> Dict[str, str]:
|
||||
"""Create context that can be used across services and processes.
|
||||
|
||||
This function retrieves the current context and converts it
|
||||
into a dictionary that can be used across actors and tasks since
|
||||
it is serializable.
|
||||
|
||||
Returns:
|
||||
- Trace Context Propagator (dict or None): A dictionary containing the propagated
|
||||
trace context if available, otherwise None.
|
||||
"""
|
||||
trace_context = get_trace_context()
|
||||
if trace_context and TraceContextTextMapPropagator:
|
||||
ctx = {}
|
||||
TraceContextTextMapPropagator().inject(ctx, trace_context)
|
||||
return ctx
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_propagated_context(
|
||||
propagated_context: Optional[Dict[str, str]] = None
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Extract the trace context from a Trace Context Propagator."""
|
||||
if is_tracing_enabled() and propagated_context and TraceContextTextMapPropagator:
|
||||
return TraceContextTextMapPropagator().extract(carrier=propagated_context)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def set_trace_context(trace_context: Dict[str, str]) -> Optional[Token]:
|
||||
"""Set the current trace context."""
|
||||
if attach is None:
|
||||
return
|
||||
|
||||
return attach(trace_context)
|
||||
|
||||
|
||||
def detach_trace_context(token: Token):
|
||||
"""Detach the current trace context corresponding to the token."""
|
||||
if detach is None:
|
||||
return
|
||||
|
||||
detach(token)
|
||||
|
||||
|
||||
def get_trace_context() -> Optional[Dict[str, str]]:
|
||||
"""Retrieve the current trace context."""
|
||||
if get_current is None:
|
||||
return None
|
||||
|
||||
context = get_current()
|
||||
return context if context else None
|
||||
|
||||
|
||||
def set_span_name(name: str):
|
||||
"""Set the name for the current span in context."""
|
||||
if TRACE_STACK:
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
trace_stack[-1].update_name(name)
|
||||
# this is added specifically for Datadog tracing.
|
||||
# See https://docs.datadoghq.com/tracing/guide/configuring-primary-operation/#opentracing
|
||||
set_span_attributes({"resource.name": name})
|
||||
|
||||
|
||||
def set_rpc_span_attributes(
|
||||
system: str = "grpc",
|
||||
method: Optional[str] = None,
|
||||
status_code: Optional[str] = None,
|
||||
service: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Use this function to set attributes for RPC spans.
|
||||
Only include attributes that are in the OpenTelemetry
|
||||
RPC span attributes spec https://opentelemetry.io/docs/specs/semconv/attributes-registry/rpc/.
|
||||
"""
|
||||
if not is_tracing_enabled():
|
||||
return
|
||||
attributes = {
|
||||
SpanAttributes.RPC_SYSTEM: system,
|
||||
SpanAttributes.RPC_METHOD: method,
|
||||
SpanAttributes.RPC_GRPC_STATUS_CODE: status_code,
|
||||
SpanAttributes.RPC_SERVICE: service,
|
||||
}
|
||||
set_span_attributes(attributes)
|
||||
|
||||
|
||||
def set_http_span_attributes(
|
||||
method: Optional[str] = None,
|
||||
status_code: Optional[str] = None,
|
||||
route: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Use this function to set attributes for HTTP spans.
|
||||
Only include attributes that are in the OpenTelemetry
|
||||
HTTP span attributes spec https://opentelemetry.io/docs/specs/semconv/attributes-registry/http/.
|
||||
"""
|
||||
if not is_tracing_enabled():
|
||||
return
|
||||
attributes = {
|
||||
SpanAttributes.HTTP_METHOD: method,
|
||||
SpanAttributes.HTTP_STATUS_CODE: status_code,
|
||||
SpanAttributes.HTTP_ROUTE: route,
|
||||
}
|
||||
set_span_attributes(attributes)
|
||||
|
||||
|
||||
def set_span_attributes(attributes: Dict[str, Any]):
|
||||
"""Set attributes for the current span in context."""
|
||||
if TRACE_STACK:
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
# filter attribute values that are None, otherwise they
|
||||
# will show up as warning logs on the console.
|
||||
attributes = {k: v for k, v in attributes.items() if v is not None}
|
||||
trace_stack[-1].set_attributes(attributes)
|
||||
|
||||
|
||||
def set_trace_status(is_error: bool, description: str = ""):
|
||||
"""Set the status for the current span in context."""
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
if is_error:
|
||||
status_code = StatusCode.ERROR
|
||||
else:
|
||||
status_code = StatusCode.OK
|
||||
description = None
|
||||
trace_stack[-1].set_status(
|
||||
Status(status_code=status_code, description=description)
|
||||
)
|
||||
|
||||
|
||||
def set_span_exception(exc: Exception, escaped: bool = False):
|
||||
"""Set the exception for the current span in context."""
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
trace_stack[-1].record_exception(exc, escaped=escaped)
|
||||
|
||||
|
||||
def is_tracing_enabled() -> bool:
|
||||
return RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH != "" and trace is not None
|
||||
|
||||
|
||||
def is_span_recording() -> bool:
|
||||
if TRACE_STACK:
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _append_trace_stack(span):
|
||||
"""Append span to global trace stack."""
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
trace_stack.append(span)
|
||||
TRACE_STACK.set(trace_stack)
|
||||
|
||||
|
||||
def _pop_trace_stack():
|
||||
"""Pop span to global trace stack."""
|
||||
trace_stack = TRACE_STACK.get([])
|
||||
if trace_stack:
|
||||
trace_stack.pop()
|
||||
TRACE_STACK.set(trace_stack)
|
||||
|
||||
|
||||
def _validate_tracing_exporter(func: Callable) -> None:
|
||||
"""Validate that the custom tracing exporter
|
||||
is a function that takes no arguments.
|
||||
"""
|
||||
if inspect.isfunction(func) is False:
|
||||
raise TypeError("Tracing exporter must be a function.")
|
||||
|
||||
signature = inspect.signature(func)
|
||||
|
||||
if len(signature.parameters) != 0:
|
||||
raise TypeError("Tracing exporter cannot take any arguments.")
|
||||
|
||||
|
||||
def _validate_tracing_exporter_processors(span_processors: List[Any]):
|
||||
"""Validate that the output of a custom tracing exporter
|
||||
returns type List[SpanProcessor].
|
||||
"""
|
||||
if not isinstance(span_processors, list):
|
||||
raise TypeError(
|
||||
"Output of tracing exporter needs to be of type "
|
||||
f"List[SpanProcessor], but received type {type(span_processors)}."
|
||||
)
|
||||
|
||||
for span_processor in span_processors:
|
||||
if not isinstance(span_processor, SpanProcessor):
|
||||
raise TypeError(
|
||||
"Output of tracing exporter needs to be of "
|
||||
"type List[SpanProcessor], "
|
||||
f"but received type {type(span_processor)}."
|
||||
)
|
||||
|
||||
|
||||
def _load_span_processors(
|
||||
tracing_exporter_import_path: str,
|
||||
tracing_file_name: str,
|
||||
):
|
||||
"""Load span processors from a custome tracing
|
||||
exporter function.
|
||||
"""
|
||||
tracing_exporter_def = import_attr(tracing_exporter_import_path)
|
||||
|
||||
if tracing_exporter_import_path == DEFAULT_TRACING_EXPORTER_IMPORT_PATH:
|
||||
return tracing_exporter_def(tracing_file_name)
|
||||
else:
|
||||
# Validate tracing exporter function
|
||||
_validate_tracing_exporter(tracing_exporter_def)
|
||||
# Validate tracing exporter processors
|
||||
span_processors = tracing_exporter_def()
|
||||
_validate_tracing_exporter_processors(span_processors)
|
||||
|
||||
return span_processors
|
||||
|
||||
|
||||
def _is_generator_function(func):
|
||||
return inspect.isgeneratorfunction(func) or inspect.isasyncgenfunction(func)
|
||||
|
||||
|
||||
def _is_async_function(func):
|
||||
return inspect.iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
|
||||
@@ -0,0 +1,55 @@
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag
|
||||
|
||||
|
||||
class ServeUsageTag(Enum):
|
||||
API_VERSION = TagKey.SERVE_API_VERSION
|
||||
NUM_DEPLOYMENTS = TagKey.SERVE_NUM_DEPLOYMENTS
|
||||
GCS_STORAGE = TagKey.GCS_STORAGE
|
||||
NUM_GPU_DEPLOYMENTS = TagKey.SERVE_NUM_GPU_DEPLOYMENTS
|
||||
FASTAPI_USED = TagKey.SERVE_FASTAPI_USED
|
||||
DAG_DRIVER_USED = TagKey.SERVE_DAG_DRIVER_USED
|
||||
HTTP_ADAPTER_USED = TagKey.SERVE_HTTP_ADAPTER_USED
|
||||
GRPC_INGRESS_USED = TagKey.SERVE_GRPC_INGRESS_USED
|
||||
REST_API_VERSION = TagKey.SERVE_REST_API_VERSION
|
||||
NUM_APPS = TagKey.SERVE_NUM_APPS
|
||||
NUM_REPLICAS_LIGHTWEIGHT_UPDATED = TagKey.SERVE_NUM_REPLICAS_LIGHTWEIGHT_UPDATED
|
||||
USER_CONFIG_LIGHTWEIGHT_UPDATED = TagKey.SERVE_USER_CONFIG_LIGHTWEIGHT_UPDATED
|
||||
AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED = (
|
||||
TagKey.SERVE_AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED
|
||||
)
|
||||
DEPLOYMENT_HANDLE_API_USED = TagKey.SERVE_DEPLOYMENT_HANDLE_API_USED
|
||||
DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED = (
|
||||
TagKey.SERVE_DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED
|
||||
)
|
||||
MULTIPLEXED_API_USED = TagKey.SERVE_MULTIPLEXED_API_USED
|
||||
HTTP_PROXY_USED = TagKey.SERVE_HTTP_PROXY_USED
|
||||
GRPC_PROXY_USED = TagKey.SERVE_GRPC_PROXY_USED
|
||||
SERVE_STATUS_API_USED = TagKey.SERVE_STATUS_API_USED
|
||||
SERVE_GET_APP_HANDLE_API_USED = TagKey.SERVE_GET_APP_HANDLE_API_USED
|
||||
SERVE_GET_DEPLOYMENT_HANDLE_API_USED = TagKey.SERVE_GET_DEPLOYMENT_HANDLE_API_USED
|
||||
APP_CONTAINER_RUNTIME_ENV_USED = TagKey.SERVE_APP_CONTAINER_RUNTIME_ENV_USED
|
||||
DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED = (
|
||||
TagKey.SERVE_DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED
|
||||
)
|
||||
NUM_NODE_COMPACTIONS = TagKey.SERVE_NUM_NODE_COMPACTIONS
|
||||
AUTO_NUM_REPLICAS_USED = TagKey.SERVE_AUTO_NUM_REPLICAS_USED
|
||||
CUSTOM_REQUEST_ROUTER_USED = TagKey.SERVE_CUSTOM_REQUEST_ROUTER_USED
|
||||
NUM_REPLICAS_VIA_API_CALL_UPDATED = TagKey.SERVE_NUM_REPLICAS_VIA_API_CALL_UPDATED
|
||||
NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE = (
|
||||
TagKey.SERVE_NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE
|
||||
)
|
||||
CUSTOM_AUTOSCALING_POLICY_USED = TagKey.SERVE_CUSTOM_AUTOSCALING_POLICY_USED
|
||||
|
||||
def record(self, value: str):
|
||||
"""Record telemetry value."""
|
||||
record_extra_usage_tag(self.value, value)
|
||||
|
||||
def get_value_from_report(self, report: Dict) -> Optional[str]:
|
||||
"""Returns `None` if the tag isn't in the report."""
|
||||
if "extra_usage_tags" not in report:
|
||||
return None
|
||||
|
||||
return report["extra_usage_tags"].get(TagKey.Name(self.value).lower(), None)
|
||||
@@ -0,0 +1,892 @@
|
||||
import asyncio
|
||||
import collections
|
||||
import copy
|
||||
import errno
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from enum import Enum
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar, Union
|
||||
|
||||
import requests
|
||||
|
||||
import ray
|
||||
import ray.util.serialization_addons
|
||||
from ray import cloudpickle
|
||||
from ray._common.constants import HEAD_NODE_RESOURCE_NAME
|
||||
from ray._common.utils import get_random_alphanumeric_string, import_attr
|
||||
from ray._raylet import MessagePackSerializer
|
||||
from ray.actor import ActorHandle
|
||||
from ray.serve._private.common import DeploymentID, RequestMetadata, ServeComponentType
|
||||
from ray.serve._private.constants import (
|
||||
HTTP_PROXY_TIMEOUT,
|
||||
SERVE_DEPLOYMENT_ACTOR_PREFIX,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.types import ObjectRef
|
||||
from ray.util.serialization import StandaloneSerializationContext
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
pd = None
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
np = None
|
||||
|
||||
FILE_NAME_REGEX = r"[^\x20-\x7E]|[<>:\"/\\|?*]"
|
||||
|
||||
MESSAGE_PACK_OFFSET = 9
|
||||
|
||||
# Attribute set on functions/methods decorated with `@serve.multiplexed`. The
|
||||
# `__serve_multiplex_wrapper` is only created lazily on the first call, so this
|
||||
# marker is used to detect multiplexing statically (e.g. at replica startup)
|
||||
# without invoking user code.
|
||||
MULTIPLEXED_FUNCTION_MARKER_ATTR = "_serve_multiplexed_function"
|
||||
|
||||
|
||||
def _callable_uses_multiplexing(callable_obj: Any) -> bool:
|
||||
"""Whether `callable_obj` is or defines an `@serve.multiplexed` function.
|
||||
|
||||
Accepts a standalone function, a class, or a class instance, so it can be used
|
||||
both at build time (where the deployment's `func_or_class` is available) and at
|
||||
runtime (where an initialized instance is available).
|
||||
|
||||
For an instance it also inspects instance attributes, so multiplexing that is
|
||||
wired up dynamically at init time (e.g. ``self._load_model =
|
||||
serve.multiplexed(...)(fn)``) is detected. This case can only be caught at
|
||||
runtime, since it is not visible on the class statically.
|
||||
"""
|
||||
# NOTE: the marker is checked with `is True` rather than truthiness because some
|
||||
# objects (e.g. `DeploymentHandle`, whose `__getattr__` returns a handle for any
|
||||
# name) return a truthy value for an arbitrary attribute. The decorator always
|
||||
# sets the marker to the literal `True`, so this stays exact without false
|
||||
# positives.
|
||||
def _has_marker(obj: Any) -> bool:
|
||||
return getattr(obj, MULTIPLEXED_FUNCTION_MARKER_ATTR, False) is True
|
||||
|
||||
# Standalone function deployment decorated with `@serve.multiplexed`.
|
||||
if _has_marker(callable_obj):
|
||||
return True
|
||||
|
||||
# A class (or instance of one) with a method decorated with `@serve.multiplexed`.
|
||||
klass = callable_obj if isinstance(callable_obj, type) else type(callable_obj)
|
||||
for base in klass.__mro__:
|
||||
for attr in base.__dict__.values():
|
||||
if _has_marker(attr):
|
||||
return True
|
||||
|
||||
# An instance that stored a multiplexed wrapper as an instance attribute.
|
||||
if not isinstance(callable_obj, type):
|
||||
for attr in getattr(callable_obj, "__dict__", {}).values():
|
||||
if _has_marker(attr):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def asyncio_grpc_exception_handler(loop, context):
|
||||
"""Exception handler to filter out false positive BlockingIOErrors from gRPC."""
|
||||
exc = context.get("exception")
|
||||
msg = context.get("message")
|
||||
if (
|
||||
exc
|
||||
and isinstance(exc, BlockingIOError)
|
||||
and exc.errno == errno.EAGAIN
|
||||
and "PollerCompletionQueue._handle_events" in msg
|
||||
):
|
||||
return
|
||||
|
||||
loop.default_exception_handler(context)
|
||||
|
||||
|
||||
def validate_ssl_config(
|
||||
ssl_certfile: Optional[str], ssl_keyfile: Optional[str]
|
||||
) -> None:
|
||||
"""Validate SSL configuration for HTTPS support.
|
||||
|
||||
Args:
|
||||
ssl_certfile: Path to SSL certificate file
|
||||
ssl_keyfile: Path to SSL private key file
|
||||
|
||||
Raises:
|
||||
ValueError: If only one of ssl_certfile or ssl_keyfile is provided
|
||||
"""
|
||||
if (ssl_certfile and not ssl_keyfile) or (ssl_keyfile and not ssl_certfile):
|
||||
raise ValueError(
|
||||
"Both ssl_keyfile and ssl_certfile must be provided together "
|
||||
"to enable HTTPS."
|
||||
)
|
||||
|
||||
|
||||
def get_deployment_actor_name(
|
||||
deployment_id: DeploymentID,
|
||||
actor_name: str,
|
||||
code_version: str,
|
||||
) -> str:
|
||||
"""Return the deterministic Ray actor name for a deployment-scoped actor.
|
||||
|
||||
The name is versioned by code_version to allow old and new replicas to
|
||||
coexist during rollout (each uses its version's actors). Actors serve as
|
||||
central state for replicas, so we version by code_version to ensure fresh
|
||||
actors when a new code version is deployed.
|
||||
"""
|
||||
base = (
|
||||
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}{deployment_id.app_name}"
|
||||
f"::{deployment_id.name}"
|
||||
)
|
||||
return f"{base}::{code_version}::{actor_name}"
|
||||
|
||||
|
||||
GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR = RuntimeError(
|
||||
"Streaming deployment handle results cannot be passed to "
|
||||
"downstream handle calls. If you have a use case requiring "
|
||||
"this feature, please file a feature request on GitHub."
|
||||
)
|
||||
|
||||
|
||||
# Use a global singleton enum to emulate default options. We cannot use None
|
||||
# for those option because None is a valid new value.
|
||||
class DEFAULT(Enum):
|
||||
VALUE = 1
|
||||
|
||||
|
||||
class DeploymentOptionUpdateType(str, Enum):
|
||||
# Nothing needs to be done other than setting the target state.
|
||||
LightWeight = "LightWeight"
|
||||
# Each DeploymentReplica instance (tracked in DeploymentState) uses certain options
|
||||
# from the deployment config. These values need to be updated in DeploymentReplica.
|
||||
NeedsReconfigure = "NeedsReconfigure"
|
||||
# Options that are sent to the replica actor. If changed, reconfigure() on the actor
|
||||
# needs to be called to update these values.
|
||||
NeedsActorReconfigure = "NeedsActorReconfigure"
|
||||
# If changed, restart all replicas.
|
||||
HeavyWeight = "HeavyWeight"
|
||||
|
||||
|
||||
# Type alias: objects that can be DEFAULT.VALUE have type Default[T]
|
||||
T = TypeVar("T")
|
||||
Default = Union[DEFAULT, T]
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
# Format for component files
|
||||
FILE_FMT = "{component_name}_{component_id}{suffix}"
|
||||
|
||||
|
||||
class _ServeCustomEncoders:
|
||||
"""Group of custom encoders for common types that's not handled by FastAPI."""
|
||||
|
||||
@staticmethod
|
||||
def encode_np_array(obj):
|
||||
assert isinstance(obj, np.ndarray)
|
||||
if obj.dtype.kind == "f": # floats
|
||||
obj = obj.astype(float)
|
||||
if obj.dtype.kind in {"i", "u"}: # signed and unsigned integers.
|
||||
obj = obj.astype(int)
|
||||
return obj.tolist()
|
||||
|
||||
@staticmethod
|
||||
def encode_np_scaler(obj):
|
||||
assert isinstance(obj, np.generic)
|
||||
return obj.item()
|
||||
|
||||
@staticmethod
|
||||
def encode_exception(obj):
|
||||
assert isinstance(obj, Exception)
|
||||
return str(obj)
|
||||
|
||||
@staticmethod
|
||||
def encode_pandas_dataframe(obj):
|
||||
assert isinstance(obj, pd.DataFrame)
|
||||
return obj.to_dict(orient="records")
|
||||
|
||||
|
||||
serve_encoders = {Exception: _ServeCustomEncoders.encode_exception}
|
||||
|
||||
if np is not None:
|
||||
serve_encoders[np.ndarray] = _ServeCustomEncoders.encode_np_array
|
||||
serve_encoders[np.generic] = _ServeCustomEncoders.encode_np_scaler
|
||||
|
||||
if pd is not None:
|
||||
serve_encoders[pd.DataFrame] = _ServeCustomEncoders.encode_pandas_dataframe
|
||||
|
||||
|
||||
@ray.remote(num_cpus=0)
|
||||
def block_until_http_ready(
|
||||
http_endpoint,
|
||||
backoff_time_s=1,
|
||||
check_ready=None,
|
||||
timeout=HTTP_PROXY_TIMEOUT,
|
||||
):
|
||||
http_is_ready = False
|
||||
start_time = time.time()
|
||||
|
||||
while not http_is_ready:
|
||||
try:
|
||||
resp = requests.get(http_endpoint)
|
||||
assert resp.status_code == 200
|
||||
if check_ready is None:
|
||||
http_is_ready = True
|
||||
else:
|
||||
http_is_ready = check_ready(resp)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 0 < timeout < time.time() - start_time:
|
||||
raise TimeoutError("HTTP proxy not ready after {} seconds.".format(timeout))
|
||||
|
||||
time.sleep(backoff_time_s)
|
||||
|
||||
|
||||
def get_random_string(length: int = 8):
|
||||
return get_random_alphanumeric_string(length)
|
||||
|
||||
|
||||
def format_actor_name(actor_name, *modifiers):
|
||||
name = actor_name
|
||||
for modifier in modifiers:
|
||||
name += "-{}".format(modifier)
|
||||
|
||||
return name
|
||||
|
||||
|
||||
CLASS_WRAPPER_METADATA_ATTRS = (
|
||||
"__name__",
|
||||
"__qualname__",
|
||||
"__module__",
|
||||
"__doc__",
|
||||
"__annotations__",
|
||||
)
|
||||
|
||||
|
||||
def copy_class_metadata(wrapper_cls, target_cls) -> None:
|
||||
"""Copy common class-level metadata onto a wrapper class."""
|
||||
for attr in CLASS_WRAPPER_METADATA_ATTRS:
|
||||
if attr == "__annotations__":
|
||||
target_annotations = getattr(target_cls, "__annotations__", None)
|
||||
if target_annotations:
|
||||
merged_annotations = dict(
|
||||
wrapper_cls.__dict__.get("__annotations__", {})
|
||||
)
|
||||
for key, value in target_annotations.items():
|
||||
merged_annotations.setdefault(key, value)
|
||||
wrapper_cls.__annotations__ = merged_annotations
|
||||
continue
|
||||
|
||||
if hasattr(target_cls, attr):
|
||||
setattr(wrapper_cls, attr, getattr(target_cls, attr))
|
||||
wrapper_cls.__wrapped__ = target_cls
|
||||
|
||||
|
||||
def ensure_serialization_context():
|
||||
"""Ensure the serialization addons on registered, even when Ray has not
|
||||
been started."""
|
||||
ctx = StandaloneSerializationContext()
|
||||
ray.util.serialization_addons.apply(ctx)
|
||||
|
||||
|
||||
def msgpack_serialize(obj):
|
||||
ctx = ray._private.worker.global_worker.get_serialization_context()
|
||||
buffer = ctx.serialize(obj)
|
||||
serialized = buffer.to_bytes()
|
||||
return serialized
|
||||
|
||||
|
||||
def msgpack_deserialize(data):
|
||||
# todo: Ray does not provide a msgpack deserialization api.
|
||||
try:
|
||||
obj = MessagePackSerializer.loads(data[MESSAGE_PACK_OFFSET:], None)
|
||||
except Exception:
|
||||
raise
|
||||
return obj
|
||||
|
||||
|
||||
def merge_dict(dict1, dict2):
|
||||
if dict1 is None and dict2 is None:
|
||||
return None
|
||||
if dict1 is None:
|
||||
dict1 = dict()
|
||||
if dict2 is None:
|
||||
dict2 = dict()
|
||||
result = dict()
|
||||
for key in dict1.keys() | dict2.keys():
|
||||
result[key] = sum([e.get(key, 0) for e in (dict1, dict2)])
|
||||
return result
|
||||
|
||||
|
||||
def parse_import_path(import_path: str):
|
||||
"""
|
||||
Takes in an import_path of form:
|
||||
|
||||
[subdirectory 1].[subdir 2]...[subdir n].[file name].[attribute name]
|
||||
|
||||
Parses this path and returns the module name (everything before the last
|
||||
dot) and attribute name (everything after the last dot), such that the
|
||||
attribute can be imported using "from module_name import attr_name".
|
||||
"""
|
||||
|
||||
nodes = import_path.split(".")
|
||||
if len(nodes) < 2:
|
||||
raise ValueError(
|
||||
f"Got {import_path} as import path. The import path "
|
||||
f"should at least specify the file name and "
|
||||
f"attribute name connected by a dot."
|
||||
)
|
||||
|
||||
return ".".join(nodes[:-1]), nodes[-1]
|
||||
|
||||
|
||||
def override_runtime_envs_except_env_vars(parent_env: Dict, child_env: Dict) -> Dict:
|
||||
"""Creates a runtime_env dict by merging a parent and child environment.
|
||||
|
||||
This method is not destructive. It leaves the parent and child envs
|
||||
the same.
|
||||
|
||||
The merge is a shallow update where the child environment inherits the
|
||||
parent environment's settings. If the child environment specifies any
|
||||
env settings, those settings take precdence over the parent.
|
||||
- Note: env_vars are a special case. The child's env_vars are combined
|
||||
with the parent.
|
||||
|
||||
Args:
|
||||
parent_env: The environment to inherit settings from.
|
||||
child_env: The environment with override settings.
|
||||
|
||||
Returns:
|
||||
A new dictionary containing the merged runtime_env settings.
|
||||
|
||||
Raises:
|
||||
TypeError: If a dictionary is not passed in for parent_env or child_env.
|
||||
"""
|
||||
|
||||
if not isinstance(parent_env, Dict):
|
||||
raise TypeError(
|
||||
f'Got unexpected type "{type(parent_env)}" for parent_env. '
|
||||
"parent_env must be a dictionary."
|
||||
)
|
||||
if not isinstance(child_env, Dict):
|
||||
raise TypeError(
|
||||
f'Got unexpected type "{type(child_env)}" for child_env. '
|
||||
"child_env must be a dictionary."
|
||||
)
|
||||
|
||||
defaults = copy.deepcopy(parent_env)
|
||||
overrides = copy.deepcopy(child_env)
|
||||
|
||||
default_env_vars = defaults.get("env_vars", {})
|
||||
override_env_vars = overrides.get("env_vars", {})
|
||||
|
||||
defaults.update(overrides)
|
||||
default_env_vars.update(override_env_vars)
|
||||
|
||||
defaults["env_vars"] = default_env_vars
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
class JavaActorHandleProxy:
|
||||
"""Wraps actor handle and translate snake_case to camelCase."""
|
||||
|
||||
def __init__(self, handle: ActorHandle):
|
||||
self.handle = handle
|
||||
self._available_attrs = set(dir(self.handle))
|
||||
|
||||
def __getattr__(self, key: str):
|
||||
if key in self._available_attrs:
|
||||
camel_case_key = key
|
||||
else:
|
||||
components = key.split("_")
|
||||
camel_case_key = components[0] + "".join(x.title() for x in components[1:])
|
||||
return getattr(self.handle, camel_case_key)
|
||||
|
||||
|
||||
def require_packages(packages: List[str]):
|
||||
"""Decorator making sure function run in specified environments
|
||||
|
||||
Examples:
|
||||
>>> from ray.serve._private.utils import require_packages
|
||||
>>> @require_packages(["numpy", "package_a"]) # doctest: +SKIP
|
||||
... def func(): # doctest: +SKIP
|
||||
... import numpy as np # doctest: +SKIP
|
||||
... ... # doctest: +SKIP
|
||||
>>> func() # doctest: +SKIP
|
||||
ImportError: func requires ["numpy", "package_a"] but
|
||||
["package_a"] are not available, please pip install them.
|
||||
|
||||
Args:
|
||||
packages: The list of package names that must be importable when the
|
||||
decorated function is invoked.
|
||||
|
||||
Returns:
|
||||
A decorator that wraps the target function with the package check.
|
||||
"""
|
||||
|
||||
def decorator(func):
|
||||
def check_import_once():
|
||||
if not hasattr(func, "_require_packages_checked"):
|
||||
missing_packages = []
|
||||
for package in packages:
|
||||
try:
|
||||
importlib.import_module(package)
|
||||
except ModuleNotFoundError:
|
||||
missing_packages.append(package)
|
||||
if len(missing_packages) > 0:
|
||||
raise ImportError(
|
||||
f"{func} requires packages {packages} to run but "
|
||||
f"{missing_packages} are missing. Please "
|
||||
"`pip install` them or add them to "
|
||||
"`runtime_env`."
|
||||
)
|
||||
func._require_packages_checked = True
|
||||
|
||||
if inspect.iscoroutinefunction(func):
|
||||
|
||||
@wraps(func)
|
||||
async def wrapped(*args, **kwargs):
|
||||
check_import_once()
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
elif inspect.isroutine(func):
|
||||
|
||||
@wraps(func)
|
||||
def wrapped(*args, **kwargs):
|
||||
check_import_once()
|
||||
return func(*args, **kwargs)
|
||||
|
||||
else:
|
||||
raise ValueError("Decorator expect callable functions.")
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def in_interactive_shell():
|
||||
# Taken from:
|
||||
# https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook
|
||||
import __main__ as main
|
||||
|
||||
return not hasattr(main, "__file__")
|
||||
|
||||
|
||||
def snake_to_camel_case(snake_str: str) -> str:
|
||||
"""Convert a snake case string to camel case."""
|
||||
|
||||
words = snake_str.strip("_").split("_")
|
||||
return words[0] + "".join(word[:1].upper() + word[1:] for word in words[1:])
|
||||
|
||||
|
||||
def check_obj_ref_ready_nowait(obj_ref: ObjectRef) -> bool:
|
||||
"""Check if ray object reference is ready without waiting for it."""
|
||||
finished, _ = ray.wait([obj_ref], timeout=0)
|
||||
return len(finished) == 1
|
||||
|
||||
|
||||
def compress_metric_report(report: Any) -> bytes:
|
||||
"""Compress a metric report (HandleMetricReport or ReplicaMetricReport) for RPC.
|
||||
|
||||
Uses zlib level 9 (stdlib, no extra deps). ~75KB uncompressed -> ~5KB for 1000 replicas.
|
||||
"""
|
||||
return zlib.compress(cloudpickle.dumps(report), level=9)
|
||||
|
||||
|
||||
def decompress_metric_report(compressed: bytes) -> Any:
|
||||
"""Decompress a metric report from RPC."""
|
||||
return cloudpickle.loads(zlib.decompress(compressed))
|
||||
|
||||
|
||||
def extract_self_if_method_call(args: List[Any], func: Callable) -> Optional[object]:
|
||||
"""Check if this is a method rather than a function.
|
||||
|
||||
Does this by checking to see if `func` is the attribute of the first
|
||||
(`self`) argument under `func.__name__`. Unfortunately, this is the most
|
||||
robust solution to this I was able to find. It would also be preferable
|
||||
to do this check when the decorator runs, rather than when the method is.
|
||||
|
||||
Arguments:
|
||||
args: arguments to the function/method call.
|
||||
func: the unbound function that was called.
|
||||
|
||||
Returns:
|
||||
The ``self`` object if it's a method call, else ``None``.
|
||||
"""
|
||||
if len(args) > 0:
|
||||
method = getattr(args[0], func.__name__, False)
|
||||
if method:
|
||||
wrapped = getattr(method, "__wrapped__", False)
|
||||
if wrapped and wrapped == func:
|
||||
return args[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def call_function_from_import_path(import_path: str) -> Any:
|
||||
"""Call the function given import path.
|
||||
|
||||
Args:
|
||||
import_path: The import path of the function to call.
|
||||
Raises:
|
||||
ValueError: If the import path is invalid.
|
||||
TypeError: If the import path is not callable.
|
||||
RuntimeError: if the function raise exeception during execution.
|
||||
Returns:
|
||||
The result of the function call.
|
||||
"""
|
||||
try:
|
||||
callback_func = import_attr(import_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"The import path {import_path} cannot be imported: {e}")
|
||||
|
||||
if not callable(callback_func):
|
||||
raise TypeError(f"The import path {import_path} is not callable.")
|
||||
|
||||
try:
|
||||
return callback_func()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"The function {import_path} raised an exception: {e}")
|
||||
|
||||
|
||||
def get_head_node_id() -> str:
|
||||
"""Get the head node id.
|
||||
|
||||
Iterate through all nodes in the ray cluster and return the node id of the first
|
||||
alive node with head node resource.
|
||||
"""
|
||||
head_node_id = None
|
||||
for node in ray.nodes():
|
||||
if HEAD_NODE_RESOURCE_NAME in node["Resources"] and node["Alive"]:
|
||||
head_node_id = node["NodeID"]
|
||||
break
|
||||
assert head_node_id is not None, "Cannot find alive head node."
|
||||
|
||||
return head_node_id
|
||||
|
||||
|
||||
def calculate_remaining_timeout(
|
||||
*,
|
||||
timeout_s: Optional[float],
|
||||
start_time_s: float,
|
||||
curr_time_s: float,
|
||||
) -> Optional[float]:
|
||||
"""Get the timeout remaining given an overall timeout, start time, and curr time.
|
||||
|
||||
If the timeout passed in was `None` or negative, will always return that timeout
|
||||
directly.
|
||||
|
||||
If the timeout is >= 0, the returned remaining timeout always be >= 0.
|
||||
"""
|
||||
if timeout_s is None or timeout_s < 0:
|
||||
return timeout_s
|
||||
|
||||
time_since_start_s = curr_time_s - start_time_s
|
||||
return max(0, timeout_s - time_since_start_s)
|
||||
|
||||
|
||||
def get_all_live_placement_group_names() -> List[str]:
|
||||
"""Fetch and parse the Ray placement group table for live placement group names.
|
||||
|
||||
Placement groups are filtered based on their `scheduling_state`; any placement
|
||||
group not in the "REMOVED" state is considered live.
|
||||
"""
|
||||
placement_group_table = ray.util.placement_group_table()
|
||||
|
||||
live_pg_names = []
|
||||
for entry in placement_group_table.values():
|
||||
pg_name = entry.get("name", "")
|
||||
if (
|
||||
pg_name
|
||||
and entry.get("stats", {}).get("scheduling_state", "UNKNOWN") != "REMOVED"
|
||||
):
|
||||
live_pg_names.append(pg_name)
|
||||
|
||||
return live_pg_names
|
||||
|
||||
|
||||
def get_active_placement_group_ids() -> Set[str]:
|
||||
"""
|
||||
Retrieve the set of placement group IDs referenced by alive Serve actors.
|
||||
|
||||
Returns:
|
||||
The set of placement group IDs referenced by alive Serve actors.
|
||||
"""
|
||||
# TODO (jeffreywang): Move the imports to the top of the file.
|
||||
# https://github.com/ray-project/ray/issues/61330
|
||||
from ray.util.state import list_actors
|
||||
from ray.util.state.common import RAY_MAX_LIMIT_FROM_API_SERVER
|
||||
|
||||
actors = list_actors(
|
||||
filters=[
|
||||
("ray_namespace", "=", SERVE_NAMESPACE),
|
||||
("state", "=", "ALIVE"),
|
||||
],
|
||||
limit=RAY_MAX_LIMIT_FROM_API_SERVER,
|
||||
detail=True,
|
||||
raise_on_missing_output=False,
|
||||
)
|
||||
|
||||
return {
|
||||
actor.placement_group_id
|
||||
for actor in actors
|
||||
if actor.placement_group_id is not None
|
||||
}
|
||||
|
||||
|
||||
def get_current_actor_id() -> str:
|
||||
"""Gets the ID of the calling actor.
|
||||
|
||||
If this is called in a driver, returns "DRIVER."
|
||||
|
||||
If otherwise called outside of an actor, returns an empty string.
|
||||
|
||||
This function hangs when GCS is down due to the `ray.get_runtime_context()`
|
||||
call.
|
||||
"""
|
||||
|
||||
worker_mode = ray.get_runtime_context().worker.mode
|
||||
if worker_mode == ray.SCRIPT_MODE:
|
||||
return "DRIVER"
|
||||
else:
|
||||
try:
|
||||
actor_id = ray.get_runtime_context().get_actor_id()
|
||||
if actor_id is None:
|
||||
return ""
|
||||
else:
|
||||
return actor_id
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def is_running_in_asyncio_loop() -> bool:
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
return True
|
||||
except RuntimeError:
|
||||
return False
|
||||
|
||||
|
||||
def get_capacity_adjusted_num_replicas(
|
||||
num_replicas: int, target_capacity: Optional[float]
|
||||
) -> int:
|
||||
"""Return the `num_replicas` adjusted by the `target_capacity`.
|
||||
|
||||
The output will only ever be 0 if `target_capacity` is 0 or `num_replicas` is
|
||||
0 (to support autoscaling deployments using scale-to-zero).
|
||||
|
||||
Rather than using the default `round` behavior in Python, which rounds half to
|
||||
even, uses the `decimal` module to round half up (standard rounding behavior).
|
||||
"""
|
||||
if target_capacity is None or target_capacity == 100:
|
||||
return num_replicas
|
||||
|
||||
if target_capacity == 0 or num_replicas == 0:
|
||||
return 0
|
||||
|
||||
adjusted_num_replicas = Decimal(num_replicas * target_capacity) / Decimal(100.0)
|
||||
rounded_adjusted_num_replicas = adjusted_num_replicas.to_integral_value(
|
||||
rounding=ROUND_HALF_UP
|
||||
)
|
||||
return max(1, int(rounded_adjusted_num_replicas))
|
||||
|
||||
|
||||
def generate_request_id() -> str:
|
||||
# NOTE(edoakes): we use random.getrandbits because it reduces CPU overhead
|
||||
# significantly. This is less cryptographically secure but should be ok for
|
||||
# request ID generation.
|
||||
# See https://bugs.python.org/issue45556 for discussion.
|
||||
return str(uuid.UUID(int=random.getrandbits(128), version=4))
|
||||
|
||||
|
||||
def inside_ray_client_context() -> bool:
|
||||
return ray.util.client.ray.is_connected()
|
||||
|
||||
|
||||
def get_component_file_name(
|
||||
component_name: str,
|
||||
component_id: str,
|
||||
component_type: Optional[ServeComponentType],
|
||||
suffix: str = "",
|
||||
) -> str:
|
||||
"""Get the component's file name. Replaces special characters with underscores."""
|
||||
component_name = re.sub(FILE_NAME_REGEX, "_", component_name)
|
||||
|
||||
# For DEPLOYMENT component type, we want to log the deployment name
|
||||
# instead of adding the component type to the component name.
|
||||
component_log_file_name = component_name
|
||||
if component_type is not None:
|
||||
component_log_file_name = f"{component_type.value}_{component_name}"
|
||||
if component_type != ServeComponentType.REPLICA:
|
||||
component_name = f"{component_type}_{component_name}"
|
||||
file_name = FILE_FMT.format(
|
||||
component_name=component_log_file_name,
|
||||
component_id=component_id,
|
||||
suffix=suffix,
|
||||
)
|
||||
return file_name
|
||||
|
||||
|
||||
def validate_route_prefix(route_prefix: Union[DEFAULT, None, str]):
|
||||
if route_prefix is DEFAULT.VALUE or route_prefix is None:
|
||||
return
|
||||
|
||||
if not route_prefix.startswith("/"):
|
||||
raise ValueError(
|
||||
f"Invalid route_prefix '{route_prefix}', "
|
||||
"must start with a forward slash ('/')."
|
||||
)
|
||||
|
||||
if route_prefix != "/" and route_prefix.endswith("/"):
|
||||
raise ValueError(
|
||||
f"Invalid route_prefix '{route_prefix}', "
|
||||
"may not end with a trailing '/'."
|
||||
)
|
||||
|
||||
if "{" in route_prefix or "}" in route_prefix:
|
||||
raise ValueError(
|
||||
f"Invalid route_prefix '{route_prefix}', may not contain wildcards."
|
||||
)
|
||||
|
||||
|
||||
async def await_deployment_response(deployment_response):
|
||||
return await deployment_response
|
||||
|
||||
|
||||
async def resolve_deployment_response(obj: Any, request_metadata: RequestMetadata):
|
||||
"""Resolve `DeploymentResponse` objects to underlying object references.
|
||||
|
||||
This enables composition without explicitly calling `_to_object_ref`.
|
||||
"""
|
||||
from ray.serve.handle import DeploymentResponse, DeploymentResponseGenerator
|
||||
|
||||
if isinstance(obj, DeploymentResponseGenerator):
|
||||
raise GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR
|
||||
elif isinstance(obj, DeploymentResponse):
|
||||
if request_metadata._by_reference and obj.by_reference:
|
||||
# If sending requests by reference, launch async task to
|
||||
# convert DeploymentResponse to an object ref
|
||||
return asyncio.create_task(obj._to_object_ref())
|
||||
else:
|
||||
# Otherwise, resolve DeploymentResponse directly to result
|
||||
return asyncio.create_task(await_deployment_response(obj))
|
||||
elif not request_metadata._by_reference and isinstance(obj, ray.ObjectRef):
|
||||
# If the router is sending requests by value (i.e. using gRPC),
|
||||
# resolve all Ray objects to mirror Ray behavior
|
||||
return asyncio.wrap_future(obj.future())
|
||||
|
||||
|
||||
def wait_for_interrupt() -> None:
|
||||
try:
|
||||
while True:
|
||||
# Block, letting Ray print logs to the terminal.
|
||||
time.sleep(10)
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Got KeyboardInterrupt, exiting...")
|
||||
# We need to re-raise KeyboardInterrupt, so serve components can be shutdown
|
||||
# from the main script.
|
||||
raise
|
||||
|
||||
|
||||
def is_grpc_enabled(grpc_config) -> bool:
|
||||
return grpc_config.port > 0 and len(grpc_config.grpc_servicer_functions) > 0
|
||||
|
||||
|
||||
class Semaphore:
|
||||
"""Based on asyncio.Semaphore.
|
||||
|
||||
This is a semaphore that can be used to limit the number of concurrent requests.
|
||||
Its maximum value is dynamic and is determined by the `get_value_fn` function.
|
||||
"""
|
||||
|
||||
def __init__(self, get_value_fn: Callable[[], int]):
|
||||
self._waiters = None
|
||||
self._value = 0
|
||||
self._get_value_fn = get_value_fn
|
||||
|
||||
def __repr__(self):
|
||||
res = super().__repr__()
|
||||
extra = "locked" if self.locked() else f"unlocked, value:{self._value}"
|
||||
if self._waiters:
|
||||
extra = f"{extra}, waiters:{len(self._waiters)}"
|
||||
return f"<{res[1:-1]} [{extra}]>"
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.acquire()
|
||||
# We have no use for the "as ..." clause in the with
|
||||
# statement for locks.
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self.release()
|
||||
|
||||
def get_max_value(self):
|
||||
return self._get_value_fn()
|
||||
|
||||
def locked(self):
|
||||
"""Returns True if semaphore cannot be acquired immediately."""
|
||||
return self._value >= self.get_max_value() or (
|
||||
any(not w.cancelled() for w in (self._waiters or ()))
|
||||
)
|
||||
|
||||
async def acquire(self):
|
||||
"""Acquire a semaphore.
|
||||
If the internal counter is larger than zero on entry,
|
||||
decrement it by one and return True immediately. If it is
|
||||
zero on entry, block, waiting until some other coroutine has
|
||||
called release() to make it larger than 0, and then return
|
||||
True.
|
||||
"""
|
||||
if not self.locked():
|
||||
self._value += 1
|
||||
return True
|
||||
|
||||
if self._waiters is None:
|
||||
self._waiters = collections.deque()
|
||||
fut = asyncio.Future()
|
||||
self._waiters.append(fut)
|
||||
|
||||
# Finally block should be called before the CancelledError
|
||||
# handling as we don't want CancelledError to call
|
||||
# _wake_up_first() and attempt to wake up itself.
|
||||
try:
|
||||
try:
|
||||
await fut
|
||||
finally:
|
||||
self._waiters.remove(fut)
|
||||
except asyncio.CancelledError:
|
||||
if not fut.cancelled():
|
||||
self._value -= 1
|
||||
self._wake_up_next()
|
||||
raise
|
||||
|
||||
if self._value < self.get_max_value():
|
||||
self._wake_up_next()
|
||||
return True
|
||||
|
||||
def release(self):
|
||||
"""Release a semaphore, incrementing the internal counter by one.
|
||||
When it was zero on entry and another coroutine is waiting for it to
|
||||
become larger than zero again, wake up that coroutine.
|
||||
"""
|
||||
self._value -= 1
|
||||
self._wake_up_next()
|
||||
|
||||
def _wake_up_next(self):
|
||||
"""Wake up the first waiter that isn't done."""
|
||||
if not self._waiters:
|
||||
return
|
||||
|
||||
for fut in self._waiters:
|
||||
if not fut.done():
|
||||
self._value += 1
|
||||
fut.set_result(True)
|
||||
return
|
||||
@@ -0,0 +1,282 @@
|
||||
import json
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, List, Optional
|
||||
from zlib import crc32
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ray.serve._private.config import DeploymentConfig
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve._private.utils import DeploymentOptionUpdateType, get_random_string
|
||||
from ray.serve.config import AutoscalingConfig
|
||||
from ray.serve.generated.serve_pb2 import DeploymentVersion as DeploymentVersionProto
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
|
||||
class DeploymentVersion:
|
||||
def __init__(
|
||||
self,
|
||||
code_version: Optional[str],
|
||||
deployment_config: DeploymentConfig,
|
||||
ray_actor_options: Optional[Dict],
|
||||
placement_group_bundles: Optional[List[Dict[str, float]]] = None,
|
||||
placement_group_strategy: Optional[str] = None,
|
||||
placement_group_bundle_label_selector: Optional[List[Dict[str, str]]] = None,
|
||||
placement_group_fallback_strategy: Optional[List[Dict[str, Any]]] = None,
|
||||
max_replicas_per_node: Optional[int] = None,
|
||||
route_prefix: Optional[str] = None,
|
||||
):
|
||||
if code_version is not None and not isinstance(code_version, str):
|
||||
raise TypeError(f"code_version must be str, got {type(code_version)}.")
|
||||
if code_version is None:
|
||||
self.code_version = get_random_string()
|
||||
else:
|
||||
self.code_version = code_version
|
||||
|
||||
# Options for this field may be mutated over time, so any logic that uses this
|
||||
# should access this field directly.
|
||||
self.deployment_config = deployment_config
|
||||
self.ray_actor_options = ray_actor_options
|
||||
self.placement_group_bundles = placement_group_bundles
|
||||
self.placement_group_strategy = placement_group_strategy
|
||||
self.placement_group_bundle_label_selector = (
|
||||
placement_group_bundle_label_selector
|
||||
)
|
||||
self.placement_group_fallback_strategy = placement_group_fallback_strategy
|
||||
self.max_replicas_per_node = max_replicas_per_node
|
||||
self.route_prefix = route_prefix
|
||||
self.compute_hashes()
|
||||
|
||||
@classmethod
|
||||
def from_deployment_version(
|
||||
cls, deployment_version, deployment_config, route_prefix: Optional[str] = None
|
||||
):
|
||||
version_copy = deepcopy(deployment_version)
|
||||
version_copy.deployment_config = deployment_config
|
||||
version_copy.route_prefix = route_prefix
|
||||
version_copy.compute_hashes()
|
||||
return version_copy
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return self._hash
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
if not isinstance(other, DeploymentVersion):
|
||||
return False
|
||||
return self._hash == other._hash
|
||||
|
||||
def requires_actor_restart(self, new_version):
|
||||
"""Determines whether the new version requires actors of the current version to
|
||||
be restarted.
|
||||
"""
|
||||
return (
|
||||
self.code_version != new_version.code_version
|
||||
or self.ray_actor_options_hash != new_version.ray_actor_options_hash
|
||||
or self.placement_group_options_hash
|
||||
!= new_version.placement_group_options_hash
|
||||
or self.max_replicas_per_node != new_version.max_replicas_per_node
|
||||
or self.gang_scheduling_config_hash
|
||||
!= new_version.gang_scheduling_config_hash
|
||||
)
|
||||
|
||||
def requires_actor_reconfigure(self, new_version):
|
||||
"""Determines whether the new version requires calling reconfigure() on the
|
||||
replica actor.
|
||||
"""
|
||||
return self.reconfigure_actor_hash != new_version.reconfigure_actor_hash
|
||||
|
||||
def requires_long_poll_broadcast(self, new_version):
|
||||
"""Determines whether lightweightly updating an existing replica to the new
|
||||
version requires broadcasting through long poll that the running replicas has
|
||||
changed.
|
||||
"""
|
||||
return (
|
||||
self.deployment_config.max_ongoing_requests
|
||||
!= new_version.deployment_config.max_ongoing_requests
|
||||
)
|
||||
|
||||
def compute_hashes(self):
|
||||
# If these change, the controller will rolling upgrade existing replicas.
|
||||
serialized_ray_actor_options = _serialize(self.ray_actor_options or {})
|
||||
self.ray_actor_options_hash = crc32(serialized_ray_actor_options)
|
||||
combined_placement_group_options = {}
|
||||
if self.placement_group_bundles is not None:
|
||||
combined_placement_group_options["bundles"] = self.placement_group_bundles
|
||||
if self.placement_group_strategy is not None:
|
||||
combined_placement_group_options["strategy"] = self.placement_group_strategy
|
||||
if self.placement_group_bundle_label_selector is not None:
|
||||
combined_placement_group_options[
|
||||
"bundle_label_selector"
|
||||
] = self.placement_group_bundle_label_selector
|
||||
if self.placement_group_fallback_strategy is not None:
|
||||
combined_placement_group_options[
|
||||
"fallback_strategy"
|
||||
] = self.placement_group_fallback_strategy
|
||||
serialized_placement_group_options = _serialize(
|
||||
combined_placement_group_options
|
||||
)
|
||||
self.placement_group_options_hash = crc32(serialized_placement_group_options)
|
||||
serialized_gang_scheduling_config = _serialize(
|
||||
self.deployment_config.gang_scheduling_config.model_dump()
|
||||
if self.deployment_config.gang_scheduling_config is not None
|
||||
else {}
|
||||
)
|
||||
self.gang_scheduling_config_hash = crc32(serialized_gang_scheduling_config)
|
||||
# Include app-level route prefix in the version hashes so changing
|
||||
# it triggers an in-place reconfigure of running replicas.
|
||||
serialized_route_prefix = _serialize(self.route_prefix)
|
||||
|
||||
# If this changes, DeploymentReplica.reconfigure() will call reconfigure on the
|
||||
# actual replica actor
|
||||
self.reconfigure_actor_hash = crc32(
|
||||
serialized_route_prefix
|
||||
+ self._get_serialized_options(
|
||||
[DeploymentOptionUpdateType.NeedsActorReconfigure]
|
||||
)
|
||||
)
|
||||
|
||||
# Used by __eq__ in deployment state to either reconfigure the replicas or
|
||||
# stop and restart them
|
||||
self._hash = crc32(
|
||||
self.code_version.encode("utf-8")
|
||||
+ serialized_ray_actor_options
|
||||
+ serialized_placement_group_options
|
||||
+ str(self.max_replicas_per_node).encode("utf-8")
|
||||
+ serialized_route_prefix
|
||||
+ self._get_serialized_options(
|
||||
[
|
||||
DeploymentOptionUpdateType.NeedsReconfigure,
|
||||
DeploymentOptionUpdateType.NeedsActorReconfigure,
|
||||
]
|
||||
)
|
||||
+ serialized_gang_scheduling_config
|
||||
)
|
||||
|
||||
def to_proto(self) -> bytes:
|
||||
# TODO(simon): enable cross language user config
|
||||
|
||||
placement_group_bundles = (
|
||||
json.dumps(self.placement_group_bundles)
|
||||
if self.placement_group_bundles is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
placement_group_bundle_label_selector = (
|
||||
json.dumps(self.placement_group_bundle_label_selector)
|
||||
if self.placement_group_bundle_label_selector is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
placement_group_fallback_strategy = (
|
||||
json.dumps(self.placement_group_fallback_strategy)
|
||||
if self.placement_group_fallback_strategy is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
placement_group_strategy = (
|
||||
self.placement_group_strategy
|
||||
if self.placement_group_strategy is not None
|
||||
else ""
|
||||
)
|
||||
max_replicas_per_node = (
|
||||
self.max_replicas_per_node if self.max_replicas_per_node is not None else 0
|
||||
)
|
||||
|
||||
return DeploymentVersionProto(
|
||||
code_version=self.code_version,
|
||||
deployment_config=self.deployment_config.to_proto(),
|
||||
ray_actor_options=json.dumps(self.ray_actor_options),
|
||||
placement_group_bundles=placement_group_bundles,
|
||||
placement_group_strategy=placement_group_strategy,
|
||||
placement_group_bundle_label_selector=placement_group_bundle_label_selector,
|
||||
placement_group_fallback_strategy=placement_group_fallback_strategy,
|
||||
max_replicas_per_node=max_replicas_per_node,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_proto(cls, proto: DeploymentVersionProto):
|
||||
return DeploymentVersion(
|
||||
proto.code_version,
|
||||
DeploymentConfig.from_proto(proto.deployment_config),
|
||||
json.loads(proto.ray_actor_options),
|
||||
placement_group_bundles=(
|
||||
json.loads(proto.placement_group_bundles)
|
||||
if proto.placement_group_bundles
|
||||
else None
|
||||
),
|
||||
placement_group_bundle_label_selector=(
|
||||
json.loads(proto.placement_group_bundle_label_selector)
|
||||
if proto.placement_group_bundle_label_selector
|
||||
else None
|
||||
),
|
||||
placement_group_fallback_strategy=(
|
||||
json.loads(proto.placement_group_fallback_strategy)
|
||||
if proto.placement_group_fallback_strategy
|
||||
else None
|
||||
),
|
||||
placement_group_strategy=(
|
||||
proto.placement_group_strategy
|
||||
if proto.placement_group_strategy
|
||||
else None
|
||||
),
|
||||
max_replicas_per_node=(
|
||||
proto.max_replicas_per_node if proto.max_replicas_per_node else None
|
||||
),
|
||||
)
|
||||
|
||||
def _get_serialized_options(
|
||||
self, update_types: List[DeploymentOptionUpdateType]
|
||||
) -> bytes:
|
||||
"""Returns a serialized dictionary containing fields of a deployment config that
|
||||
should prompt a deployment version update.
|
||||
"""
|
||||
reconfigure_dict = {}
|
||||
# In pydantic 2.0, `__fields__` has been renamed to `model_fields`.
|
||||
# Access from class, not instance, to avoid deprecation warning.
|
||||
fields = DeploymentConfig.model_fields
|
||||
for option_name, field in fields.items():
|
||||
# In Pydantic v2, extra kwargs passed to Field() are in json_schema_extra
|
||||
json_schema_extra = field.json_schema_extra
|
||||
option_weight = (
|
||||
json_schema_extra.get("update_type")
|
||||
if isinstance(json_schema_extra, dict)
|
||||
else None
|
||||
)
|
||||
if option_weight in update_types:
|
||||
reconfigure_dict[option_name] = getattr(
|
||||
self.deployment_config, option_name
|
||||
)
|
||||
# If autoscaling config was changed, only broadcast to
|
||||
# replicas if metrics_interval_s or look_back_period_s
|
||||
# was changed, because the rest of the fields are only
|
||||
# used in deployment state manager
|
||||
if isinstance(reconfigure_dict[option_name], AutoscalingConfig):
|
||||
reconfigure_dict[option_name] = reconfigure_dict[
|
||||
option_name
|
||||
].model_dump(include={"metrics_interval_s", "look_back_period_s"})
|
||||
elif isinstance(reconfigure_dict[option_name], BaseModel):
|
||||
reconfigure_dict[option_name] = reconfigure_dict[
|
||||
option_name
|
||||
].model_dump()
|
||||
|
||||
# Can't serialize bytes. The request router class is already
|
||||
# included in the serialized config as request_router_class.
|
||||
if "request_router_config" in reconfigure_dict:
|
||||
reconfigure_dict["request_router_config"].pop(
|
||||
"_serialized_request_router_cls", None
|
||||
)
|
||||
|
||||
if (
|
||||
isinstance(self.deployment_config.user_config, bytes)
|
||||
and "user_config" in reconfigure_dict
|
||||
):
|
||||
del reconfigure_dict["user_config"]
|
||||
return self.deployment_config.user_config + _serialize(reconfigure_dict)
|
||||
|
||||
return _serialize(reconfigure_dict)
|
||||
|
||||
|
||||
def _serialize(json_object):
|
||||
return str.encode(json.dumps(json_object, sort_keys=True))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple, Union
|
||||
|
||||
from ray.serve._private.broker import Broker
|
||||
from ray.serve._private.constants import SERVE_LOGGER_NAME
|
||||
from ray.serve.config import AutoscalingContext
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
DEFAULT_ASYNC_INFERENCE_QUEUE_POLL_INTERVAL_S = 10.0
|
||||
|
||||
|
||||
class AsyncInferenceAutoscalingPolicy:
|
||||
"""Autoscaling policy that scales replicas based on message queue length.
|
||||
|
||||
Polls a message broker (Redis or RabbitMQ) for queue length and combines
|
||||
it with HTTP request load to compute the desired number of replicas.
|
||||
|
||||
Polling uses one-shot async tasks instead of an infinite background loop.
|
||||
An infinite ``while True`` coroutine holds a strong reference to ``self``
|
||||
through the coroutine, and the event loop keeps the task alive, so
|
||||
``__del__`` would never fire after the framework drops the policy on
|
||||
redeploy/deregistration — leaking both the poller and the broker
|
||||
connection. Instead, each poll is a single one-shot task kicked off from
|
||||
``__call__`` when the poll interval has elapsed. The task completes
|
||||
naturally after one poll, so there is at most one short-lived in-flight
|
||||
task at any time and no cleanup is needed when the policy is
|
||||
garbage-collected.
|
||||
|
||||
This policy is intended for use with ``@task_consumer`` deployments.
|
||||
Pass it as a class-based policy via ``AutoscalingPolicy``:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray.serve.config import AutoscalingConfig, AutoscalingPolicy
|
||||
|
||||
@serve.deployment(
|
||||
autoscaling_config=AutoscalingConfig(
|
||||
min_replicas=1,
|
||||
max_replicas=10,
|
||||
policy=AutoscalingPolicy(
|
||||
policy_function=AsyncInferenceAutoscalingPolicy,
|
||||
policy_kwargs={
|
||||
"broker_url": "redis://localhost:6379/0",
|
||||
"queue_name": "my_queue",
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
@task_consumer(task_processor_config=config)
|
||||
class MyConsumer: ...
|
||||
|
||||
Args:
|
||||
broker_url: URL of the message broker (e.g. ``redis://localhost:6379/0``
|
||||
or ``amqp://guest:guest@localhost:5672//``).
|
||||
queue_name: Name of the queue to monitor.
|
||||
rabbitmq_management_url: RabbitMQ HTTP management API URL. Only required
|
||||
for RabbitMQ brokers (e.g. ``http://guest:guest@localhost:15672/api/``).
|
||||
poll_interval_s: How often (in seconds) to poll the broker for queue
|
||||
length. Defaults to 10s. Lower values increase responsiveness
|
||||
but add broker load.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
broker_url: str,
|
||||
queue_name: str,
|
||||
rabbitmq_management_url: Optional[str] = None,
|
||||
poll_interval_s: float = DEFAULT_ASYNC_INFERENCE_QUEUE_POLL_INTERVAL_S,
|
||||
):
|
||||
self._broker_url = broker_url
|
||||
self._queue_name = queue_name
|
||||
self._rabbitmq_management_url = rabbitmq_management_url
|
||||
self._poll_interval_s = poll_interval_s
|
||||
|
||||
self._queue_length: int = 0
|
||||
self._broker: Optional[Broker] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._last_poll_time: float = 0.0
|
||||
|
||||
def _ensure_broker(self) -> None:
|
||||
"""Lazily initialize the broker connection."""
|
||||
if self._broker is not None:
|
||||
return
|
||||
|
||||
if self._rabbitmq_management_url is not None:
|
||||
self._broker = Broker(
|
||||
self._broker_url, http_api=self._rabbitmq_management_url
|
||||
)
|
||||
else:
|
||||
self._broker = Broker(self._broker_url)
|
||||
|
||||
async def _poll_once(self) -> None:
|
||||
"""Single one-shot poll of the broker for queue length."""
|
||||
try:
|
||||
queues = await self._broker.queues([self._queue_name])
|
||||
if queues is not None:
|
||||
for q in queues:
|
||||
if q.get("name") == self._queue_name:
|
||||
queue_length = q.get("messages")
|
||||
if queue_length is not None:
|
||||
self._queue_length = queue_length
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get queue length for '{self._queue_name}': {e}")
|
||||
|
||||
def __call__(
|
||||
self, ctx: AutoscalingContext
|
||||
) -> Tuple[Union[int, float], Dict[str, Any]]:
|
||||
self._ensure_broker()
|
||||
|
||||
# Clear completed poll task so a new one can be started.
|
||||
if self._task is not None and self._task.done():
|
||||
self._task = None
|
||||
|
||||
# Start a new poll if the interval has elapsed and no poll is in-flight.
|
||||
now = time.monotonic()
|
||||
if self._task is None and (now - self._last_poll_time) >= self._poll_interval_s:
|
||||
self._last_poll_time = now
|
||||
self._task = asyncio.get_running_loop().create_task(self._poll_once())
|
||||
|
||||
num_running_replicas = ctx.current_num_replicas
|
||||
total_workload = ctx.total_num_requests + self._queue_length
|
||||
config = ctx.config
|
||||
|
||||
if num_running_replicas == 0:
|
||||
return 1 if total_workload > 0 else 0, {"queue_length": self._queue_length}
|
||||
|
||||
target_num_requests = (
|
||||
config.get_target_ongoing_requests() * num_running_replicas
|
||||
)
|
||||
error_ratio = total_workload / target_num_requests
|
||||
desired_num_replicas = num_running_replicas * error_ratio
|
||||
|
||||
return desired_num_replicas, {"queue_length": self._queue_length}
|
||||
@@ -0,0 +1,359 @@
|
||||
import functools
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
from ray.serve._private.common import DeploymentID
|
||||
from ray.serve._private.constants import (
|
||||
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY,
|
||||
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY,
|
||||
SERVE_LOGGER_NAME,
|
||||
)
|
||||
from ray.serve.config import AutoscalingConfig, AutoscalingContext
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
# Tolerance for delay elapsed-time comparisons. Subtracting two large
|
||||
# time.time() values (or test fake clocks derived from tick counters) can
|
||||
# drift slightly below the true elapsed interval in IEEE 754 (e.g. 400.0s
|
||||
# configured delay may compare as 399.9999999999999 >= 400.0).
|
||||
_DELAY_ELAPSED_EPS_S = 1e-6
|
||||
|
||||
|
||||
def _apply_scaling_factors(
|
||||
desired_num_replicas: Union[int, float],
|
||||
current_num_replicas: int,
|
||||
autoscaling_config: AutoscalingConfig,
|
||||
) -> int:
|
||||
"""Apply scaling factors to the desired number of replicas.
|
||||
Returns the scaled number of replicas depending on the scaling factor.
|
||||
The computation uses the difference between desired and current to scale.
|
||||
|
||||
"""
|
||||
# When scaling from zero, the scaling factor is not meaningful: the
|
||||
# entire desired count would be treated as the delta and amplified,
|
||||
# creating a feedback loop that compounds every control-loop tick.
|
||||
# Return the raw desired value and let bounds handle the rest.
|
||||
if current_num_replicas == 0:
|
||||
return math.ceil(desired_num_replicas)
|
||||
|
||||
replicas_delta = desired_num_replicas - current_num_replicas
|
||||
scaling_factor = (
|
||||
autoscaling_config.get_upscaling_factor()
|
||||
if replicas_delta > 0
|
||||
else autoscaling_config.get_downscaling_factor()
|
||||
)
|
||||
scaled_num_replicas = math.ceil(
|
||||
current_num_replicas + scaling_factor * replicas_delta
|
||||
)
|
||||
# If the scaled_replicas are stuck during downscaling because of scaling factor, decrement by 1.
|
||||
if (
|
||||
math.ceil(float(desired_num_replicas)) < current_num_replicas
|
||||
and scaled_num_replicas == current_num_replicas
|
||||
):
|
||||
scaled_num_replicas -= 1
|
||||
return scaled_num_replicas
|
||||
|
||||
|
||||
def _apply_delay_logic(
|
||||
desired_num_replicas: int,
|
||||
curr_target_num_replicas: int,
|
||||
config: AutoscalingConfig,
|
||||
policy_state: Dict[str, Any],
|
||||
_now: Optional[float] = None,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
|
||||
"""Apply delay logic to the desired number of replicas.
|
||||
|
||||
Uses wall-clock timestamps to measure delay instead of counting iterations,
|
||||
so the effective delay matches the configured delay_s regardless of how long
|
||||
each control loop iteration takes.
|
||||
"""
|
||||
now = _now if _now is not None else time.time()
|
||||
decision_num_replicas = curr_target_num_replicas
|
||||
# decision_counter encodes direction: >0 means upscale, <0 means downscale.
|
||||
# We keep it for backward-compatible state transitions but the actual delay
|
||||
# check uses the timestamp.
|
||||
decision_counter = policy_state.get(SERVE_AUTOSCALING_DECISION_COUNTERS_KEY, 0)
|
||||
decision_timestamp = policy_state.get(
|
||||
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY, None
|
||||
)
|
||||
|
||||
# Scale up.
|
||||
if desired_num_replicas > curr_target_num_replicas:
|
||||
# If the previous decision was to scale down, reset.
|
||||
if decision_counter < 0:
|
||||
decision_counter = 0
|
||||
decision_timestamp = None
|
||||
decision_counter += 1
|
||||
|
||||
# Record the timestamp when we first start wanting to scale up.
|
||||
if decision_timestamp is None:
|
||||
decision_timestamp = now
|
||||
|
||||
# Only actually scale the replicas if enough wall-clock time has
|
||||
# elapsed since the first consecutive scale-up decision.
|
||||
if now - decision_timestamp + _DELAY_ELAPSED_EPS_S >= config.upscale_delay_s:
|
||||
decision_counter = 0
|
||||
decision_timestamp = None
|
||||
decision_num_replicas = desired_num_replicas
|
||||
|
||||
# Scale down.
|
||||
elif desired_num_replicas < curr_target_num_replicas:
|
||||
# If the previous decision was to scale up, reset.
|
||||
if decision_counter > 0:
|
||||
decision_counter = 0
|
||||
decision_timestamp = None
|
||||
decision_counter -= 1
|
||||
|
||||
# Downscaling to zero is only allowed from 1 -> 0
|
||||
is_scaling_to_zero = curr_target_num_replicas == 1
|
||||
# Determine the delay to use
|
||||
if is_scaling_to_zero:
|
||||
if config.downscale_to_zero_delay_s is not None:
|
||||
delay_s = config.downscale_to_zero_delay_s
|
||||
else:
|
||||
delay_s = config.downscale_delay_s
|
||||
else:
|
||||
delay_s = config.downscale_delay_s
|
||||
# The desired_num_replicas>0 for downscaling cases other than 1->0
|
||||
desired_num_replicas = max(1, desired_num_replicas)
|
||||
|
||||
# Record the timestamp when we first start wanting to scale down.
|
||||
if decision_timestamp is None:
|
||||
decision_timestamp = now
|
||||
|
||||
# Only actually scale the replicas if enough wall-clock time has
|
||||
# elapsed since the first consecutive scale-down decision.
|
||||
if now - decision_timestamp + _DELAY_ELAPSED_EPS_S >= delay_s:
|
||||
decision_counter = 0
|
||||
decision_timestamp = None
|
||||
decision_num_replicas = desired_num_replicas
|
||||
|
||||
# Do nothing.
|
||||
else:
|
||||
decision_counter = 0
|
||||
decision_timestamp = None
|
||||
|
||||
policy_state[SERVE_AUTOSCALING_DECISION_COUNTERS_KEY] = decision_counter
|
||||
policy_state[SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY] = decision_timestamp
|
||||
return decision_num_replicas, policy_state
|
||||
|
||||
|
||||
def _apply_default_params(
|
||||
desired_num_replicas: Union[int, float],
|
||||
ctx: AutoscalingContext,
|
||||
policy_state: Dict[str, Any],
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
"""Apply the default parameters to the desired number of replicas."""
|
||||
|
||||
desired_num_replicas = _apply_scaling_factors(
|
||||
desired_num_replicas, ctx.current_num_replicas, ctx.config
|
||||
)
|
||||
|
||||
# If curr num replicas is 0 and the policy wants to scale up (e.g. based on internal
|
||||
# signals like queue length), bypass the delay logic for immediate scale-up.
|
||||
if ctx.current_num_replicas == 0 and desired_num_replicas > 0:
|
||||
return desired_num_replicas, policy_state
|
||||
|
||||
# Apply delay logic
|
||||
# Only send the internal state here to avoid overwriting the custom policy state.
|
||||
final_num_replicas, updated_state = _apply_delay_logic(
|
||||
max(0, desired_num_replicas), ctx.target_num_replicas, ctx.config, policy_state
|
||||
)
|
||||
|
||||
return final_num_replicas, updated_state
|
||||
|
||||
|
||||
def _extract_internal_policy_state(policy_state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract the internal states from a policy state dict."""
|
||||
return {
|
||||
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY: policy_state.get(
|
||||
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY, 0
|
||||
),
|
||||
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY: policy_state.get(
|
||||
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY, None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _apply_default_params_and_merge_state(
|
||||
policy_state: Dict[str, Any],
|
||||
user_policy_state: Dict[str, Any],
|
||||
desired_num_replicas: Union[int, float],
|
||||
ctx: AutoscalingContext,
|
||||
) -> Tuple[int, Dict[str, Any]]:
|
||||
|
||||
internal_policy_state = _extract_internal_policy_state(policy_state)
|
||||
# Only pass the internal state used for delay counters so we don't
|
||||
# overwrite any custom user state.
|
||||
final_num_replicas, updated_state = _apply_default_params(
|
||||
desired_num_replicas, ctx, internal_policy_state
|
||||
)
|
||||
# Merge internal updated_state with the user's custom policy state.
|
||||
if updated_state:
|
||||
user_policy_state.update(updated_state)
|
||||
return final_num_replicas, user_policy_state
|
||||
|
||||
|
||||
def _merge_user_state_with_internal_state(
|
||||
policy_state: Dict[str, Any],
|
||||
user_policy_state: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge user state with previous policy state, preserving internal keys.
|
||||
|
||||
This mutates and returns `user_policy_state`.
|
||||
"""
|
||||
internal_policy_state = _extract_internal_policy_state(policy_state)
|
||||
user_policy_state.update(internal_policy_state)
|
||||
return user_policy_state
|
||||
|
||||
|
||||
def _get_cold_start_scale_up_replicas(ctx: AutoscalingContext) -> Optional[int]:
|
||||
"""
|
||||
Returns the desired number of replicas if the cold start fast path applies, otherwise returns None.
|
||||
"""
|
||||
if ctx.current_num_replicas == 0 and ctx.total_num_requests > 0:
|
||||
return max(
|
||||
math.ceil(1 * ctx.config.get_upscaling_factor()),
|
||||
ctx.target_num_replicas,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _apply_autoscaling_config(
|
||||
policy_func: Callable[
|
||||
[AutoscalingContext], Tuple[Union[int, float], Dict[str, Any]]
|
||||
]
|
||||
) -> Callable[[AutoscalingContext], Tuple[int, Dict[str, Any]]]:
|
||||
"""
|
||||
Wraps a custom policy function to automatically apply:
|
||||
- upscaling_factor / downscaling_factor
|
||||
- min_replicas / max_replicas bounds
|
||||
- upscale_delay_s / downscale_delay_s / downscale_to_zero_delay_s
|
||||
"""
|
||||
|
||||
@functools.wraps(policy_func)
|
||||
def wrapped_policy(ctx: AutoscalingContext) -> Tuple[int, Dict[str, Any]]:
|
||||
|
||||
# Cold start fast path: 0 replicas bypasses delay logic for immediate scale-up
|
||||
cold_start_replicas = _get_cold_start_scale_up_replicas(ctx)
|
||||
if cold_start_replicas is not None:
|
||||
return cold_start_replicas, ctx.policy_state
|
||||
policy_state = ctx.policy_state.copy()
|
||||
desired_num_replicas, updated_custom_policy_state = policy_func(ctx)
|
||||
final_num_replicas, final_state = _apply_default_params_and_merge_state(
|
||||
policy_state, updated_custom_policy_state, desired_num_replicas, ctx
|
||||
)
|
||||
|
||||
return final_num_replicas, final_state
|
||||
|
||||
return wrapped_policy
|
||||
|
||||
|
||||
def _apply_app_level_autoscaling_config(
|
||||
policy_func: Callable[
|
||||
[Dict[DeploymentID, AutoscalingContext]],
|
||||
Tuple[
|
||||
Dict[DeploymentID, Union[int, float]],
|
||||
Optional[Dict[DeploymentID, Dict]],
|
||||
],
|
||||
]
|
||||
) -> Callable[
|
||||
[Dict[DeploymentID, AutoscalingContext]],
|
||||
Tuple[Dict[DeploymentID, int], Dict[DeploymentID, Dict]],
|
||||
]:
|
||||
"""
|
||||
Wraps an application-level custom policy function to automatically apply per-deployment:
|
||||
- upscaling_factor / downscaling_factor
|
||||
- min_replicas / max_replicas bounds
|
||||
- upscale_delay_s / downscale_delay_s / downscale_to_zero_delay_s
|
||||
"""
|
||||
|
||||
@functools.wraps(policy_func)
|
||||
def wrapped_policy(
|
||||
contexts: Dict[DeploymentID, AutoscalingContext]
|
||||
) -> Tuple[Dict[DeploymentID, int], Dict[DeploymentID, Dict]]:
|
||||
|
||||
# Store the policy state per deployment
|
||||
state_per_deployment = {}
|
||||
for dep_id, ctx in contexts.items():
|
||||
state_per_deployment[dep_id] = ctx.policy_state.copy()
|
||||
|
||||
# Send to the actual policy
|
||||
desired_num_replicas_dict, updated_custom_policy_state = policy_func(contexts)
|
||||
updated_custom_policy_state = updated_custom_policy_state or {}
|
||||
|
||||
# Build per-deployment replicas count and state dictionary.
|
||||
final_decisions: Dict[DeploymentID, int] = {}
|
||||
final_state: Dict[DeploymentID, Dict] = {}
|
||||
for dep_id, ctx in contexts.items():
|
||||
custom_policy_state_per_deployment = (
|
||||
updated_custom_policy_state.get(dep_id) or {}
|
||||
).copy()
|
||||
if dep_id not in desired_num_replicas_dict:
|
||||
final_state[dep_id] = _merge_user_state_with_internal_state(
|
||||
state_per_deployment[dep_id],
|
||||
custom_policy_state_per_deployment,
|
||||
)
|
||||
continue
|
||||
# Cold start fast path: 0 replicas bypasses delay logic for immediate scale-up
|
||||
cold_start_replicas = _get_cold_start_scale_up_replicas(ctx)
|
||||
if cold_start_replicas is not None:
|
||||
final_decisions[dep_id] = cold_start_replicas
|
||||
# Merge user policy state with internal policy state
|
||||
final_state[dep_id] = _merge_user_state_with_internal_state(
|
||||
state_per_deployment[dep_id],
|
||||
custom_policy_state_per_deployment,
|
||||
)
|
||||
continue
|
||||
final_num_replicas, final_dep_state = _apply_default_params_and_merge_state(
|
||||
state_per_deployment[dep_id],
|
||||
custom_policy_state_per_deployment,
|
||||
desired_num_replicas_dict[dep_id],
|
||||
ctx,
|
||||
)
|
||||
final_decisions[dep_id] = final_num_replicas
|
||||
final_state[dep_id] = final_dep_state
|
||||
return final_decisions, final_state
|
||||
|
||||
return wrapped_policy
|
||||
|
||||
|
||||
def _core_replica_queue_length_policy(
|
||||
ctx: AutoscalingContext,
|
||||
) -> Tuple[float, Dict[str, Any]]:
|
||||
num_running_replicas = ctx.current_num_replicas
|
||||
config = ctx.config
|
||||
if num_running_replicas == 0:
|
||||
return ctx.target_num_replicas, {}
|
||||
target_num_requests = config.get_target_ongoing_requests() * num_running_replicas
|
||||
error_ratio = ctx.total_num_requests / target_num_requests
|
||||
desired_num_replicas = num_running_replicas * error_ratio
|
||||
return desired_num_replicas, {}
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
def replica_queue_length_autoscaling_policy(
|
||||
ctx: AutoscalingContext,
|
||||
) -> Tuple[Union[int, float], Dict[str, Any]]:
|
||||
"""The default autoscaling policy based on basic thresholds for scaling.
|
||||
There is a minimum threshold for the average queue length in the cluster
|
||||
to scale up and a maximum threshold to scale down. Each period, a 'scale
|
||||
up' or 'scale down' decision is made. This decision must be made for a
|
||||
specified number of periods in a row before the number of replicas is
|
||||
actually scaled. See config options for more details. Assumes
|
||||
`get_decision_num_replicas` is called once every CONTROL_LOOP_PERIOD_S
|
||||
seconds.
|
||||
"""
|
||||
# Adding this guard makes the public policy safe to call directly.
|
||||
cold_start_replicas = _get_cold_start_scale_up_replicas(ctx)
|
||||
if cold_start_replicas is not None:
|
||||
return cold_start_replicas, ctx.policy_state
|
||||
return _core_replica_queue_length_policy(ctx)
|
||||
|
||||
|
||||
default_autoscaling_policy = replica_queue_length_autoscaling_policy
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,475 @@
|
||||
"""
|
||||
This file stores global state for a Serve application. Deployment replicas
|
||||
can use this state to access metadata or the Serve controller.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import logging
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import ray
|
||||
from ray.serve._private.client import ServeControllerClient
|
||||
from ray.serve._private.common import DeploymentID, ReplicaID
|
||||
from ray.serve._private.config import DeploymentConfig
|
||||
from ray.serve._private.constants import (
|
||||
RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR,
|
||||
RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR,
|
||||
RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR,
|
||||
RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR,
|
||||
SERVE_CONTROLLER_NAME,
|
||||
SERVE_LOGGER_NAME,
|
||||
SERVE_NAMESPACE,
|
||||
)
|
||||
from ray.serve._private.replica_result import ReplicaResult
|
||||
from ray.serve._private.utils import get_deployment_actor_name
|
||||
from ray.serve.exceptions import RayServeException
|
||||
from ray.serve.gang import GangContext
|
||||
from ray.serve.grpc_util import RayServegRPCContext
|
||||
from ray.serve.schema import ReplicaRank
|
||||
from ray.util.annotations import DeveloperAPI
|
||||
|
||||
logger = logging.getLogger(SERVE_LOGGER_NAME)
|
||||
|
||||
_INTERNAL_REPLICA_CONTEXT: "ReplicaContext" = None
|
||||
_INTERNAL_DEPLOYMENT_ACTOR_CONTEXT: "DeploymentActorContext" = None
|
||||
_global_client: ServeControllerClient = None
|
||||
# Ray job id (driver session) that created the cached client above. ray.shutdown()
|
||||
# leaves this module global intact, so after a reconnect (new job id) the cached
|
||||
# handle is unusable and must not be reused. See #64647.
|
||||
_global_client_job_id: Optional[str] = None
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class ReplicaContext:
|
||||
"""Stores runtime context info for replicas.
|
||||
|
||||
Fields:
|
||||
- app_name: name of the application the replica is a part of.
|
||||
- deployment: name of the deployment the replica is a part of.
|
||||
- replica_tag: unique ID for the replica.
|
||||
- servable_object: instance of the user class/function this replica is running.
|
||||
- rank: the rank of the replica.
|
||||
- world_size: the number of replicas in the deployment.
|
||||
- gang_context: context information for the gang the replica is part of.
|
||||
- code_version: code version of the deployment (for get_deployment_actor).
|
||||
"""
|
||||
|
||||
replica_id: ReplicaID
|
||||
servable_object: Callable
|
||||
_deployment_config: DeploymentConfig
|
||||
rank: ReplicaRank
|
||||
world_size: int
|
||||
_handle_registration_callback: Optional[Callable[[DeploymentID], None]] = None
|
||||
gang_context: Optional[GangContext] = None
|
||||
code_version: Optional[str] = None
|
||||
|
||||
@property
|
||||
def app_name(self) -> str:
|
||||
return self.replica_id.deployment_id.app_name
|
||||
|
||||
@property
|
||||
def deployment(self) -> str:
|
||||
return self.replica_id.deployment_id.name
|
||||
|
||||
@property
|
||||
def replica_tag(self) -> str:
|
||||
return self.replica_id.unique_id
|
||||
|
||||
|
||||
@DeveloperAPI
|
||||
@dataclass
|
||||
class DeploymentActorContext:
|
||||
"""Stores runtime context info for deployment-scoped actors."""
|
||||
|
||||
deployment_id: DeploymentID
|
||||
actor_name: str
|
||||
code_version: Optional[str] = None
|
||||
|
||||
@property
|
||||
def app_name(self) -> str:
|
||||
return self.deployment_id.app_name
|
||||
|
||||
@property
|
||||
def deployment(self) -> str:
|
||||
return self.deployment_id.name
|
||||
|
||||
|
||||
def _get_global_client(
|
||||
raise_if_no_controller_running: bool = True,
|
||||
) -> Optional[ServeControllerClient]:
|
||||
"""Gets the global client, which stores the controller's handle.
|
||||
|
||||
Args:
|
||||
raise_if_no_controller_running: Whether to raise an exception if
|
||||
there is no currently running Serve controller.
|
||||
|
||||
Returns:
|
||||
ServeControllerClient to the running Serve controller. If there
|
||||
is no running controller and raise_if_no_controller_running is
|
||||
set to False, returns None.
|
||||
|
||||
Raises:
|
||||
RayServeException: If there is no running Serve controller actor
|
||||
and raise_if_no_controller_running is set to True.
|
||||
"""
|
||||
|
||||
if _cached_client_from_current_session():
|
||||
return _global_client
|
||||
|
||||
# No usable cache (never set, or left over from a previous driver session).
|
||||
# Drop any stale handle and rediscover the controller by name.
|
||||
_disconnect()
|
||||
return _connect(raise_if_no_controller_running)
|
||||
|
||||
|
||||
def _check_cached_client_alive() -> tuple:
|
||||
"""Health-check the cached controller client.
|
||||
|
||||
Returns:
|
||||
(client, had_cached) tuple.
|
||||
- ``(client, True)``: cached client from the current session is alive.
|
||||
- ``(None, True)``: cached client from the current session existed but
|
||||
is unreachable; the cache has been cleared. Callers should **not**
|
||||
attempt to reconnect via ``_connect()`` because GCS is likely dead and
|
||||
``ray.get_actor()`` would hang until the 60-second C++ GCS
|
||||
reconnection timeout kills the process.
|
||||
- ``(None, False)``: no usable cached client (never set, or left over
|
||||
from a previous driver session). Callers may safely call
|
||||
``_get_global_client()`` to discover a running controller.
|
||||
"""
|
||||
|
||||
if not _cached_client_from_current_session():
|
||||
# Either nothing cached, or a handle left behind by a previous driver
|
||||
# session. In the latter case GCS is alive (we are connected now), so it
|
||||
# is safe for callers to reconnect. Drop any stale handle.
|
||||
_disconnect()
|
||||
return None, False
|
||||
|
||||
try:
|
||||
ray.get(_global_client._controller.check_alive.remote(), timeout=5)
|
||||
return _global_client, True
|
||||
except Exception as e:
|
||||
logger.info(f"The cached controller has died or is unreachable: {e}.")
|
||||
_disconnect()
|
||||
return None, True
|
||||
|
||||
|
||||
def _set_global_client(client):
|
||||
global _global_client, _global_client_job_id
|
||||
_global_client = client
|
||||
_global_client_job_id = (
|
||||
ray.get_runtime_context().get_job_id() if client is not None else None
|
||||
)
|
||||
|
||||
|
||||
def _disconnect():
|
||||
"""Forget the cached controller client for this driver session.
|
||||
|
||||
Mirrors ``_connect()``. This does **not** shut Serve down on the cluster; it
|
||||
only drops the local cached handle so the next call rediscovers the
|
||||
controller by name.
|
||||
"""
|
||||
_set_global_client(None)
|
||||
|
||||
|
||||
def _cached_client_from_current_session() -> bool:
|
||||
"""Whether a usable cached client exists for the current Ray session.
|
||||
|
||||
``_global_client`` is a module global that survives ``ray.shutdown()``, so a
|
||||
handle cached by a previous driver session cannot be used after the driver
|
||||
reconnects (which yields a new job id). Comparing job ids detects that case.
|
||||
"""
|
||||
if _global_client is None or not ray.is_initialized():
|
||||
return False
|
||||
return _global_client_job_id == ray.get_runtime_context().get_job_id()
|
||||
|
||||
|
||||
def _get_internal_replica_context():
|
||||
return _INTERNAL_REPLICA_CONTEXT
|
||||
|
||||
|
||||
def _get_internal_deployment_actor_context():
|
||||
global _INTERNAL_DEPLOYMENT_ACTOR_CONTEXT
|
||||
|
||||
if _INTERNAL_DEPLOYMENT_ACTOR_CONTEXT is not None:
|
||||
return _INTERNAL_DEPLOYMENT_ACTOR_CONTEXT
|
||||
|
||||
app_name = os.environ.get(RAY_SERVE_INTERNAL_DEPLOYMENT_APP_NAME_ENV_VAR)
|
||||
deployment_name = os.environ.get(RAY_SERVE_INTERNAL_DEPLOYMENT_NAME_ENV_VAR)
|
||||
actor_name = os.environ.get(RAY_SERVE_INTERNAL_DEPLOYMENT_ACTOR_NAME_ENV_VAR)
|
||||
|
||||
if app_name is None or deployment_name is None or actor_name is None:
|
||||
return None
|
||||
|
||||
_INTERNAL_DEPLOYMENT_ACTOR_CONTEXT = DeploymentActorContext(
|
||||
deployment_id=DeploymentID(name=deployment_name, app_name=app_name),
|
||||
actor_name=actor_name,
|
||||
code_version=os.environ.get(RAY_SERVE_INTERNAL_DEPLOYMENT_CODE_VERSION_ENV_VAR),
|
||||
)
|
||||
return _INTERNAL_DEPLOYMENT_ACTOR_CONTEXT
|
||||
|
||||
|
||||
def _get_deployment_actor(actor_name: str):
|
||||
"""Get a handle to a deployment-scoped actor by name.
|
||||
|
||||
Thin wrapper around ``ray.get_actor`` with the Serve deployment-actor naming
|
||||
convention. See ``serve.get_deployment_actor`` docstring for behavior,
|
||||
``ValueError``/``RayActorError`` expectations, and refresh patterns.
|
||||
"""
|
||||
internal_context = _get_internal_replica_context()
|
||||
if internal_context is None:
|
||||
raise RayServeException(
|
||||
"`serve.get_deployment_actor()` may only be called from within "
|
||||
"a Ray Serve deployment replica."
|
||||
)
|
||||
deployment_id = internal_context.replica_id.deployment_id
|
||||
return ray.get_actor(
|
||||
get_deployment_actor_name(
|
||||
deployment_id,
|
||||
actor_name,
|
||||
code_version=internal_context.code_version,
|
||||
),
|
||||
namespace=SERVE_NAMESPACE,
|
||||
)
|
||||
|
||||
|
||||
def _set_internal_replica_context(
|
||||
*,
|
||||
replica_id: ReplicaID,
|
||||
servable_object: Callable,
|
||||
_deployment_config: DeploymentConfig,
|
||||
rank: ReplicaRank,
|
||||
world_size: int,
|
||||
handle_registration_callback: Optional[Callable[[str, str], None]] = None,
|
||||
gang_context: Optional[GangContext] = None,
|
||||
code_version: Optional[str] = None,
|
||||
):
|
||||
global _INTERNAL_REPLICA_CONTEXT
|
||||
_INTERNAL_REPLICA_CONTEXT = ReplicaContext(
|
||||
replica_id=replica_id,
|
||||
servable_object=servable_object,
|
||||
_deployment_config=_deployment_config,
|
||||
rank=rank,
|
||||
world_size=world_size,
|
||||
_handle_registration_callback=handle_registration_callback,
|
||||
gang_context=gang_context,
|
||||
code_version=code_version,
|
||||
)
|
||||
|
||||
|
||||
def _connect(raise_if_no_controller_running: bool = True) -> ServeControllerClient:
|
||||
"""Connect to an existing Serve application on this Ray cluster.
|
||||
|
||||
If called from within a replica, this will connect to the same Serve
|
||||
app that the replica is running in.
|
||||
|
||||
Args:
|
||||
raise_if_no_controller_running: If ``True``, raise when no Serve
|
||||
controller actor is found. If ``False``, return ``None`` instead.
|
||||
|
||||
Returns:
|
||||
ServeControllerClient that encapsulates a Ray actor handle to the
|
||||
existing Serve application's Serve Controller. None if there is
|
||||
no running Serve controller actor and raise_if_no_controller_running
|
||||
is set to False.
|
||||
|
||||
Raises:
|
||||
RayServeException: If there is no running Serve controller actor
|
||||
and raise_if_no_controller_running is set to True.
|
||||
"""
|
||||
|
||||
# Initialize ray if needed.
|
||||
ray._private.worker.global_worker._filter_logs_by_job = False
|
||||
if not ray.is_initialized():
|
||||
ray.init(namespace=SERVE_NAMESPACE)
|
||||
|
||||
# Try to get serve controller if it exists
|
||||
try:
|
||||
controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE)
|
||||
except ValueError:
|
||||
if raise_if_no_controller_running:
|
||||
raise RayServeException(
|
||||
"There is no Serve instance running on this Ray cluster."
|
||||
)
|
||||
return
|
||||
|
||||
client = ServeControllerClient(
|
||||
controller,
|
||||
)
|
||||
_set_global_client(client)
|
||||
return client
|
||||
|
||||
|
||||
# Serve request context var which is used for storing the internal
|
||||
# request context information.
|
||||
# route_prefix: http url route path, e.g. http://127.0.0.1:/app
|
||||
# the route is "/app". When you send requests by handle,
|
||||
# the route is empty.
|
||||
# request_id: the request id is generated from http proxy, the value
|
||||
# shouldn't be changed when the variable is set.
|
||||
# This can be from the client and is used for logging.
|
||||
# _internal_request_id: the request id is generated from the proxy. Used to track the
|
||||
# request objects in the system.
|
||||
# note:
|
||||
# The request context is readonly to avoid potential
|
||||
# async task conflicts when using it concurrently.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RequestContext:
|
||||
route: str = ""
|
||||
request_id: str = ""
|
||||
_internal_request_id: str = ""
|
||||
app_name: str = ""
|
||||
multiplexed_model_id: str = ""
|
||||
session_id: str = ""
|
||||
grpc_context: Optional[RayServegRPCContext] = None
|
||||
is_http_request: bool = False
|
||||
cancel_on_parent_request_cancel: bool = False
|
||||
# The client address in "host:port" format, if available.
|
||||
_client: str = ""
|
||||
# Ray tracing context for this request (if tracing is enabled)
|
||||
# This is extracted from _ray_trace_ctx kwarg at the replica entry point
|
||||
# Advanced users can access this to propagate tracing to external systems
|
||||
_ray_trace_ctx: Optional[dict] = None
|
||||
|
||||
|
||||
_serve_request_context = contextvars.ContextVar(
|
||||
"Serve internal request context variable", default=None
|
||||
)
|
||||
|
||||
_serve_batch_request_context = contextvars.ContextVar(
|
||||
"Serve internal batching request context variable", default=None
|
||||
)
|
||||
|
||||
|
||||
def _get_serve_request_context():
|
||||
"""Get the current request context.
|
||||
|
||||
Returns:
|
||||
The current request context
|
||||
"""
|
||||
|
||||
if _serve_request_context.get() is None:
|
||||
_serve_request_context.set(_RequestContext())
|
||||
return _serve_request_context.get()
|
||||
|
||||
|
||||
def _get_serve_batch_request_context():
|
||||
"""Get the list of request contexts for the current batch."""
|
||||
if _serve_batch_request_context.get() is None:
|
||||
_serve_batch_request_context.set([])
|
||||
return _serve_batch_request_context.get()
|
||||
|
||||
|
||||
def _set_request_context(
|
||||
route: str = "",
|
||||
request_id: str = "",
|
||||
_internal_request_id: str = "",
|
||||
app_name: str = "",
|
||||
multiplexed_model_id: str = "",
|
||||
):
|
||||
"""Set the request context. If the value is not set,
|
||||
the current context value will be used."""
|
||||
|
||||
current_request_context = _get_serve_request_context()
|
||||
|
||||
_serve_request_context.set(
|
||||
_RequestContext(
|
||||
route=route or current_request_context.route,
|
||||
request_id=request_id or current_request_context.request_id,
|
||||
_internal_request_id=_internal_request_id
|
||||
or current_request_context._internal_request_id,
|
||||
app_name=app_name or current_request_context.app_name,
|
||||
multiplexed_model_id=multiplexed_model_id
|
||||
or current_request_context.multiplexed_model_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _unset_request_context():
|
||||
"""Unset the request context."""
|
||||
_serve_request_context.set(_RequestContext())
|
||||
|
||||
|
||||
def _set_batch_request_context(request_contexts: List[_RequestContext]):
|
||||
"""Add the request context to the batch request context."""
|
||||
_serve_batch_request_context.set(request_contexts)
|
||||
|
||||
|
||||
# `_requests_pending_assignment` is a map from request ID to a
|
||||
# dictionary of asyncio tasks.
|
||||
# The request ID points to an ongoing request that is executing on the
|
||||
# current replica, and the asyncio tasks are ongoing tasks started on
|
||||
# the router to assign child requests to downstream replicas.
|
||||
|
||||
# A dictionary is used over a set to track the asyncio tasks for more
|
||||
# efficient addition and deletion time complexity. A uniquely generated
|
||||
# `response_id` is used to identify each task.
|
||||
|
||||
_requests_pending_assignment: Dict[str, Dict[str, asyncio.Task]] = defaultdict(dict)
|
||||
|
||||
|
||||
# Note that the functions below that manipulate
|
||||
# `_requests_pending_assignment` are NOT thread-safe. They are only
|
||||
# expected to be called from the same thread/asyncio event-loop.
|
||||
|
||||
|
||||
def _get_requests_pending_assignment(parent_request_id: str) -> Dict[str, asyncio.Task]:
|
||||
if parent_request_id in _requests_pending_assignment:
|
||||
return _requests_pending_assignment[parent_request_id]
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _add_request_pending_assignment(parent_request_id: str, response_id: str, task):
|
||||
# NOTE: `parent_request_id` is the `internal_request_id` corresponding
|
||||
# to an ongoing Serve request, so it is always non-empty.
|
||||
_requests_pending_assignment[parent_request_id][response_id] = task
|
||||
|
||||
|
||||
def _remove_request_pending_assignment(parent_request_id: str, response_id: str):
|
||||
if response_id in _requests_pending_assignment[parent_request_id]:
|
||||
del _requests_pending_assignment[parent_request_id][response_id]
|
||||
|
||||
if len(_requests_pending_assignment[parent_request_id]) == 0:
|
||||
del _requests_pending_assignment[parent_request_id]
|
||||
|
||||
|
||||
# `_in_flight_requests` is a map from request ID to a dictionary of replica results.
|
||||
# The request ID points to an ongoing Serve request, and the replica results are
|
||||
# in-flight child requests that have been assigned to a downstream replica.
|
||||
|
||||
# A dictionary is used over a set to track the replica results for more
|
||||
# efficient addition and deletion time complexity. A uniquely generated
|
||||
# `response_id` is used to identify each replica result.
|
||||
|
||||
_in_flight_requests: Dict[str, Dict[str, ReplicaResult]] = defaultdict(dict)
|
||||
|
||||
# Note that the functions below that manipulate `_in_flight_requests`
|
||||
# are NOT thread-safe. They are only expected to be called from the
|
||||
# same thread/asyncio event-loop.
|
||||
|
||||
|
||||
def _get_in_flight_requests(parent_request_id):
|
||||
if parent_request_id in _in_flight_requests:
|
||||
return _in_flight_requests[parent_request_id]
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def _add_in_flight_request(parent_request_id, response_id, replica_result):
|
||||
_in_flight_requests[parent_request_id][response_id] = replica_result
|
||||
|
||||
|
||||
def _remove_in_flight_request(parent_request_id, response_id):
|
||||
if response_id in _in_flight_requests[parent_request_id]:
|
||||
del _in_flight_requests[parent_request_id][response_id]
|
||||
|
||||
if len(_in_flight_requests[parent_request_id]) == 0:
|
||||
del _in_flight_requests[parent_request_id]
|
||||
@@ -0,0 +1,5 @@
|
||||
from ray.dag.input_node import InputNode
|
||||
|
||||
__all__ = [
|
||||
"InputNode",
|
||||
]
|
||||
@@ -0,0 +1,590 @@
|
||||
import warnings
|
||||
from copy import deepcopy
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from ray.serve._private.config import (
|
||||
DeploymentConfig,
|
||||
ReplicaConfig,
|
||||
RequestRouterConfig,
|
||||
handle_num_replicas_auto,
|
||||
)
|
||||
from ray.serve._private.usage import ServeUsageTag
|
||||
from ray.serve._private.utils import DEFAULT, Default
|
||||
from ray.serve.config import (
|
||||
AutoscalingConfig,
|
||||
DeploymentActorConfig,
|
||||
GangSchedulingConfig,
|
||||
)
|
||||
from ray.serve.schema import DeploymentSchema, LoggingConfig, RayActorOptionsSchema
|
||||
from ray.util.annotations import PublicAPI
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class Application:
|
||||
"""One or more deployments bound with arguments that can be deployed together.
|
||||
|
||||
Can be passed into another `Deployment.bind()` to compose multiple deployments in a
|
||||
single application, passed to `serve.run`, or deployed via a Serve config file.
|
||||
|
||||
For example, to define an Application and run it in Python:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from ray import serve
|
||||
from ray.serve import Application
|
||||
|
||||
@serve.deployment
|
||||
class MyDeployment:
|
||||
pass
|
||||
|
||||
app: Application = MyDeployment.bind(OtherDeployment.bind())
|
||||
serve.run(app)
|
||||
|
||||
To run the same app using the command line interface (CLI):
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
serve run python_file:app
|
||||
|
||||
To deploy the same app via a config file:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
applications:
|
||||
my_app:
|
||||
import_path: python_file:app
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, bound_deployment: "Deployment"):
|
||||
# This is used by `build_app`, but made private so users don't use it.
|
||||
self._bound_deployment = bound_deployment
|
||||
# Optional peer ingress request router for ingress bypass mode.
|
||||
self._ingress_request_router: Optional["Application"] = None
|
||||
|
||||
def _with_ingress_request_router(
|
||||
self, ingress_request_router: "Application"
|
||||
) -> "Application":
|
||||
# Internal-only, unstable hook for the Serve LLM direct-ingress stack.
|
||||
# This is not a stable public Serve API.
|
||||
self._ingress_request_router = ingress_request_router
|
||||
return self
|
||||
|
||||
|
||||
@PublicAPI(stability="stable")
|
||||
class Deployment:
|
||||
"""Class (or function) decorated with the `@serve.deployment` decorator.
|
||||
|
||||
This is run on a number of replica actors. Requests to those replicas call
|
||||
this class.
|
||||
|
||||
One or more deployments can be composed together into an `Application` which is
|
||||
then run via `serve.run` or a config file.
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
@serve.deployment
|
||||
class MyDeployment:
|
||||
def __init__(self, name: str):
|
||||
self._name = name
|
||||
|
||||
def __call__(self, request):
|
||||
return "Hello world!"
|
||||
|
||||
app = MyDeployment.bind()
|
||||
# Run via `serve.run` or the `serve run` CLI command.
|
||||
serve.run(app)
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
deployment_config: DeploymentConfig,
|
||||
replica_config: ReplicaConfig,
|
||||
version: Optional[str] = None,
|
||||
_internal: bool = False,
|
||||
) -> None:
|
||||
"""Construct a Deployment. Should only be called by Serve internals.
|
||||
|
||||
Args:
|
||||
name: Unique name of this deployment.
|
||||
deployment_config: Serve-level configuration (number of replicas,
|
||||
user config, autoscaling, etc.).
|
||||
replica_config: Replica-level configuration (actor options, init
|
||||
args/kwargs, etc.).
|
||||
version: Optional opaque deployment version used to determine
|
||||
whether replicas need to be restarted on update.
|
||||
_internal: Internal flag; ``Deployment`` instances must be created
|
||||
via the ``@serve.deployment`` decorator, which sets this to
|
||||
``True``.
|
||||
"""
|
||||
if not _internal:
|
||||
raise RuntimeError(
|
||||
"The Deployment constructor should not be called "
|
||||
"directly. Use `@serve.deployment` instead."
|
||||
)
|
||||
self._validate_name(name)
|
||||
if not (version is None or isinstance(version, str)):
|
||||
raise TypeError("version must be a string.")
|
||||
|
||||
self._name = name
|
||||
self._version = version
|
||||
self._deployment_config = deployment_config
|
||||
self._replica_config = replica_config
|
||||
|
||||
def _validate_name(self, name: str):
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("name must be a string.")
|
||||
|
||||
# name does not contain #
|
||||
if "#" in name:
|
||||
warnings.warn(
|
||||
f"Deployment names should not contain the '#' character, this will raise an error starting in Ray 2.46.0. "
|
||||
f"Current name: {name}."
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Unique name of this deployment."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def version(self) -> Optional[str]:
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def func_or_class(self) -> Union[Callable, str]:
|
||||
"""Underlying class or function that this deployment wraps."""
|
||||
return self._replica_config.deployment_def
|
||||
|
||||
@property
|
||||
def num_replicas(self) -> int:
|
||||
"""Target number of replicas."""
|
||||
return self._deployment_config.num_replicas
|
||||
|
||||
@property
|
||||
def user_config(self) -> Any:
|
||||
"""Dynamic user-provided config options."""
|
||||
return self._deployment_config.user_config
|
||||
|
||||
@property
|
||||
def max_ongoing_requests(self) -> int:
|
||||
"""Max number of requests a replica can handle at once."""
|
||||
return self._deployment_config.max_ongoing_requests
|
||||
|
||||
@property
|
||||
def max_queued_requests(self) -> int:
|
||||
"""Max number of requests that can be queued in each deployment handle."""
|
||||
return self._deployment_config.max_queued_requests
|
||||
|
||||
@property
|
||||
def ray_actor_options(self) -> Optional[Dict]:
|
||||
"""Actor options such as resources required for each replica."""
|
||||
return self._replica_config.ray_actor_options
|
||||
|
||||
@property
|
||||
def init_args(self) -> Tuple[Any]:
|
||||
return self._replica_config.init_args
|
||||
|
||||
@property
|
||||
def init_kwargs(self) -> Tuple[Any]:
|
||||
return self._replica_config.init_kwargs
|
||||
|
||||
@property
|
||||
def logging_config(self) -> Dict:
|
||||
return self._deployment_config.logging_config
|
||||
|
||||
def set_logging_config(self, logging_config: Dict):
|
||||
self._deployment_config.logging_config = logging_config
|
||||
|
||||
def __call__(self):
|
||||
raise RuntimeError(
|
||||
"Deployments cannot be constructed directly. "
|
||||
"Use `deployment.deploy() instead.`"
|
||||
)
|
||||
|
||||
def bind(self, *args, **kwargs) -> Application:
|
||||
"""Bind the arguments to the deployment and return an Application.
|
||||
|
||||
The returned Application can be deployed using `serve.run` (or via
|
||||
config file) or bound to another deployment for composition.
|
||||
"""
|
||||
return Application(self.options(_init_args=args, _init_kwargs=kwargs))
|
||||
|
||||
def options(
|
||||
self,
|
||||
func_or_class: Optional[Callable] = None,
|
||||
name: Default[str] = DEFAULT.VALUE,
|
||||
version: Default[str] = DEFAULT.VALUE,
|
||||
num_replicas: Default[Optional[Union[int, str]]] = DEFAULT.VALUE,
|
||||
ray_actor_options: Default[Optional[Dict]] = DEFAULT.VALUE,
|
||||
placement_group_bundles: Default[List[Dict[str, float]]] = DEFAULT.VALUE,
|
||||
placement_group_strategy: Default[str] = DEFAULT.VALUE,
|
||||
placement_group_bundle_label_selector: Default[
|
||||
List[Dict[str, str]]
|
||||
] = DEFAULT.VALUE,
|
||||
max_replicas_per_node: Default[int] = DEFAULT.VALUE,
|
||||
user_config: Default[Optional[Any]] = DEFAULT.VALUE,
|
||||
max_ongoing_requests: Default[int] = DEFAULT.VALUE,
|
||||
max_queued_requests: Default[int] = DEFAULT.VALUE,
|
||||
autoscaling_config: Default[
|
||||
Union[Dict, AutoscalingConfig, None]
|
||||
] = DEFAULT.VALUE,
|
||||
graceful_shutdown_wait_loop_s: Default[float] = DEFAULT.VALUE,
|
||||
graceful_shutdown_timeout_s: Default[float] = DEFAULT.VALUE,
|
||||
health_check_period_s: Default[float] = DEFAULT.VALUE,
|
||||
health_check_timeout_s: Default[float] = DEFAULT.VALUE,
|
||||
logging_config: Default[Union[Dict, LoggingConfig, None]] = DEFAULT.VALUE,
|
||||
request_router_config: Default[
|
||||
Union[Dict, RequestRouterConfig, None]
|
||||
] = DEFAULT.VALUE,
|
||||
_init_args: Default[Tuple[Any]] = DEFAULT.VALUE,
|
||||
_init_kwargs: Default[Dict[Any, Any]] = DEFAULT.VALUE,
|
||||
_internal: bool = False,
|
||||
max_constructor_retry_count: Default[int] = DEFAULT.VALUE,
|
||||
gang_scheduling_config: Default[
|
||||
Union[Dict, GangSchedulingConfig, None]
|
||||
] = DEFAULT.VALUE,
|
||||
deployment_actors: Default[
|
||||
Optional[List[Union[Dict, DeploymentActorConfig]]]
|
||||
] = DEFAULT.VALUE,
|
||||
) -> "Deployment":
|
||||
"""Return a copy of this deployment with updated options.
|
||||
|
||||
Only those options passed in will be updated, all others will remain
|
||||
unchanged from the existing deployment.
|
||||
|
||||
Refer to the `@serve.deployment` decorator docs for available arguments.
|
||||
"""
|
||||
if not _internal and version is not DEFAULT.VALUE:
|
||||
raise ValueError(
|
||||
"`version` in `Deployment.options()` has been removed. "
|
||||
"Serve manages deployment versions internally."
|
||||
)
|
||||
|
||||
# Modify max_ongoing_requests and autoscaling_config if
|
||||
# `num_replicas="auto"`
|
||||
if max_ongoing_requests is None:
|
||||
raise ValueError("`max_ongoing_requests` must be non-null, got None.")
|
||||
|
||||
if num_replicas == "auto":
|
||||
max_ongoing_requests, autoscaling_config = handle_num_replicas_auto(
|
||||
max_ongoing_requests, autoscaling_config
|
||||
)
|
||||
|
||||
ServeUsageTag.AUTO_NUM_REPLICAS_USED.record("1")
|
||||
|
||||
# NOTE: The user_configured_option_names should be the first thing that's
|
||||
# defined in this method. It depends on the locals() dictionary storing
|
||||
# only the function args/kwargs.
|
||||
# Create list of all user-configured options from keyword args
|
||||
user_configured_option_names = [
|
||||
option
|
||||
for option, value in locals().items()
|
||||
if option not in {"self", "func_or_class", "_internal"}
|
||||
and value is not DEFAULT.VALUE
|
||||
]
|
||||
|
||||
new_deployment_config = deepcopy(self._deployment_config)
|
||||
if not _internal:
|
||||
new_deployment_config.user_configured_option_names.update(
|
||||
user_configured_option_names
|
||||
)
|
||||
|
||||
if num_replicas not in [
|
||||
DEFAULT.VALUE,
|
||||
None,
|
||||
"auto",
|
||||
] and autoscaling_config not in [
|
||||
DEFAULT.VALUE,
|
||||
None,
|
||||
]:
|
||||
raise ValueError(
|
||||
"Manually setting num_replicas is not allowed when "
|
||||
"autoscaling_config is provided."
|
||||
)
|
||||
|
||||
if num_replicas == 0:
|
||||
raise ValueError("num_replicas is expected to larger than 0")
|
||||
|
||||
if num_replicas not in [DEFAULT.VALUE, None, "auto"]:
|
||||
new_deployment_config.num_replicas = num_replicas
|
||||
|
||||
if user_config is not DEFAULT.VALUE:
|
||||
new_deployment_config.user_config = user_config
|
||||
|
||||
if max_ongoing_requests is not DEFAULT.VALUE:
|
||||
new_deployment_config.max_ongoing_requests = max_ongoing_requests
|
||||
|
||||
if max_queued_requests is not DEFAULT.VALUE:
|
||||
new_deployment_config.max_queued_requests = max_queued_requests
|
||||
|
||||
if max_constructor_retry_count is not DEFAULT.VALUE:
|
||||
new_deployment_config.max_constructor_retry_count = (
|
||||
max_constructor_retry_count
|
||||
)
|
||||
|
||||
if func_or_class is None:
|
||||
func_or_class = self._replica_config.deployment_def
|
||||
|
||||
if name is DEFAULT.VALUE:
|
||||
name = self._name
|
||||
|
||||
if version is DEFAULT.VALUE:
|
||||
version = self._version
|
||||
|
||||
if _init_args is DEFAULT.VALUE:
|
||||
_init_args = self._replica_config.init_args
|
||||
|
||||
if _init_kwargs is DEFAULT.VALUE:
|
||||
_init_kwargs = self._replica_config.init_kwargs
|
||||
|
||||
if ray_actor_options is DEFAULT.VALUE:
|
||||
ray_actor_options = self._replica_config.ray_actor_options
|
||||
|
||||
if placement_group_bundles is DEFAULT.VALUE:
|
||||
placement_group_bundles = self._replica_config.placement_group_bundles
|
||||
|
||||
if placement_group_strategy is DEFAULT.VALUE:
|
||||
placement_group_strategy = self._replica_config.placement_group_strategy
|
||||
|
||||
if placement_group_bundle_label_selector is DEFAULT.VALUE:
|
||||
placement_group_bundle_label_selector = (
|
||||
self._replica_config.placement_group_bundle_label_selector
|
||||
)
|
||||
|
||||
# TODO(ryanaoleary@): Add conditional check once fallback_strategy is
|
||||
# added to placement group options.
|
||||
placement_group_fallback_strategy = (
|
||||
self._replica_config.placement_group_fallback_strategy
|
||||
)
|
||||
|
||||
if max_replicas_per_node is DEFAULT.VALUE:
|
||||
max_replicas_per_node = self._replica_config.max_replicas_per_node
|
||||
|
||||
if autoscaling_config is not DEFAULT.VALUE:
|
||||
new_deployment_config.autoscaling_config = autoscaling_config
|
||||
|
||||
if request_router_config is not DEFAULT.VALUE:
|
||||
new_deployment_config.request_router_config = request_router_config
|
||||
|
||||
if graceful_shutdown_wait_loop_s is not DEFAULT.VALUE:
|
||||
new_deployment_config.graceful_shutdown_wait_loop_s = (
|
||||
graceful_shutdown_wait_loop_s
|
||||
)
|
||||
|
||||
if graceful_shutdown_timeout_s is not DEFAULT.VALUE:
|
||||
new_deployment_config.graceful_shutdown_timeout_s = (
|
||||
graceful_shutdown_timeout_s
|
||||
)
|
||||
|
||||
if health_check_period_s is not DEFAULT.VALUE:
|
||||
new_deployment_config.health_check_period_s = health_check_period_s
|
||||
|
||||
if health_check_timeout_s is not DEFAULT.VALUE:
|
||||
new_deployment_config.health_check_timeout_s = health_check_timeout_s
|
||||
|
||||
if logging_config is not DEFAULT.VALUE:
|
||||
if isinstance(logging_config, LoggingConfig):
|
||||
logging_config = logging_config.model_dump()
|
||||
new_deployment_config.logging_config = logging_config
|
||||
|
||||
if gang_scheduling_config is not DEFAULT.VALUE:
|
||||
new_deployment_config.gang_scheduling_config = gang_scheduling_config
|
||||
|
||||
if deployment_actors is not DEFAULT.VALUE:
|
||||
new_deployment_config.deployment_actors = deployment_actors
|
||||
|
||||
gc = new_deployment_config.gang_scheduling_config
|
||||
if (
|
||||
gc is not None
|
||||
and isinstance(new_deployment_config.num_replicas, int)
|
||||
and new_deployment_config.autoscaling_config is None
|
||||
):
|
||||
# When autoscaling is enabled, num_replicas defaults to 1
|
||||
if new_deployment_config.num_replicas % gc.gang_size != 0:
|
||||
raise ValueError(
|
||||
f"num_replicas ({new_deployment_config.num_replicas}) must "
|
||||
f"be a multiple of gang_size ({gc.gang_size})."
|
||||
)
|
||||
|
||||
if gc is not None and max_replicas_per_node is not None:
|
||||
raise ValueError(
|
||||
"Setting max_replicas_per_node is not allowed when "
|
||||
"gang_scheduling_config is provided."
|
||||
)
|
||||
|
||||
if gc is not None and placement_group_strategy is not None:
|
||||
raise ValueError(
|
||||
"Setting placement_group_strategy is not allowed when "
|
||||
"gang_scheduling_config is provided. Use "
|
||||
"gang_scheduling_config.gang_placement_strategy instead."
|
||||
)
|
||||
|
||||
new_replica_config = ReplicaConfig.create(
|
||||
func_or_class,
|
||||
init_args=_init_args,
|
||||
init_kwargs=_init_kwargs,
|
||||
ray_actor_options=ray_actor_options,
|
||||
placement_group_bundles=placement_group_bundles,
|
||||
placement_group_strategy=placement_group_strategy,
|
||||
placement_group_bundle_label_selector=placement_group_bundle_label_selector,
|
||||
placement_group_fallback_strategy=placement_group_fallback_strategy,
|
||||
max_replicas_per_node=max_replicas_per_node,
|
||||
)
|
||||
|
||||
return Deployment(
|
||||
name,
|
||||
new_deployment_config,
|
||||
new_replica_config,
|
||||
version=version,
|
||||
_internal=True,
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
return all(
|
||||
[
|
||||
self._name == other._name,
|
||||
self._version == other._version,
|
||||
self._deployment_config == other._deployment_config,
|
||||
self._replica_config.init_args == other._replica_config.init_args,
|
||||
self._replica_config.init_kwargs == other._replica_config.init_kwargs,
|
||||
self._replica_config.ray_actor_options
|
||||
== other._replica_config.ray_actor_options,
|
||||
]
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"Deployment(name={self._name})"
|
||||
|
||||
def __repr__(self):
|
||||
return str(self)
|
||||
|
||||
|
||||
def deployment_to_schema(d: Deployment) -> DeploymentSchema:
|
||||
"""Converts a live deployment object to a corresponding structured schema.
|
||||
|
||||
Args:
|
||||
d: Deployment object to convert
|
||||
|
||||
Returns:
|
||||
The structured ``DeploymentSchema`` representing ``d``.
|
||||
"""
|
||||
|
||||
if d.ray_actor_options is not None:
|
||||
ray_actor_options_schema = RayActorOptionsSchema.model_validate(
|
||||
d.ray_actor_options
|
||||
)
|
||||
else:
|
||||
ray_actor_options_schema = None
|
||||
|
||||
deployment_options = {
|
||||
"name": d.name,
|
||||
"num_replicas": None
|
||||
if d._deployment_config.autoscaling_config
|
||||
else d.num_replicas,
|
||||
"max_ongoing_requests": d.max_ongoing_requests,
|
||||
"max_queued_requests": d.max_queued_requests,
|
||||
"user_config": d.user_config,
|
||||
"autoscaling_config": d._deployment_config.autoscaling_config,
|
||||
"graceful_shutdown_wait_loop_s": d._deployment_config.graceful_shutdown_wait_loop_s, # noqa: E501
|
||||
"graceful_shutdown_timeout_s": d._deployment_config.graceful_shutdown_timeout_s,
|
||||
"health_check_period_s": d._deployment_config.health_check_period_s,
|
||||
"health_check_timeout_s": d._deployment_config.health_check_timeout_s,
|
||||
"ray_actor_options": ray_actor_options_schema,
|
||||
"placement_group_strategy": d._replica_config.placement_group_strategy,
|
||||
"placement_group_bundles": d._replica_config.placement_group_bundles,
|
||||
"max_replicas_per_node": d._replica_config.max_replicas_per_node,
|
||||
"logging_config": d._deployment_config.logging_config,
|
||||
"request_router_config": d._deployment_config.request_router_config,
|
||||
"gang_scheduling_config": d._deployment_config.gang_scheduling_config,
|
||||
"deployment_actors": d._deployment_config.deployment_actors,
|
||||
"rolling_update_percentage": d._deployment_config.rolling_update_percentage,
|
||||
}
|
||||
|
||||
# Let non-user-configured options be set to defaults. If the schema
|
||||
# is converted back to a deployment, this lets Serve continue tracking
|
||||
# which options were set by the user. Name is a required field in the
|
||||
# schema, so it should be passed in explicitly.
|
||||
for option in list(deployment_options.keys()):
|
||||
if (
|
||||
option != "name"
|
||||
and option not in d._deployment_config.user_configured_option_names
|
||||
):
|
||||
del deployment_options[option]
|
||||
|
||||
# TODO(Sihan) DeploymentConfig num_replicas and auto_config can be set together
|
||||
# because internally we use these two field for autoscale and deploy.
|
||||
# We can improve the code after we separate the user faced deployment config and
|
||||
# internal deployment config.
|
||||
return DeploymentSchema(**deployment_options)
|
||||
|
||||
|
||||
def schema_to_deployment(s: DeploymentSchema) -> Deployment:
|
||||
"""Creates a deployment with parameters specified in schema.
|
||||
|
||||
The returned deployment CANNOT be deployed immediately. It's func_or_class
|
||||
value is an empty string (""), which is not a valid import path. The
|
||||
func_or_class value must be overwritten with a valid function or class
|
||||
before the deployment can be deployed.
|
||||
"""
|
||||
|
||||
if s.ray_actor_options is DEFAULT.VALUE:
|
||||
ray_actor_options = None
|
||||
else:
|
||||
ray_actor_options = s.ray_actor_options.model_dump(exclude_unset=True)
|
||||
|
||||
if s.placement_group_bundles is DEFAULT.VALUE:
|
||||
placement_group_bundles = None
|
||||
else:
|
||||
placement_group_bundles = s.placement_group_bundles
|
||||
|
||||
if s.placement_group_strategy is DEFAULT.VALUE:
|
||||
placement_group_strategy = None
|
||||
else:
|
||||
placement_group_strategy = s.placement_group_strategy
|
||||
|
||||
if s.max_replicas_per_node is DEFAULT.VALUE:
|
||||
max_replicas_per_node = None
|
||||
else:
|
||||
max_replicas_per_node = s.max_replicas_per_node
|
||||
|
||||
deployment_config = DeploymentConfig.from_default(
|
||||
num_replicas=s.num_replicas,
|
||||
user_config=s.user_config,
|
||||
max_ongoing_requests=s.max_ongoing_requests,
|
||||
max_queued_requests=s.max_queued_requests,
|
||||
autoscaling_config=s.autoscaling_config,
|
||||
graceful_shutdown_wait_loop_s=s.graceful_shutdown_wait_loop_s,
|
||||
graceful_shutdown_timeout_s=s.graceful_shutdown_timeout_s,
|
||||
health_check_period_s=s.health_check_period_s,
|
||||
health_check_timeout_s=s.health_check_timeout_s,
|
||||
logging_config=s.logging_config,
|
||||
request_router_config=s.request_router_config,
|
||||
gang_scheduling_config=s.gang_scheduling_config,
|
||||
deployment_actors=s.deployment_actors,
|
||||
rolling_update_percentage=s.rolling_update_percentage,
|
||||
)
|
||||
deployment_config.user_configured_option_names = (
|
||||
s._get_user_configured_option_names()
|
||||
)
|
||||
|
||||
replica_config = ReplicaConfig.create(
|
||||
deployment_def="",
|
||||
init_args=(),
|
||||
init_kwargs={},
|
||||
ray_actor_options=ray_actor_options,
|
||||
placement_group_bundles=placement_group_bundles,
|
||||
placement_group_strategy=placement_group_strategy,
|
||||
max_replicas_per_node=max_replicas_per_node,
|
||||
)
|
||||
|
||||
return Deployment(
|
||||
name=s.name,
|
||||
deployment_config=deployment_config,
|
||||
replica_config=replica_config,
|
||||
_internal=True,
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user