chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
+886
View File
@@ -0,0 +1,886 @@
load("@rules_python//python:defs.bzl", "py_library", "py_test")
load("//bazel:python.bzl", "py_test_module_list", "py_test_module_list_with_env_variants")
py_library(
name = "conftest",
srcs = ["conftest.py"],
)
py_library(
name = "common",
srcs = glob(["common/*.py"]),
visibility = [
"//python/ray/serve/tests:__subpackages__",
],
)
# Minimal installation test (should *not* include conftest).
py_test_module_list(
size = "small",
files = [
"test_minimal_installation.py",
],
tags = [
"exclusive",
"minimal",
"team:serve",
],
deps = [
"//python/ray/serve:serve_lib",
],
)
# Custom metrics tests.
py_test_module_list_with_env_variants(
size = "medium",
env_variants = {
"metr_agg_at_controller": {
"env": {
"RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER": "1",
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "0",
"RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR": "0.001",
"RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S": "3",
},
"name_suffix": "_metr_agg_at_controller",
},
"metr_agg_at_replicas": {
"env": {
"RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER": "0",
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "0",
"RAY_SERVE_AUTOSCALING_METRIC_RECORD_INTERVAL_FACTOR": "0.001",
"RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S": "3",
},
"name_suffix": "_metr_agg_at_replicas",
},
},
files = [
"test_custom_autoscaling_metrics.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Small tests.
py_test_module_list(
size = "small",
files = [
"test_advanced.py",
"test_cluster_node_info_cache.py",
"test_constructor_failure.py",
"test_deployment_version.py",
"test_enable_task_events.py",
"test_expected_versions.py",
"test_http_cancellation.py",
"test_kv_store.py",
"test_long_poll.py",
"test_persistence.py",
"test_proxy_actor_wrapper.py",
"test_replica_request_context.py",
"test_serve_with_tracing.py",
"test_websockets.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Medium tests.
py_test_module_list(
size = "medium",
files = [
"test_actor_replica_wrapper.py",
"test_backpressure.py",
"test_backpressure_grpc.py",
"test_batching.py",
"test_broadcast.py",
"test_callback.py",
"test_cluster.py",
"test_controller.py",
"test_controller_benchmark.py",
"test_controller_recovery.py",
"test_deploy_2.py",
"test_deployment_scheduler_downscale.py",
"test_deployment_topology.py",
"test_failure.py",
"test_failure_2.py",
"test_grpc_e2e.py",
"test_grpc_replica_wrapper.py",
"test_handle.py",
"test_handle_1.py",
"test_handle_2.py",
"test_handle_cancellation.py",
"test_handle_streaming.py",
"test_healthcheck.py",
"test_http_headers.py",
"test_http_routes.py",
"test_https_proxy.py",
"test_list_outbound_deployments.py",
"test_max_replicas_per_node.py",
"test_multiplex.py",
"test_proxy.py",
"test_proxy_response_generator.py",
"test_queue_monitor.py",
"test_ray_client.py",
"test_record_replica_metadata.py",
"test_record_routing_stats.py",
"test_regression.py",
"test_replica_placement_group.py",
"test_request_timeout.py",
"test_round_robin_router.py",
"test_streaming_response.py",
"test_task_consumer_autoscaling.py",
"test_task_processor.py",
"test_util.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests.
py_test_module_list(
size = "large",
files = [
"test_deployment_scheduler.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Medium tests, don't run on windows.
py_test_module_list(
size = "medium",
env = {
"RAY_SERVE_FAIL_ON_RANK_ERROR": "1",
},
files = [
"test_gcs_failure.py",
"test_gradio.py",
"test_replica_ranks.py",
],
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Tracing tests (require fixture data and tracing env var for subprocesses).
py_test_module_list(
size = "medium",
data = glob(["fixtures/*.*"]),
env = {
"RAY_SERVE_TRACING_EXPORTER_IMPORT_PATH": "ray.serve._private.tracing_utils:default_tracing_exporter",
"RAY_SERVE_TRACING_SAMPLING_RATIO": "1.0",
},
files = [
"test_tracing_utils.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests.
py_test_module_list(
size = "enormous",
data = glob(["test_config_files/**/*"]),
files = [
"test_autoscaling_policy.py",
"test_deploy.py",
"test_fastapi.py",
"test_grpc.py",
"test_logging.py",
"test_logging_2.py",
"test_shutdown.py",
"test_standalone.py",
"test_standalone_3.py",
"test_target_capacity.py",
"test_telemetry.py",
"test_telemetry_1.py",
"test_telemetry_2.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Logging tests with client IP logging enabled.
py_test_module_list(
size = "enormous",
env = {"RAY_SERVE_LOG_CLIENT_ADDRESS": "1"},
files = [
"test_logging.py",
"test_logging_2.py",
],
name_suffix = "_with_client_address_logging",
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Don't run gang scheduling tests on Windows
py_test_module_list(
size = "enormous",
files = [
"test_gang_scheduling.py",
],
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests requiring `test_config_files/`.
py_test_module_list(
size = "large",
data = glob(["test_config_files/**/*"]),
files = [
"test_capacity_queue_router.py",
"test_cli.py",
"test_cli_2.py",
"test_cli_3.py",
"test_cli_4.py",
"test_consistent_hash_router.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests require `test_config_files/`, no windows.
py_test_module_list(
size = "large",
data = glob(["test_config_files/**/*"]),
files = [
"test_standalone_2.py",
],
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Run serially on Windows.
py_test_module_list(
size = "medium",
timeout = "long",
data = glob(["test_config_files/**/*"]),
env = {
"RAY_SERVE_ROUTER_QUEUE_LEN_GAUGE_THROTTLE_S": "0",
"RAY_SERVE_RUN_SYNC_IN_THREADPOOL": "1",
"RAY_SERVE_REPLICA_UTILIZATION_WINDOW_S": "5",
"RAY_SERVE_REPLICA_UTILIZATION_REPORT_INTERVAL_S": "1",
"RAY_SERVE_REPLICA_UTILIZATION_NUM_BUCKETS": "10",
"RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_WINDOW_S": "5",
"RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_REPORT_INTERVAL_S": "1",
"RAY_SERVE_REPLICA_MAX_PROCESSING_LATENCY_NUM_BUCKETS": "5",
"RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S": "0.1",
},
files = [
"test_deploy_app.py",
"test_deploy_app_2.py",
"test_metrics.py",
"test_metrics_2.py",
"test_metrics_3.py",
],
tags = [
"exclusive",
"team:serve",
"use_all_core_windows",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Run the controller metric high-cardinality opt-out test with the flag disabled.
py_test_module_list(
size = "medium",
args = [
"-k",
"test_disable_high_cardinality_controller_metrics",
],
env = {
"RAY_SERVE_CONTROLLER_METRICS_INCLUDE_HIGH_CARDINALITY_TAGS": "0",
},
files = [
"test_metrics.py",
],
name_suffix = "_controller_metrics_without_high_cardinality",
tags = [
"exclusive",
"team:serve",
"use_all_core_windows",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Deployment actors tests: split into own target with size=large because
# the test file has 39 tests including heavyweight crash-recovery tests
# with 120s waits that exceed the medium/long (900s) timeout under CI load.
py_test_module_list(
size = "large",
data = glob(["test_config_files/**/*"]),
env = {
"RAY_SERVE_ROUTER_QUEUE_LEN_GAUGE_THROTTLE_S": "0",
"RAY_SERVE_RUN_SYNC_IN_THREADPOOL": "1",
"RAY_SERVE_REPLICA_UTILIZATION_WINDOW_S": "5",
"RAY_SERVE_REPLICA_UTILIZATION_REPORT_INTERVAL_S": "1",
"RAY_SERVE_REPLICA_UTILIZATION_NUM_BUCKETS": "10",
"RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S": "0.1",
},
files = [
"test_deployment_actors.py",
"test_deployment_actors_recovery.py",
],
tags = [
"exclusive",
"team:serve",
"use_all_core_windows",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Minimal tests
py_test_module_list(
size = "large",
files = [
"test_api.py",
],
tags = [
"exclusive",
"minimal",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# API tests that run faster
py_test_module_list(
size = "small",
files = [
"test_api_2.py",
],
tags = [
"exclusive",
"minimal",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Model composition test that needs medium size
py_test_module_list(
size = "medium",
timeout = "moderate",
files = [
"test_model_composition.py",
],
tags = [
"exclusive",
"minimal",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Post-wheel-build tests.
py_test_module_list(
size = "large",
files = [
"test_runtime_env.py",
"test_runtime_env_2.py",
],
tags = [
"custom_setup",
"exclusive",
"post_wheel_build",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Runs test_api and test_failure with injected failures in the controller.
py_test(
name = "test_controller_crashes",
size = "large",
srcs = [
"test_api.py",
"test_api_2.py",
"test_controller_crashes.py",
"test_failure.py",
"test_failure_2.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Serve HA.
py_test(
name = "test_serve_ha",
size = "medium",
srcs = ["test_serve_ha.py"],
tags = [
"custom_setup",
"exclusive",
"ha_integration",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# ----- TEST FEATURE FLAGS -----
# Test autoscaling with different metric collection configurations
AUTOSCALING_METRIC_ENV_VARIANTS = {
"metr_disab": {
"env": {
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "0",
# Make sure queued metrics are cleared out quickly.
"RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S": "0.1",
},
"name_suffix": "_metr_disab",
},
"metr_agg_at_controller": {
"env": {
"RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER": "1",
# Make sure queued metrics are cleared out quickly.
"RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S": "0.1",
},
"name_suffix": "_metr_agg_at_controller",
},
"metr_agg_at_controller_and_replicas": {
"env": {
"RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER": "1",
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "0",
# Make sure queued metrics are cleared out quickly.
"RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S": "0.1",
},
"name_suffix": "_metr_agg_at_controller_and_replicas",
},
}
py_test_module_list_with_env_variants(
size = "large",
env_variants = AUTOSCALING_METRIC_ENV_VARIANTS,
files = [
"test_autoscaling_policy.py",
"test_deploy.py",
"test_standalone_3.py",
"test_target_capacity.py",
],
tags = [
"autoscaling",
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Test autoscaling with streaming and unary configurations.
# Regression test for https://github.com/ray-project/ray/issues/61551
AUTOSCALING_ENV_VARIANTS = {
"env_overrides": {
"env": {
"RAY_SERVE_LOG_TO_STDERR": "0",
"RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP": "0",
"RAY_SERVE_USE_GRPC_BY_DEFAULT": "1",
},
"name_suffix": "_env_overrides",
},
"throughput_optimized": {
"env": {
"RAY_SERVE_THROUGHPUT_OPTIMIZED": "1",
},
"name_suffix": "_throughput_optimized",
},
}
py_test_module_list_with_env_variants(
size = "large",
args = [
"-k",
"TestAutoscalingWithRejection",
],
env_variants = AUTOSCALING_ENV_VARIANTS,
files = [
"test_autoscaling_policy.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Test feature flag for task events.
py_test_module_list(
size = "small",
data = glob(["test_config_files/**/*"]),
env = {"RAY_SERVE_ENABLE_TASK_EVENTS": "1"},
files = [
"test_enable_task_events.py",
],
name_suffix = "_with_task_events_enabled",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Medium tests with pack scheduling
py_test_module_list(
size = "medium",
data = glob(["test_config_files/**/*"]),
env = {"RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY": "1"},
files = [
"test_cluster.py",
"test_controller_recovery.py",
"test_gcs_failure.py",
"test_max_replicas_per_node.py",
"test_replica_placement_group.py",
],
name_suffix = "_with_pack_scheduling",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests with pack scheduling
py_test_module_list(
size = "enormous",
env = {"RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY": "1"},
files = [
"test_standalone.py",
"test_standalone_3.py",
],
name_suffix = "_with_pack_sche",
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Large tests with pack scheduling, no windows
py_test_module_list(
size = "large",
data = glob(["test_config_files/**/*"]),
env = {"RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY": "1"},
files = [
"test_deployment_scheduler.py",
"test_deployment_scheduler_downscale.py",
"test_gang_scheduling.py",
"test_standalone_2.py",
],
name_suffix = "_with_pack_scheduling",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Test handle API with local testing mode.
py_test_module_list(
size = "small",
env = {"RAY_SERVE_FORCE_LOCAL_TESTING_MODE": "1"},
files = [
"test_handle_1.py",
"test_handle_2.py",
"test_handle_cancellation.py",
"test_handle_streaming.py",
],
name_suffix = "_with_local_testing_mode",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# Test currently off-by-default behavior to run replica sync methods in a threadpool.
# TODO(edoakes): remove this once the FF is flipped on by default.
py_test_module_list(
size = "medium",
env = {"RAY_SERVE_RUN_SYNC_IN_THREADPOOL": "1"},
files = [
"test_replica_sync_methods.py",
],
name_suffix = "_with_run_sync_in_threadpool",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
py_test_module_list(
size = "medium",
env = {"RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP": "0"},
files = [
"test_handle_same_loop.py",
"test_proxy.py",
],
name_suffix = "_with_router_in_same_loop",
tags = [
"exclusive",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
py_test_module_list(
size = "large",
files = [
"test_direct_ingress.py",
],
tags = [
"direct_ingress",
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
py_test_module_list(
size = "large",
timeout = "eternal",
files = [
"test_per_request_headers.py",
],
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
py_test_module_list(
size = "large",
env = {"RAY_SERVE_USE_GRPC_BY_DEFAULT": "1"},
files = [
"test_direct_ingress.py",
],
name_suffix = "_with_grpc",
tags = [
"direct_ingress",
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
py_test_module_list(
size = "large",
timeout = "eternal",
env = {"RAY_SERVE_USE_GRPC_BY_DEFAULT": "1"},
files = [
"test_per_request_headers.py",
],
name_suffix = "_with_grpc",
tags = [
"exclusive",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
# HAProxy tests need RAY_SERVE_ENABLE_HA_PROXY=1. The HAProxy binary comes from
# the ray-haproxy package in the serve test dependencies.
py_test_module_list_with_env_variants(
size = "large",
env_variants = {
"system": {
"env": {
"RAY_SERVE_ENABLE_HA_PROXY": "1",
"RAY_SERVE_DIRECT_INGRESS_MIN_DRAINING_PERIOD_S": "0.01",
"RAY_SERVE_STATUS_GAUGE_REPORT_INTERVAL_S": "0.1",
},
"name_suffix": "",
},
},
files = [
"test_haproxy.py",
"test_haproxy_api.py",
"test_haproxy_metrics.py",
"test_metrics_haproxy.py",
],
tags = [
"exclusive",
"haproxy",
"no_windows",
"team:serve",
],
deps = [
":common",
":conftest",
"//python/ray/serve:serve_lib",
],
)
View File
@@ -0,0 +1,11 @@
# ruff: noqa
"""
This file contains links to pinned versions of remote URIs used in testing.
All tests should use pinned versions to avoid accidental breakages.
"""
TEST_DAG_PINNED_URI = "https://github.com/ray-project/test_dag/archive/78b4a5da38796123d9f9ffff59bab2792a043e95.zip"
TEST_DEPLOY_GROUP_PINNED_URI = "https://github.com/ray-project/test_deploy_group/archive/67971777e225600720f91f618cdfe71fc47f60ee.zip"
TEST_MODULE_PINNED_URI = "https://github.com/ray-project/test_module/archive/aa6f366f7daa78c98408c27d917a983caa9f888b.zip"
TEST_RUNTIME_ENV_PINNED_URI = "https://github.com/ray-project/test_runtime_env/archive/a82f5fb9e5ddd417aebd18fd6b28caeabf252a37.zip"
@@ -0,0 +1,96 @@
"""
Ray decorated classes and functions defined at top of file, importable with
fully qualified name as import_path to test DAG building, artifact generation
and structured deployment.
"""
import asyncio
from typing import Dict, Union
from ray import serve
from ray.actor import ActorHandle
from ray.serve.handle import DeploymentHandle
NESTED_HANDLE_KEY = "nested_handle"
@serve.deployment
class ClassHello:
def __init__(self):
pass
def hello(self):
return "hello"
@serve.deployment
class Model:
def __init__(self, weight: int, ratio: float = None):
self.weight = weight
self.ratio = ratio or 1
def forward(self, input: int):
return self.ratio * self.weight * input
def __call__(self, request):
input_data = request
return self.ratio * self.weight * input_data
@serve.deployment
class Combine:
def __init__(
self,
m1: DeploymentHandle,
m2: Union[DeploymentHandle, Dict[str, DeploymentHandle]],
m2_nested: bool = False,
):
self.m1 = m1
self.m2 = m2.get(NESTED_HANDLE_KEY) if m2_nested else m2
async def __call__(self, req):
if isinstance(self.m1, ActorHandle) and isinstance(self.m2, ActorHandle):
r1_ref = self.m1.forward.remote(req)
r2_ref = self.m2.forward.remote(req)
else:
r1_ref = await self.m1.forward.remote(req)._to_object_ref()
r2_ref = await self.m2.forward.remote(req)._to_object_ref()
return sum(await asyncio.gather(r1_ref, r2_ref))
@serve.deployment
class Counter:
def __init__(self, val):
self.val = val
def get(self):
return self.val
def inc(self, inc):
self.val += inc
@serve.deployment
def fn_hello():
return "hello"
@serve.deployment
def fn(val, incr=0):
return val + incr
@serve.deployment
def combine(m1_output, m2_output, kwargs_output=0):
return m1_output + m2_output + kwargs_output
def class_factory():
class MyInlineClass:
def __init__(self, val):
self.val = val
def get(self):
return self.val
return MyInlineClass
+458
View File
@@ -0,0 +1,458 @@
import os
import random
import socket
import subprocess
import tempfile
from contextlib import contextmanager
from copy import deepcopy
from typing import Any, Dict, Generator
import httpx
import pytest
import pytest_asyncio
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray._common.usage import usage_lib
from ray._common.utils import reset_ray_address
from ray.cluster_utils import AutoscalingCluster, Cluster
from ray.serve._private.test_utils import (
TELEMETRY_ROUTE_PREFIX,
TEST_METRICS_EXPORT_PORT,
check_ray_started,
check_ray_stopped,
start_telemetry_app,
)
from ray.serve.config import HTTPOptions, ProxyLocation, gRPCOptions
from ray.serve.context import _get_global_client
from ray.tests.conftest import ( # noqa
external_redis,
propagate_logs,
pytest_runtest_makereport,
)
# https://tools.ietf.org/html/rfc6335#section-6
MIN_DYNAMIC_PORT = 49152
MAX_DYNAMIC_PORT = 65535
TEST_GRPC_SERVICER_FUNCTIONS = [
"ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server",
"ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server",
]
if os.environ.get("RAY_SERVE_INTENTIONALLY_CRASH", False) == 1:
serve.controller._CRASH_AFTER_CHECKPOINT_PROBABILITY = 0.5
@pytest.fixture(autouse=True)
def _clear_stale_ray_address():
# Serve CI runs several test targets per container sharing /tmp/ray; a target
# killed mid-run can leave a ray_current_cluster pointing at a dead cluster.
# Drop it before each test so an address-less ray.init() starts fresh.
reset_ray_address()
yield
@pytest.fixture
def ray_shutdown():
serve.shutdown()
if ray.is_initialized():
ray.shutdown()
yield
serve.shutdown()
if ray.is_initialized():
ray.shutdown()
@pytest.fixture
def ray_cluster():
cluster = Cluster()
yield cluster
serve.shutdown()
ray.shutdown()
cluster.shutdown()
@pytest.fixture
def ray_autoscaling_cluster(request):
# NOTE(zcin): We have to make a deepcopy here because AutoscalingCluster
# modifies the dictionary that's passed in.
params = deepcopy(request.param)
cluster = AutoscalingCluster(**params)
cluster.start()
yield
serve.shutdown()
ray.shutdown()
cluster.shutdown()
@pytest.fixture
def ray_start(scope="module"):
port = random.randint(MIN_DYNAMIC_PORT, MAX_DYNAMIC_PORT)
subprocess.check_output(
[
"ray",
"start",
"--head",
"--num-cpus",
"16",
"--ray-client-server-port",
f"{port}",
]
)
try:
yield f"localhost:{port}"
finally:
subprocess.check_output(["ray", "stop", "--force"])
def _check_ray_stop():
try:
httpx.get("http://localhost:8265/api/ray/version")
return False
except Exception:
return True
@contextmanager
def start_and_shutdown_ray_cli():
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(_check_ray_stop, timeout=15)
subprocess.check_output(["ray", "start", "--head"])
yield
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(_check_ray_stop, timeout=15)
@pytest.fixture(scope="module")
def start_and_shutdown_ray_cli_module():
with start_and_shutdown_ray_cli():
yield
@pytest.fixture
def tmp_dir():
with tempfile.TemporaryDirectory() as tmp_dir:
old_dir = os.getcwd()
os.chdir(tmp_dir)
yield tmp_dir
os.chdir(old_dir)
@pytest.fixture(scope="session")
def _shared_serve_instance():
# Note(simon):
# This line should be not turned on on master because it leads to very
# spammy and not useful log in case of a failure in CI.
# To run locally, please use this instead.
# SERVE_DEBUG_LOG=1 pytest -v -s test_api.py
# os.environ["SERVE_DEBUG_LOG"] = "1" <- Do not uncomment this.
# Overriding task_retry_delay_ms to relaunch actors more quickly
ray.init(
address="local",
num_cpus=36,
namespace="default_test_namespace",
_metrics_export_port=9999,
_system_config={"metrics_report_interval_ms": 1000, "task_retry_delay_ms": 50},
)
serve.start(
proxy_location=ProxyLocation.HeadOnly,
http_options={"host": "0.0.0.0"},
grpc_options={
"port": 9000,
"grpc_servicer_functions": TEST_GRPC_SERVICER_FUNCTIONS,
},
)
yield _get_global_client()
# Shutdown Serve and Ray when the session ends so that proxy actors
# (e.g. HAProxyManager) run their shutdown logic and stop subprocesses.
serve.shutdown()
@pytest_asyncio.fixture
async def serve_instance_async(_shared_serve_instance):
yield _shared_serve_instance
# Clear all state for 2.x applications and deployments.
_shared_serve_instance.delete_all_apps()
# Clear the ServeHandle cache between tests to avoid them piling up.
await _shared_serve_instance.shutdown_cached_handles_async()
@pytest.fixture
def serve_instance(_shared_serve_instance):
yield _shared_serve_instance
# Clear all state for 2.x applications and deployments.
_shared_serve_instance.delete_all_apps()
# Clear the ServeHandle cache between tests to avoid them piling up.
_shared_serve_instance.shutdown_cached_handles()
@pytest.fixture
def serve_instance_with_signal(serve_instance):
client = serve_instance
signal = SignalActor.options(name="signal123").remote()
yield client, signal
# Delete signal actor so there is no conflict between tests
ray.kill(signal)
def check_ray_stop():
try:
httpx.get("http://localhost:8265/api/ray/version")
return False
except Exception:
return True
@pytest.fixture(scope="function")
def ray_start_stop():
subprocess.check_output(["ray", "stop", "--force"])
ray.shutdown()
wait_for_condition(
check_ray_stop,
timeout=15,
)
subprocess.check_output(["ray", "start", "--head"])
wait_for_condition(
lambda: httpx.get("http://localhost:8265/api/ray/version").status_code == 200,
timeout=15,
)
ray.init("auto")
yield
serve.shutdown()
ray.shutdown()
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(
check_ray_stop,
timeout=15,
)
@pytest.fixture(scope="function")
def ray_start_stop_in_specific_directory(request):
original_working_dir = os.getcwd()
# Change working directory so Ray will start in the requested directory.
new_working_dir = request.param
os.chdir(new_working_dir)
print(f"\nChanged working directory to {new_working_dir}\n")
subprocess.check_output(["ray", "start", "--head"])
wait_for_condition(
lambda: httpx.get("http://localhost:8265/api/ray/version").status_code == 200,
timeout=15,
)
try:
yield
finally:
# Change the directory back to the original one.
os.chdir(original_working_dir)
print(f"\nChanged working directory back to {original_working_dir}\n")
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(
check_ray_stop,
timeout=15,
)
@pytest.fixture
def ray_instance(
request: pytest.FixtureRequest,
) -> Generator[Dict[str, Any], None, None]:
"""Starts and stops a Ray instance for this test.
Args:
request: request.param should contain a dictionary of env vars and
their values. The Ray instance will be started with these env vars.
Yields:
Dict[str, Any]: The dict returned by ``ray.init`` for the started cluster.
"""
original_env_vars = os.environ.copy()
try:
requested_env_vars = request.param
except AttributeError:
requested_env_vars = {}
os.environ.update(requested_env_vars)
yield ray.init(
address="local",
_metrics_export_port=9999,
_system_config={
"metrics_report_interval_ms": 1000,
"task_retry_delay_ms": 50,
},
)
serve.shutdown()
ray.shutdown()
os.environ.clear()
os.environ.update(original_env_vars)
@pytest.fixture
def manage_ray_with_telemetry(monkeypatch):
with monkeypatch.context() as m:
m.setenv("RAY_USAGE_STATS_ENABLED", "1")
m.setenv(
"RAY_USAGE_STATS_REPORT_URL",
f"http://127.0.0.1:8000{TELEMETRY_ROUTE_PREFIX}",
)
m.setenv("RAY_USAGE_STATS_REPORT_INTERVAL_S", "1")
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(check_ray_stopped, timeout=5)
subprocess.check_output(["ray", "start", "--head"])
wait_for_condition(check_ray_started, timeout=5)
storage = start_telemetry_app()
wait_for_condition(
lambda: ray.get(storage.get_reports_received.remote()) > 1, timeout=15
)
yield storage
# Call Python API shutdown() methods to clear global variable state
serve.shutdown()
ray.shutdown()
# Reset global state (any keys that may have been set and cached while the
# workload was running).
usage_lib.reset_global_state()
# Shut down Ray cluster with CLI
subprocess.check_output(["ray", "stop", "--force"])
wait_for_condition(check_ray_stopped, timeout=5)
def wait_for_metrics_port_free(port=TEST_METRICS_EXPORT_PORT, timeout=30):
"""
Ensures the metrics export port is freed.
"""
def port_free():
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
s.bind(("", port))
return True
except OSError:
return False
finally:
s.close()
wait_for_condition(port_free, timeout=timeout, retry_interval_ms=200)
def wait_for_metrics_endpoint(session_name, port=TEST_METRICS_EXPORT_PORT, timeout=30):
"""
Ensures the current dashboard agent is serving the metrics endpoint. A
timeout indicates another agent is still running and holding the port.
"""
def ready():
try:
resp = httpx.get(f"http://localhost:{port}/metrics", timeout=1.0)
except Exception:
return False
return resp.status_code == 200 and f'SessionName="{session_name}"' in resp.text
wait_for_condition(ready, timeout=timeout, retry_interval_ms=500)
@pytest.fixture
def metrics_start_shutdown(request):
param = request.param if hasattr(request, "param") else None
request_timeout_s = param if param else None
"""Fixture provides a fresh Ray cluster to prevent metrics state sharing."""
wait_for_metrics_port_free()
ray.init(
address="local",
_metrics_export_port=TEST_METRICS_EXPORT_PORT,
_system_config={
"metrics_report_interval_ms": 100,
"task_retry_delay_ms": 50,
},
)
try:
session_name = ray._private.worker._global_node.session_name
wait_for_metrics_endpoint(session_name)
grpc_port = 9000
grpc_servicer_functions = [
"ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server",
"ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server",
]
yield serve.start(
grpc_options=gRPCOptions(
port=grpc_port,
grpc_servicer_functions=grpc_servicer_functions,
request_timeout_s=request_timeout_s,
),
http_options=HTTPOptions(
host="0.0.0.0",
request_timeout_s=request_timeout_s,
),
)
finally:
serve.shutdown()
ray.shutdown()
reset_ray_address()
# Helper function to return the node ID of a remote worker.
@ray.remote(num_cpus=0)
def _get_node_id():
return ray.get_runtime_context().get_node_id()
# Test fixture to start a Serve instance in a RayCluster with two labeled nodes
@pytest.fixture(scope="module")
def serve_instance_with_labeled_nodes():
cluster = Cluster()
# Unlabeled default node.
cluster.add_node(num_cpus=3, resources={"worker0": 1})
# Node 1 - labeled A100 node in us-west.
cluster.add_node(
num_cpus=3,
resources={"worker1": 1},
labels={"region": "us-west", "gpu-type": "A100"},
)
# Node 2 - labeled H100 node in us-east.
cluster.add_node(
num_cpus=3,
resources={"worker2": 1},
labels={"region": "us-east", "gpu-type": "H100"},
)
cluster.wait_for_nodes()
if ray.is_initialized():
ray.shutdown()
ray.init(address=cluster.address)
node_1_id = ray.get(_get_node_id.options(resources={"worker1": 1}).remote())
node_2_id = ray.get(_get_node_id.options(resources={"worker2": 1}).remote())
serve.start()
yield _get_global_client(), node_1_id, node_2_id, cluster
serve.shutdown()
ray.shutdown()
cluster.shutdown()
+46
View File
@@ -0,0 +1,46 @@
[
{
"name": "route_to_replica BasicModel __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"deployment": "BasicModel",
"app": "default",
"call_method": "__call__",
"route": "/",
"multiplexed_model_id": "",
"is_streaming": true,
"is_http_request": true,
"is_grpc_request": false,
"resource.name": "route_to_replica BasicModel __call__",
"http.method": "__call__",
"http.route": "/"
},
"events": [],
"links": []
},
{
"name": "proxy_http_request BasicModel POST /",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "OK"
},
"attributes": {
"request_id": "",
"deployment": "BasicModel",
"app": "default",
"request_type": "http",
"request_method": "POST",
"request_route_path": "/",
"resource.name": "proxy_http_request BasicModel POST /",
"http.method": "POST",
"http.status_code": 200,
"http.route": "/"
},
"events": [],
"links": []
}
]
@@ -0,0 +1,38 @@
[
{
"name": "application_span",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {
"deployment": "BasicModel",
"replica_id": ""
},
"events": [],
"links": []
},
{
"name": "replica_handle_request BasicModel __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"replica_id": "",
"deployment": "BasicModel",
"app": "default",
"call_method": "__call__",
"route": "/",
"multiplexed_model_id": "",
"is_streaming": true,
"resource.name": "replica_handle_request BasicModel __call__",
"http.method": "POST",
"http.status_code": "200",
"http.route": "/"
},
"events": [],
"links": []
}
]
@@ -0,0 +1,12 @@
[
{
"name": "upstream_app",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {},
"events": [],
"links": []
}
]
+45
View File
@@ -0,0 +1,45 @@
[
{
"name": "route_to_replica grpc-deployment __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"deployment": "grpc-deployment",
"app": "default",
"call_method": "__call__",
"route": "default",
"multiplexed_model_id": "",
"is_streaming": false,
"is_http_request": false,
"is_grpc_request": true,
"resource.name": "route_to_replica grpc-deployment __call__",
"rpc.system": "gRPC",
"rpc.method": "__call__",
"rpc.service": "grpc-deployment"
},
"events": [],
"links": []
},
{
"name": "proxy_grpc_request grpc-deployment /ray.serve.UserDefinedService/__call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "OK"
},
"attributes": {
"request_id": "",
"deployment": "grpc-deployment",
"app": "default",
"request_type": "grpc",
"resource.name": "proxy_grpc_request grpc-deployment /ray.serve.UserDefinedService/__call__",
"rpc.system": "grpc",
"rpc.method": "/ray.serve.UserDefinedService/__call__",
"rpc.grpc.status_code": "OK"
},
"events": [],
"links": []
}
]
+38
View File
@@ -0,0 +1,38 @@
[
{
"name": "application_span",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {
"deployment": "grpc-deployment",
"replica_id": ""
},
"events": [],
"links": []
},
{
"name": "replica_handle_request grpc-deployment __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"replica_id": "",
"deployment": "grpc-deployment",
"app": "default",
"call_method": "__call__",
"route": "default",
"multiplexed_model_id": "",
"is_streaming": false,
"resource.name": "replica_handle_request grpc-deployment __call__",
"rpc.system": "gRPC",
"rpc.method": "__call__",
"rpc.service": "grpc-deployment"
},
"events": [],
"links": []
}
]
@@ -0,0 +1,12 @@
[
{
"name": "upstream_app",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {},
"events": [],
"links": []
}
]
@@ -0,0 +1,46 @@
[
{
"name": "route_to_replica StreamingModel __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"deployment": "StreamingModel",
"app": "default",
"call_method": "__call__",
"route": "/",
"multiplexed_model_id": "",
"is_streaming": true,
"is_http_request": true,
"is_grpc_request": false,
"resource.name": "route_to_replica StreamingModel __call__",
"http.method": "__call__",
"http.route": "/"
},
"events": [],
"links": []
},
{
"name": "proxy_http_request StreamingModel GET /",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "OK"
},
"attributes": {
"request_id": "",
"deployment": "StreamingModel",
"app": "default",
"request_type": "http",
"request_method": "GET",
"request_route_path": "/",
"resource.name": "proxy_http_request StreamingModel GET /",
"http.method": "GET",
"http.status_code": 200,
"http.route": "/"
},
"events": [],
"links": []
}
]
@@ -0,0 +1,38 @@
[
{
"name": "application_span",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {
"deployment": "StreamingModel",
"replica_id": ""
},
"events": [],
"links": []
},
{
"name": "replica_handle_request StreamingModel __call__",
"kind": "SpanKind.SERVER",
"status": {
"status_code": "UNSET"
},
"attributes": {
"request_id": "",
"replica_id": "",
"deployment": "StreamingModel",
"app": "default",
"call_method": "__call__",
"route": "/",
"multiplexed_model_id": "",
"is_streaming": true,
"resource.name": "replica_handle_request StreamingModel __call__",
"http.method": "GET",
"http.status_code": "200",
"http.route": "/"
},
"events": [],
"links": []
}
]
@@ -0,0 +1,12 @@
[
{
"name": "upstream_app",
"kind": "SpanKind.INTERNAL",
"status": {
"status_code": "UNSET"
},
"attributes": {},
"events": [],
"links": []
}
]
@@ -0,0 +1,458 @@
import asyncio
import pickle
import sys
from types import SimpleNamespace
from typing import Union
import pytest
import ray
from ray import ObjectRef, ObjectRefGenerator
from ray._common.test_utils import SignalActor, async_wait_for_condition
from ray._common.utils import get_or_create_event_loop
from ray.exceptions import ActorDiedError, ActorUnavailableError, TaskCancelledError
from ray.serve._private.common import (
DeploymentID,
ReplicaID,
ReplicaQueueLengthInfo,
RequestMetadata,
RunningReplicaInfo,
)
from ray.serve._private.constants import SERVE_NAMESPACE
from ray.serve._private.request_router.common import PendingRequest
from ray.serve._private.request_router.replica_wrapper import RunningReplica
from ray.serve._private.test_utils import send_signal_on_cancellation
from ray.serve._private.utils import Semaphore
class _IntMetricsManager:
"""Minimal metrics manager that tracks only the in-flight count."""
def __init__(self):
self._n = 0
def get_num_ongoing_requests(self):
return self._n
def inc_num_ongoing_requests(self, _):
self._n += 1
def dec_num_ongoing_requests(self, _):
self._n -= 1
@ray.remote(num_cpus=0)
class SlotReservationActor:
"""Ray actor wrapping the real Replica.reserve_slot / release_slot.
Used by integration tests that need production slot-reservation logic
running under Ray's actor concurrency model — unit tests share one event
loop and can't observe sync/async ordering on a real ReplicaActor.
"""
def __init__(self, max_ongoing_requests: int):
from ray.serve._private.replica import Replica
replica = Replica.__new__(Replica)
replica._deployment_config = SimpleNamespace(
max_ongoing_requests=max_ongoing_requests
)
replica._reserved_slots = set()
replica._semaphore = Semaphore(lambda: max_ongoing_requests)
replica._metrics_manager = _IntMetricsManager()
# __init__ is bypassed; set the quiesce flag read by
# _can_accept_request (reservations are rejected once quiescing).
replica._quiescing = False
self._replica = replica
async def reserve_slot(self, request_metadata, slot_token: str):
return await self._replica.reserve_slot(request_metadata, slot_token)
def release_slot(self, slot_token: str):
return self._replica.release_slot(slot_token)
def get_num_ongoing_requests(self) -> int:
return self._replica.get_num_ongoing_requests()
@ray.remote(num_cpus=0)
class BlockingReserveActor:
"""Actor whose reserve_slot blocks on a SignalActor.
Records every release_slot token it receives so a test can verify the
cancellation cleanup path in RunningReplica.reserve_slot.
"""
def __init__(self, signal_actor):
self._signal = signal_actor
self._released_tokens = []
async def reserve_slot(self, request_metadata, slot_token: str):
await self._signal.wait.remote()
return True, 1
def release_slot(self, slot_token: str):
self._released_tokens.append(slot_token)
return True, 0
def get_released_tokens(self):
return list(self._released_tokens)
@ray.remote(num_cpus=0)
class FakeReplicaActor:
def __init__(self):
self._replica_queue_length_info = None
def set_replica_queue_length_info(self, info: ReplicaQueueLengthInfo):
self._replica_queue_length_info = info
async def handle_request(
self,
request_metadata: Union[bytes, RequestMetadata],
message: str,
*,
is_streaming: bool,
):
if isinstance(request_metadata, bytes):
request_metadata = pickle.loads(request_metadata)
assert not is_streaming and not request_metadata.is_streaming
return message
async def handle_request_streaming(
self,
request_metadata: Union[bytes, RequestMetadata],
message: str,
*,
is_streaming: bool,
):
if isinstance(request_metadata, bytes):
request_metadata = pickle.loads(request_metadata)
assert is_streaming and request_metadata.is_streaming
for i in range(5):
yield f"{message}-{i}"
async def handle_request_with_rejection(
self,
pickled_request_metadata: bytes,
*args,
**kwargs,
):
cancelled_signal_actor = kwargs.pop("cancelled_signal_actor", None)
if cancelled_signal_actor is not None:
executing_signal_actor = kwargs.pop("executing_signal_actor")
async with send_signal_on_cancellation(cancelled_signal_actor):
await executing_signal_actor.send.remote()
return
# Special case: if "raise_task_cancelled_error" is in kwargs, raise TaskCancelledError
# This simulates the scenario where the underlying Ray task gets cancelled
if kwargs.pop("raise_task_cancelled_error", False):
raise TaskCancelledError()
yield pickle.dumps(self._replica_queue_length_info)
if not self._replica_queue_length_info.accepted:
return
request_metadata = pickle.loads(pickled_request_metadata)
if request_metadata.is_streaming:
async for result in self.handle_request_streaming(
request_metadata, *args, **kwargs
):
yield result
else:
yield await self.handle_request(request_metadata, *args, **kwargs)
@pytest.fixture
def setup_fake_replica(ray_instance) -> RunningReplica:
replica_id = ReplicaID(
"fake_replica", deployment_id=DeploymentID(name="fake_deployment")
)
actor_name = replica_id.to_full_id_str()
# Create actor with a name so it can be retrieved by get_actor_handle()
_ = FakeReplicaActor.options(
name=actor_name, namespace=SERVE_NAMESPACE, lifetime="detached"
).remote()
return RunningReplicaInfo(
replica_id=replica_id,
node_id=None,
node_ip=None,
availability_zone=None,
actor_name=actor_name,
max_ongoing_requests=10,
is_cross_language=False,
)
def test_update_replica_info_refreshes_backend_http_endpoint(setup_fake_replica):
replica = RunningReplica(setup_fake_replica)
assert replica.backend_http_endpoint is None
updated_info = RunningReplicaInfo(
replica_id=setup_fake_replica.replica_id,
node_id=setup_fake_replica.node_id,
node_ip="127.0.0.1",
availability_zone=setup_fake_replica.availability_zone,
actor_name=setup_fake_replica.actor_name,
max_ongoing_requests=setup_fake_replica.max_ongoing_requests,
is_cross_language=setup_fake_replica.is_cross_language,
backend_http_port=8001,
)
replica.update_replica_info(updated_info)
assert replica.backend_http_endpoint == ("127.0.0.1", 8001)
def test_backend_http_endpoint_requires_host_and_port(setup_fake_replica):
replica = RunningReplica(setup_fake_replica)
updated_info = RunningReplicaInfo(
replica_id=setup_fake_replica.replica_id,
node_id=setup_fake_replica.node_id,
node_ip=None,
availability_zone=setup_fake_replica.availability_zone,
actor_name=setup_fake_replica.actor_name,
max_ongoing_requests=setup_fake_replica.max_ongoing_requests,
is_cross_language=setup_fake_replica.is_cross_language,
backend_http_port=8001,
)
replica.update_replica_info(updated_info)
assert replica.backend_http_endpoint is None
@pytest.mark.asyncio
@pytest.mark.parametrize("is_streaming", [False, True])
async def test_send_request_without_rejection(setup_fake_replica, is_streaming: bool):
replica = RunningReplica(setup_fake_replica)
pr = PendingRequest(
args=["Hello"],
kwargs={"is_streaming": is_streaming},
metadata=RequestMetadata(
request_id="abc",
internal_request_id="def",
is_streaming=is_streaming,
),
)
replica_result = replica.try_send_request(pr, with_rejection=False)
if is_streaming:
assert isinstance(replica_result.to_object_ref_gen(), ObjectRefGenerator)
for i in range(5):
assert await replica_result.__anext__() == f"Hello-{i}"
else:
assert isinstance(replica_result.to_object_ref(), ObjectRef)
assert isinstance(await replica_result.to_object_ref_async(), ObjectRef)
assert await replica_result.get_async() == "Hello"
@pytest.mark.asyncio
@pytest.mark.parametrize("accepted", [False, True])
@pytest.mark.parametrize("is_streaming", [False, True])
async def test_send_request_with_rejection(
setup_fake_replica, accepted: bool, is_streaming: bool
):
actor_handle = setup_fake_replica.get_actor_handle()
replica = RunningReplica(setup_fake_replica)
ray.get(
actor_handle.set_replica_queue_length_info.remote(
ReplicaQueueLengthInfo(accepted=accepted, num_ongoing_requests=10),
)
)
pr = PendingRequest(
args=["Hello"],
kwargs={"is_streaming": is_streaming},
metadata=RequestMetadata(
request_id="abc",
internal_request_id="def",
is_streaming=is_streaming,
),
)
replica_result = replica.try_send_request(pr, with_rejection=True)
info = await replica_result.get_rejection_response()
assert info.accepted == accepted
assert info.num_ongoing_requests == 10
if not accepted:
pass
elif is_streaming:
assert isinstance(replica_result.to_object_ref_gen(), ObjectRefGenerator)
for i in range(5):
assert await replica_result.__anext__() == f"Hello-{i}"
else:
assert isinstance(replica_result.to_object_ref(), ObjectRef)
assert isinstance(await replica_result.to_object_ref_async(), ObjectRef)
assert await replica_result.get_async() == "Hello"
@pytest.mark.asyncio
async def test_send_request_with_rejection_cancellation(setup_fake_replica):
"""
Verify that the downstream actor method call is cancelled if the call to send the
request to the replica is cancelled.
"""
replica = RunningReplica(setup_fake_replica)
executing_signal_actor = SignalActor.remote()
cancelled_signal_actor = SignalActor.remote()
pr = PendingRequest(
args=["Hello"],
kwargs={
"cancelled_signal_actor": cancelled_signal_actor,
"executing_signal_actor": executing_signal_actor,
},
metadata=RequestMetadata(
request_id="abc",
internal_request_id="def",
),
)
# Send request should hang because the downstream actor method call blocks
# before sending the system message.
replica_result = replica.try_send_request(pr, with_rejection=True)
request_task = get_or_create_event_loop().create_task(
replica_result.get_rejection_response()
)
# Check that the downstream actor method call has started.
await executing_signal_actor.wait.remote()
_, pending = await asyncio.wait([request_task], timeout=0.001)
assert len(pending) == 1
# Cancel the task. This should cause the downstream actor method call to
# be cancelled (verified via signal actor).
request_task.cancel()
with pytest.raises(asyncio.CancelledError):
await request_task
await cancelled_signal_actor.wait.remote()
@pytest.mark.asyncio
async def test_send_request_with_rejection_task_cancelled_error(setup_fake_replica):
"""
Test that TaskCancelledError from the underlying Ray task gets converted to
asyncio.CancelledError when sending request with rejection.
"""
actor_handle = setup_fake_replica.get_actor_handle()
replica = RunningReplica(setup_fake_replica)
# Set up the replica to accept the request
ray.get(
actor_handle.set_replica_queue_length_info.remote(
ReplicaQueueLengthInfo(accepted=True, num_ongoing_requests=5),
)
)
pr = PendingRequest(
args=["Hello"],
kwargs={
"raise_task_cancelled_error": True
}, # This will trigger TaskCancelledError
metadata=RequestMetadata(
request_id="abc",
internal_request_id="def",
),
)
# The TaskCancelledError should be caught and converted to asyncio.CancelledError
replica_result = replica.try_send_request(pr, with_rejection=True)
with pytest.raises(asyncio.CancelledError):
await replica_result.get_rejection_response()
def _spawn_running_replica(actor_cls, replica_id_str: str, *actor_args, **actor_kwargs):
"""Spawn a named actor and wrap it in a RunningReplica.
Returns ``(running_replica, actor_handle)``. The actor must be created
with the canonical replica-id name so RunningReplica can resolve it
through its normal GCS lookup.
"""
replica_id = ReplicaID(
replica_id_str, deployment_id=DeploymentID(name="slot_reservation_test")
)
actor_name = replica_id.to_full_id_str()
actor_handle = actor_cls.options(
name=actor_name, namespace=SERVE_NAMESPACE, lifetime="detached"
).remote(*actor_args, **actor_kwargs)
info = RunningReplicaInfo(
replica_id=replica_id,
node_id=None,
node_ip=None,
availability_zone=None,
actor_name=actor_name,
max_ongoing_requests=10,
is_cross_language=False,
)
return RunningReplica(info), actor_handle
def _dummy_request_metadata() -> RequestMetadata:
return RequestMetadata(request_id="abc", internal_request_id="def")
@pytest.mark.asyncio
async def test_reserve_slot_cancellation_releases_slot_on_actor(ray_instance):
"""If the awaiting reserve_slot task is cancelled, the wrapper must fire a
follow-up release_slot.remote(token) so the actor doesn't leak the slot.
"""
signal = SignalActor.remote()
replica, actor = _spawn_running_replica(
BlockingReserveActor, "blocking-replica", signal
)
task = get_or_create_event_loop().create_task(
replica.reserve_slot(_dummy_request_metadata())
)
# Let the actor enter reserve_slot and start awaiting the signal.
_, pending = await asyncio.wait([task], timeout=0.5)
assert len(pending) == 1
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
# Unblock the actor so it can process the follow-up release_slot.remote().
await signal.send.remote()
# The wrapper's cancellation cleanup fires release_slot.remote(token)
# without awaiting it; wait until the actor records the call.
async def _release_received():
return bool(await actor.get_released_tokens.remote())
await async_wait_for_condition(_release_received, timeout=5)
released_tokens = await actor.get_released_tokens.remote()
assert len(released_tokens) == 1
@pytest.mark.asyncio
async def test_reserve_slot_propagates_actor_died_error(ray_instance):
"""If the replica actor is dead, RunningReplica.reserve_slot must raise
ActorDiedError so AsyncioRouter.choose_replica can retry against another
replica. ActorUnavailableError is also acceptable on the brief window
before the actor failure has propagated.
"""
replica, actor = _spawn_running_replica(
SlotReservationActor, "doomed-replica", max_ongoing_requests=1
)
# Confirm liveness via a successful reservation first.
_, info = await replica.reserve_slot(_dummy_request_metadata())
assert info.accepted
ray.kill(actor)
with pytest.raises((ActorDiedError, ActorUnavailableError)):
await replica.reserve_slot(_dummy_request_metadata())
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+146
View File
@@ -0,0 +1,146 @@
import sys
import time
import httpx
import pytest
from starlette.requests import Request
import ray
from ray import serve
from ray._common.test_utils import SignalActor
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve._private.test_utils import Barrier
from ray.serve.handle import DeploymentHandle
from ray.util.state import list_objects
def test_serve_forceful_shutdown(serve_instance):
@serve.deployment(graceful_shutdown_timeout_s=0.1)
def sleeper():
while True:
time.sleep(1000)
handle = serve.run(sleeper.bind())
response = handle.remote()
serve.delete(SERVE_DEFAULT_APP_NAME)
with pytest.raises(ray.exceptions.RayActorError):
response.result()
def test_serve_graceful_shutdown(serve_instance):
signal = SignalActor.remote()
@serve.deployment(
name="wait",
max_ongoing_requests=10,
graceful_shutdown_timeout_s=1000,
graceful_shutdown_wait_loop_s=0.5,
)
class Wait:
async def __call__(self, signal_actor):
await signal_actor.wait.remote()
handle = serve.run(Wait.bind())
responses = [handle.remote(signal) for _ in range(10)]
# Wait for all the queries to be enqueued
with pytest.raises(TimeoutError):
responses[0].result(timeout_s=1)
@ray.remote(num_cpus=0)
def do_blocking_delete():
serve.delete(SERVE_DEFAULT_APP_NAME)
# Now delete the deployment. This should trigger the shutdown sequence.
delete_ref = do_blocking_delete.remote()
# The queries should be enqueued but not executed becuase they are blocked
# by signal actor.
with pytest.raises(TimeoutError):
responses[0].result(timeout_s=1)
signal.send.remote()
# All the queries should be drained and executed without error.
[r.result() for r in responses]
# Blocking delete should complete.
ray.get(delete_ref)
def test_parallel_start(serve_instance):
# Test the ability to start multiple replicas in parallel.
# In the past, when Serve scale up a deployment, it does so one by one and
# wait for each replica to initialize. This test avoid this by preventing
# the first replica to finish initialization unless the second replica is
# also started.
barrier = Barrier.remote(n=2)
@serve.deployment(num_replicas=2)
class LongStartingServable:
def __init__(self):
ray.get(barrier.wait.remote(), timeout=10)
def __call__(self):
return "Ready"
handle = serve.run(LongStartingServable.bind())
handle.remote().result(timeout_s=10)
def test_passing_object_ref_to_deployment_not_pinned_to_memory(serve_instance):
"""Passing object refs to deployments should not pin the refs in memory.
We had issue that passing object ref to a deployment will result in memory leak
due to _PyObjScanner/ cloudpickler pinning the object to memory. This test will
ensure the object ref is released after the request is done.
See: https://github.com/ray-project/ray/issues/43248
"""
def _obj_ref_exists_in_state_api(obj_ref_hex: str) -> bool:
return (
len(
list_objects(
filters=[("object_id", "=", obj_ref_hex)],
raise_on_missing_output=False,
)
)
> 0
)
@serve.deployment
class Dep1:
def multiply_by_two(self, length: int):
return length * 2
@serve.deployment
class Gateway:
def __init__(self, dep1: DeploymentHandle):
self.dep1: DeploymentHandle = dep1
async def __call__(self, http_request: Request) -> str:
length = int(http_request.query_params.get("length"))
length_ref = ray.put(length)
# Sanity check that the ObjectRef exists in the state API.
assert _obj_ref_exists_in_state_api(length_ref.hex())
return {
"length": length,
"result": await self.dep1.multiply_by_two.remote(length_ref),
"length_ref_hex": length_ref.hex(),
}
serve.run(Gateway.bind(Dep1.bind()))
length = 10
response = httpx.get(f"http://localhost:8000?length={length}").json()
assert response["length"] == length
assert response["result"] == length * 2
# Ensure the object ref is not in the memory anymore.
assert not _obj_ref_exists_in_state_api(response["length_ref_hex"])
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import pytest
import ray
from ray import serve
from ray._common.network_utils import build_address
from ray.serve._private.common import RequestProtocol
from ray.serve._private.test_utils import get_application_urls
def test_get_application_urls(serve_instance):
@serve.deployment
def f():
return "Hello, world!"
serve.run(f.bind())
controller_details = ray.get(serve_instance._controller.get_actor_details.remote())
node_ip = controller_details.node_ip
assert get_application_urls(use_localhost=False) == [
f"http://{build_address(node_ip, 8000)}"
]
assert get_application_urls("gRPC", use_localhost=False) == [
build_address(node_ip, 9000)
]
assert get_application_urls(RequestProtocol.HTTP, use_localhost=False) == [
f"http://{build_address(node_ip, 8000)}"
]
assert get_application_urls(RequestProtocol.GRPC, use_localhost=False) == [
build_address(node_ip, 9000)
]
def test_get_application_urls_with_app_name(serve_instance):
@serve.deployment
def f():
return "Hello, world!"
serve.run(f.bind(), name="app1", route_prefix="/")
controller_details = ray.get(serve_instance._controller.get_actor_details.remote())
node_ip = controller_details.node_ip
assert get_application_urls("HTTP", app_name="app1", use_localhost=False) == [
f"http://{node_ip}:8000"
]
assert get_application_urls("gRPC", app_name="app1", use_localhost=False) == [
f"{node_ip}:9000"
]
def test_get_application_urls_with_route_prefix(serve_instance):
@serve.deployment
def f():
return "Hello, world!"
serve.run(f.bind(), name="app1", route_prefix="/app1")
controller_details = ray.get(serve_instance._controller.get_actor_details.remote())
node_ip = controller_details.node_ip
assert get_application_urls("HTTP", app_name="app1", use_localhost=False) == [
f"http://{node_ip}:8000/app1"
]
assert get_application_urls("gRPC", app_name="app1", use_localhost=False) == [
f"{node_ip}:9000"
]
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
import sys
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from urllib.parse import urljoin
import httpx
import pytest
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from starlette.requests import Request
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.test_utils import get_application_url
from ray.serve.exceptions import BackPressureError
def test_handle_backpressure(serve_instance):
"""Requests should raise a BackPressureError once the limit is reached."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Deployment:
async def __call__(self, msg: str) -> str:
await signal_actor.wait.remote()
return msg
handle = serve.run(Deployment.bind())
# First response should block. Until the signal is sent, all subsequent requests
# will be queued in the handle.
first_response = handle.remote("hi-1")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
# Check that beyond the 1st queued request, others are dropped due to backpressure.
second_response = handle.remote("hi-2")
# Wait until "hi-2" is actually registered as queued in the router before
# sending more requests. The router processes the request asynchronously on
# its own event loop thread, so without this the requests below can race
# ahead and get queued themselves (blocking forever) instead of being
# rejected with backpressure.
wait_for_condition(
lambda: handle._router._asyncio_router._metrics_manager.num_queued_requests == 1
)
for _ in range(10):
with pytest.raises(BackPressureError):
handle.remote().result()
# Send the signal; the first request will be unblocked and the second should
# subsequently get scheduled and executed.
ray.get(signal_actor.send.remote())
assert first_response.result() == "hi-1"
assert second_response.result() == "hi-2"
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
def test_http_backpressure(serve_instance):
"""Requests should return a 503 once the limit is reached."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Deployment:
async def __call__(self, request: Request) -> str:
msg = (await request.json())["msg"]
await signal_actor.wait.remote()
return msg
serve.run(Deployment.bind())
def send_request(msg: str = "hi"):
application_url = get_application_url()
r = httpx.request("GET", application_url, json={"msg": msg}, timeout=30.0)
return r.status_code, r.text
with ThreadPoolExecutor(max_workers=5) as exc:
# First response should block. Until the signal is sent, all subsequent
# requests will be queued in the handle.
first_fut = exc.submit(send_request, "hi-1")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([first_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
# Second request should get queued.
second_fut = exc.submit(send_request, "hi-2")
done, _ = wait(
[first_fut, second_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
# Check that beyond the 1st queued request, others are dropped due to
# backpressure.
for _ in range(10):
rejected_fut = exc.submit(send_request, "hi-err")
status_code, text = rejected_fut.result()
assert status_code == 503
assert text.startswith("Request dropped due to backpressure")
# Send the signal; the first request will be unblocked and the second
# should subsequently get scheduled and executed.
ray.get(signal_actor.send.remote())
assert first_fut.result() == (200, "hi-1")
assert second_fut.result() == (200, "hi-2")
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
def test_model_composition_backpressure(serve_instance):
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Child:
async def __call__(self):
await signal_actor.wait.remote()
return "ok"
@serve.deployment
class Parent:
def __init__(self, child):
self.child = child
async def __call__(self):
return await self.child.remote()
def send_request():
return httpx.get(get_application_url())
serve.run(Parent.bind(child=Child.bind()))
with ThreadPoolExecutor(max_workers=3) as exc:
# Send first request, wait for it to be blocked while executing.
executing_fut = exc.submit(send_request)
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([executing_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
# Send second request, it should get queued.
queued_fut = exc.submit(send_request)
done, _ = wait(
[executing_fut, queued_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
# Send third request, it should get rejected.
rejected_fut = exc.submit(send_request)
assert rejected_fut.result().status_code == 503
# Send signal, check the two requests succeed.
ray.get(signal_actor.send.remote(clear=False))
assert executing_fut.result().status_code == 200
assert executing_fut.result().text == "ok"
assert queued_fut.result().status_code == 200
assert queued_fut.result().text == "ok"
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
@pytest.mark.parametrize("request_type", ["async_non_gen", "sync_non_gen"])
def test_model_composition_backpressure_with_fastapi(serve_instance, request_type):
"""Tests backpressure behavior with FastAPI model composition.
Tests that when a Child deployment with max_ongoing_requests=1 and max_queued_requests=1
is called through a Parent FastAPI deployment:
1. First request blocks while executing
2. Second request gets queued
3. Third request gets rejected with 503 status code
4. After unblocking, first two requests complete successfully
Tests both async and sync non-generator endpoints.
"""
signal_actor = SignalActor.remote()
app = FastAPI()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Child:
async def __call__(self):
await signal_actor.wait.remote()
return "ok"
@serve.deployment
@serve.ingress(app)
class Parent:
def __init__(self, child):
self.child = child
@app.get("/async_non_gen")
async def async_non_gen(self):
result = await self.child.remote()
return PlainTextResponse(result)
@app.get("/sync_non_gen")
def sync_non_gen(self):
result = self.child.remote().result()
return PlainTextResponse(result)
def send_request():
url_map = {
"async_non_gen": urljoin(get_application_url(), "async_non_gen"),
"sync_non_gen": urljoin(get_application_url(), "sync_non_gen"),
}
resp = httpx.get(url_map[request_type])
return resp
serve.run(Parent.bind(child=Child.bind()))
with ThreadPoolExecutor(max_workers=3) as exc:
executing_fut = exc.submit(send_request)
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
done, _ = wait([executing_fut], timeout=0.1, return_when=FIRST_COMPLETED)
assert len(done) == 0
queued_fut = exc.submit(send_request)
done, _ = wait(
[executing_fut, queued_fut], timeout=0.1, return_when=FIRST_COMPLETED
)
assert len(done) == 0
rejected_fut = exc.submit(send_request)
assert rejected_fut.result().status_code == 503
# Send signal, let the two requests succeed.
ray.get(signal_actor.send.remote())
assert executing_fut.result().status_code == 200
assert executing_fut.result().text == "ok"
assert queued_fut.result().status_code == 200
assert queued_fut.result().text == "ok"
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,79 @@
import sys
from typing import Tuple
import grpc
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.common import RequestProtocol
from ray.serve._private.test_utils import get_application_url
from ray.serve.generated import serve_pb2, serve_pb2_grpc
def test_grpc_backpressure(serve_instance):
"""Requests should return UNAVAILABLE once the limit is reached."""
signal_actor = SignalActor.remote()
@serve.deployment(max_ongoing_requests=1, max_queued_requests=1)
class Deployment:
async def __call__(self, request: serve_pb2.UserDefinedMessage):
await signal_actor.wait.remote()
return serve_pb2.UserDefinedResponse(greeting=request.name)
serve.run(Deployment.bind())
@ray.remote(num_cpus=0)
def do_request(msg: str) -> Tuple[grpc.StatusCode, str]:
channel = grpc.insecure_channel(
get_application_url(protocol=RequestProtocol.GRPC)
)
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
try:
response, call = stub.__call__.with_call(
serve_pb2.UserDefinedMessage(name=msg)
)
return call.code(), response.greeting
except grpc.RpcError as e:
return e.code(), e.details()
# First response should block. Until the signal is sent, all subsequent requests
# will be queued in the handle.
first_ref = do_request.remote("hi-1")
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 1)
_, pending = ray.wait([first_ref], timeout=0.1)
assert len(pending) == 1
# Check that beyond the 1st queued request, others are dropped due to backpressure.
num_requests = 10
burst_refs = [do_request.remote("hi-err") for _ in range(num_requests)]
def num_rejected() -> int:
ready, _ = ray.wait(burst_refs, num_returns=len(burst_refs), timeout=0)
rejected = 0
for status_code, text in ray.get(ready):
if status_code == grpc.StatusCode.RESOURCE_EXHAUSTED:
assert text.startswith("Request dropped due to backpressure")
rejected += 1
return rejected
# All but the single queued request should be rejected with backpressure.
wait_for_condition(lambda: num_rejected() == num_requests - 1)
# Send the signal; the ongoing request and the single queued request both
# get unblocked and complete successfully.
ray.get(signal_actor.send.remote())
assert ray.get(first_ref) == (grpc.StatusCode.OK, "hi-1")
num_ok = sum(
1 for status_code, _ in ray.get(burst_refs) if status_code == grpc.StatusCode.OK
)
assert num_ok == 1
ray.get(signal_actor.send.remote(clear=True))
wait_for_condition(lambda: ray.get(signal_actor.cur_num_waiters.remote()) == 0)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+751
View File
@@ -0,0 +1,751 @@
import asyncio
import math
from collections.abc import Callable
from concurrent.futures.thread import ThreadPoolExecutor
from functools import partial
from threading import Thread
from typing import List, Optional
import httpx
import pytest
from fastapi import FastAPI, Request
from starlette.responses import StreamingResponse
from ray import serve
from ray._common.test_utils import SignalActor, async_wait_for_condition
from ray.serve._private.test_utils import get_application_url
from ray.serve.batching import _RuntimeSummaryStatistics
from ray.serve.context import (
_get_serve_batch_request_context,
_get_serve_request_context,
)
def test_batching(serve_instance):
@serve.deployment
class BatchingExample:
def __init__(self):
self.count = 0
@serve.batch(max_batch_size=5, batch_wait_timeout_s=1)
async def handle_batch(self, requests):
self.count += 1
batch_size = len(requests)
return [self.count] * batch_size
async def __call__(self, request):
return await self.handle_batch(request)
handle = serve.run(BatchingExample.bind())
result_list = [handle.remote(1) for _ in range(20)]
# since count is only updated per batch of queries
# If there atleast one __call__ fn call with batch size greater than 1
# counter result will always be less than 20
assert max([r.result() for r in result_list]) < 20
def test_concurrent_batching(serve_instance):
BATCHES_IN_FLIGHT = 2
MAX_BATCH_SIZE = 5
BATCH_WAIT_TIMEOUT_S = 1
MAX_REQUESTS_IN_FLIGHT = BATCHES_IN_FLIGHT * MAX_BATCH_SIZE
@serve.deployment(max_ongoing_requests=MAX_REQUESTS_IN_FLIGHT * 2)
class BatchingExample:
def __init__(self):
self.n_batches_in_flight = 0
self.n_requests_in_flight = 0
@serve.batch(
max_batch_size=MAX_BATCH_SIZE,
batch_wait_timeout_s=BATCH_WAIT_TIMEOUT_S,
max_concurrent_batches=BATCHES_IN_FLIGHT,
)
async def handle_batch(self, requests):
self.n_batches_in_flight += 1
self.n_requests_in_flight += len(requests)
await asyncio.sleep(0.5)
out = [
(req_idx, self.n_batches_in_flight, self.n_requests_in_flight)
for req_idx in requests
]
await asyncio.sleep(0.5)
self.n_requests_in_flight -= len(requests)
self.n_batches_in_flight -= 1
return out
async def __call__(self, request):
return await self.handle_batch(request)
handle = serve.run(BatchingExample.bind())
idxs = set(range(20))
result_futures = [handle.remote(i) for i in idxs]
result_list = [future.result() for future in result_futures]
out_idxs = set()
for idx, batches_in_flight, requests_in_flight in result_list:
out_idxs.add(idx)
assert (
batches_in_flight == BATCHES_IN_FLIGHT
), f"Should have been {BATCHES_IN_FLIGHT} batches in flight at all times, got {batches_in_flight}"
assert (
requests_in_flight == MAX_REQUESTS_IN_FLIGHT
), f"Should have been {MAX_REQUESTS_IN_FLIGHT} requests in flight at all times, got {requests_in_flight}"
assert idxs == out_idxs, "All requests should be processed"
def test_batching_exception(serve_instance):
@serve.deployment
class NoListReturned:
def __init__(self):
self.count = 0
@serve.batch(max_batch_size=5)
async def handle_batch(self, requests):
return len(requests)
async def __call__(self, request):
return await self.handle_batch(request)
# Set the max batch size.
handle = serve.run(NoListReturned.bind())
with pytest.raises(TypeError):
assert handle.remote(1).result()
@pytest.mark.asyncio
async def test_batch_generator_streaming_response_integration_test(serve_instance):
NUM_YIELDS = 10
@serve.deployment
class Textgen:
@serve.batch(max_batch_size=4, batch_wait_timeout_s=1000)
async def batch_handler(self, prompts: List[str]):
for _ in range(NUM_YIELDS):
# Check that the batch handler can yield unhashable types
prompt_responses = [{"value": prompt} for prompt in prompts]
yield prompt_responses
async def value_extractor(self, prompt_responses):
async for prompt_response in prompt_responses:
yield prompt_response["value"]
async def __call__(self, request):
prompt = request.query_params["prompt"]
response_values = self.value_extractor(self.batch_handler(prompt))
return StreamingResponse(response_values)
serve.run(Textgen.bind())
prompt_prefix = "hola"
url = f"{get_application_url()}/?prompt={prompt_prefix}"
with ThreadPoolExecutor() as pool:
futs = [pool.submit(partial(httpx.get, url + str(idx))) for idx in range(4)]
responses = [fut.result() for fut in futs]
for idx, response in enumerate(responses):
assert response.status_code == 200
assert response.text == "".join([prompt_prefix + str(idx)] * NUM_YIELDS)
def test_batching_client_dropped_unary(serve_instance):
"""Test unary batching with clients that drops the connection.
After requests are dropped. The next request should succeed.
"""
@serve.deployment
class ModelUnary:
@serve.batch(max_batch_size=5)
async def handle_batch(self, requests):
await asyncio.sleep(0.05)
return ["fake-response" for _ in range(len(requests))]
async def __call__(self, request):
return await self.handle_batch(request)
serve.run(ModelUnary.bind())
url = f"{get_application_url()}/"
# Sending requests with clients that drops the connection.
for _ in range(3):
with pytest.raises(httpx.ReadTimeout):
httpx.get(url, timeout=0.005)
# The following request should succeed.
resp = httpx.get(url, timeout=1)
assert resp.status_code == 200
assert resp.text == "fake-response"
def test_batching_client_dropped_streaming(serve_instance):
"""Test streaming batching with clients that drops the connection.
After requests are dropped. The next request should succeed.
"""
@serve.deployment
class ModelStreaming:
@serve.batch(max_batch_size=3)
async def handle_batch(self, requests):
await asyncio.sleep(0.05)
for i in range(10):
yield [str(i) for _ in range(len(requests))]
async def __call__(self, request):
return StreamingResponse(self.handle_batch(request))
serve.run(ModelStreaming.bind())
url = "http://localhost:8000/"
# Sending requests with clients that drops the connection.
for _ in range(3):
with pytest.raises((httpx.ReadTimeout, httpx.ConnectError)):
httpx.get(url, timeout=0.005)
# The following request should succeed.
resp = httpx.get(url, timeout=1)
assert resp.status_code == 200
assert resp.text == "0123456789"
@pytest.mark.asyncio
@pytest.mark.parametrize("max_concurrent_batches", [1, 10])
@pytest.mark.parametrize("max_batch_size", [1, 10])
@pytest.mark.parametrize("n_requests", [1, 10])
async def test_observability_helpers(
serve_instance, n_requests: int, max_batch_size: int, max_concurrent_batches: int
) -> None:
"""Checks observability helper methods that are used for batching.
Tests three observability helper methods:
* _get_curr_iteration_start_times: gets the current iteration's start
time.
* _is_batching_task_alive: returns whether the batch-handler task is
alive.
* _get_handling_task_stack: returns the stack for the batch-handler task.
"""
signal_actor = SignalActor.remote()
@serve.deployment(
name="batcher", max_ongoing_requests=max_concurrent_batches * max_batch_size
)
class Batcher:
@serve.batch(
max_batch_size=max_batch_size,
max_concurrent_batches=max_concurrent_batches,
batch_wait_timeout_s=0.1,
)
async def handle_batch(self, requests):
await signal_actor.wait.remote() # wait until the outer signal actor is released
return [0] * len(requests)
async def __call__(self, request):
return await self.handle_batch(request)
async def _get_curr_iteration_start_times(self) -> _RuntimeSummaryStatistics:
return self.handle_batch._get_curr_iteration_start_times()
async def _is_batching_task_alive(self) -> bool:
return await self.handle_batch._is_batching_task_alive()
async def _get_handling_task_stack(self) -> Optional[str]:
return await self.handle_batch._get_handling_task_stack()
serve.run(target=Batcher.bind(), name="app_name")
handle = serve.get_deployment_handle(deployment_name="batcher", app_name="app_name")
assert await handle._is_batching_task_alive.remote()
min_num_batches = min(
math.ceil(n_requests / max_batch_size), max_concurrent_batches
)
async with httpx.AsyncClient() as client:
tasks1 = await send_k_requests(
signal_actor,
n_requests,
min_num_batches,
app_name="app_name",
client=client,
)
prev_iter_times = await handle._get_curr_iteration_start_times.remote()
await signal_actor.send.remote() # unblock the batch handler now that we have the iter times
assert len(prev_iter_times.start_times) >= min_num_batches
assert len(await handle._get_handling_task_stack.remote()) is not None
assert await handle._is_batching_task_alive.remote()
tasks2 = await send_k_requests(
signal_actor,
n_requests,
min_num_batches,
app_name="app_name",
client=client,
)
new_iter_times = await handle._get_curr_iteration_start_times.remote()
await signal_actor.send.remote() # unblock the batch handler now that we have the iter times
assert len(new_iter_times.start_times) >= min_num_batches
assert len(await handle._get_handling_task_stack.remote()) is not None
assert await handle._is_batching_task_alive.remote()
assert new_iter_times.min_start_time > prev_iter_times.max_start_time
# Cancel and await all tasks to avoid "Task exception was never retrieved" warning.
# We don't need the HTTP responses, just need to clean up the tasks properly.
for task in tasks1 + tasks2:
task.cancel()
await asyncio.gather(*tasks1, *tasks2, return_exceptions=True)
async def send_k_requests(
signal_actor: SignalActor,
k: int,
min_num_batches: float,
app_name: str,
client: httpx.AsyncClient,
) -> List[asyncio.Task]:
"""Send k requests and wait until at least min_num_batches are waiting.
Returns the list of request tasks so they can be awaited by the caller
after unblocking the batch handler.
"""
await signal_actor.send.remote(True) # type: ignore[attr-defined]
tasks = []
for _ in range(k):
tasks.append(
asyncio.create_task(
client.get(f"{get_application_url(app_name=app_name)}/")
)
)
await wait_for_n_waiters(
signal_actor, lambda num_waiters: num_waiters >= min_num_batches
)
return tasks
async def wait_for_n_waiters(
signal_actor: SignalActor, condition: Callable[[int], bool]
) -> None:
async def poll() -> bool:
num_waiters: int = await signal_actor.cur_num_waiters.remote() # type: ignore[attr-defined]
return condition(num_waiters)
return await async_wait_for_condition(poll)
def test_batching_request_context(serve_instance):
"""Test that _get_serve_batch_request_context() works correctly with batching.
With 6 requests and max_batch_size=3, Serve should create 2 batches processed in parallel.
Each batch should have access to the request contexts of all requests in that batch,
and context should be properly unset after processing.
"""
@serve.deployment(max_ongoing_requests=10)
class BatchContextTester:
def __init__(self):
self.batch_results = []
@serve.batch(
max_batch_size=3, batch_wait_timeout_s=1.0, max_concurrent_batches=2
)
async def handle_batch(self, batch):
# Store results for verification
batch_result = {
"batch_size": len(batch),
"batch_request_contexts": _get_serve_batch_request_context(),
"current_request_context": _get_serve_request_context(),
}
self.batch_results.append(batch_result)
return ["ok" for _ in range(len(batch))]
async def __call__(self, request):
return await self.handle_batch(1)
async def get_results(self):
return self.batch_results
handle = serve.run(BatchContextTester.bind())
def do_request():
"""Make a request with a specific request ID."""
url = get_application_url()
r = httpx.post(f"{url}/")
r.raise_for_status()
# Launch 6 requests. Expect 2 batches of 3 requests each.
threads = [Thread(target=do_request) for _ in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
# Get results from the deployment
batch_results = handle.get_results.remote().result()
# Verify each batch has correct size and context
total_requests_processed = 0
request_ids_in_batch_context = set()
for result in batch_results:
# Batch context should contain all 3 request contexts
assert (
len(result["batch_request_contexts"]) == 3
), f"Expected 3 contexts in batch, got {result['batch_request_contexts']}"
req_ids_in_batch_context = [
ctx.request_id for ctx in result["batch_request_contexts"]
]
assert (
len(req_ids_in_batch_context) == 3
), f"Expected 3 batch request IDs, got {len(req_ids_in_batch_context)}"
request_ids_in_batch_context.update(req_ids_in_batch_context)
# Current request context read within the batcher should be a default empty context.
current_request_context = result["current_request_context"]
assert current_request_context.request_id == ""
assert current_request_context.route == ""
assert current_request_context.app_name == ""
assert current_request_context.multiplexed_model_id == ""
total_requests_processed += result["batch_size"]
# Verify all 6 requests were processed
assert (
total_requests_processed == 6
), f"Expected 6 total requests processed, got {total_requests_processed}"
assert (
len(request_ids_in_batch_context) == 6
), f"Expected 6 unique request IDs, got {len(request_ids_in_batch_context)}"
def test_batch_size_fn_simple(serve_instance):
"""Test batch_size_fn with a simple custom batch size metric."""
@serve.deployment
class BatchSizeFnExample:
def __init__(self):
self.batches_received = []
@serve.batch(
max_batch_size=100, # Set based on total size, not count
batch_wait_timeout_s=0.5,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def handle_batch(self, requests: List):
# Record the batch for verification
self.batches_received.append(requests)
# Return results
return [req["value"] * 2 for req in requests]
async def __call__(self, request):
return await self.handle_batch(request)
def get_batches(self):
return self.batches_received
handle = serve.run(BatchSizeFnExample.bind())
# Send requests with different sizes
# Request 1: size=30, value=1
# Request 2: size=40, value=2
# Request 3: size=20, value=3
# Request 4: size=25, value=4
# Total of first 3 = 90 (< 100), but adding 4th would be 115 (> 100)
requests = [
{"size": 30, "value": 1},
{"size": 40, "value": 2},
{"size": 20, "value": 3},
{"size": 25, "value": 4},
]
result_futures = [handle.remote(req) for req in requests]
results = [future.result() for future in result_futures]
# Verify results are correct
assert results == [2, 4, 6, 8]
# Verify batching behavior
batches = handle.get_batches.remote().result()
# Should have created at least one batch
assert len(batches) > 0
def test_batch_size_fn_graph_nodes(serve_instance):
"""Test batch_size_fn with a GNN-style use case (batching by total nodes)."""
class Graph:
def __init__(self, num_nodes: int, graph_id: int):
self.num_nodes = num_nodes
self.graph_id = graph_id
@serve.deployment
class GraphBatcher:
def __init__(self):
self.batch_sizes = []
@serve.batch(
max_batch_size=100, # Max 100 nodes per batch
batch_wait_timeout_s=0.5,
batch_size_fn=lambda graphs: sum(g.num_nodes for g in graphs),
)
async def process_graphs(self, graphs: List[Graph]):
# Record batch size (total nodes)
total_nodes = sum(g.num_nodes for g in graphs)
self.batch_sizes.append(total_nodes)
# Return graph_id * num_nodes as result
return [g.graph_id * g.num_nodes for g in graphs]
async def __call__(self, graph):
return await self.process_graphs(graph)
def get_batch_sizes(self):
return self.batch_sizes
handle = serve.run(GraphBatcher.bind())
# Create graphs with different node counts
# Graph 1: 30 nodes, Graph 2: 40 nodes, Graph 3: 35 nodes, Graph 4: 50 nodes
# First 3 total = 105 nodes (> 100), so should be 2 batches
graphs = [
Graph(num_nodes=30, graph_id=1),
Graph(num_nodes=40, graph_id=2),
Graph(num_nodes=35, graph_id=3),
Graph(num_nodes=50, graph_id=4),
]
result_futures = [handle.remote(g) for g in graphs]
results = [future.result() for future in result_futures]
# Verify results
assert results == [30, 80, 105, 200]
# Verify batch sizes respect the limit
batch_sizes = handle.get_batch_sizes.remote().result()
for batch_size in batch_sizes:
# Each batch should have <= 100 nodes
assert batch_size <= 100, f"Batch size {batch_size} exceeds limit of 100"
def test_batch_size_fn_token_count(serve_instance):
"""Test batch_size_fn with an NLP-style use case (batching by total tokens)."""
@serve.deployment
class TokenBatcher:
@serve.batch(
max_batch_size=1000, # Max 1000 tokens per batch
batch_wait_timeout_s=0.5,
batch_size_fn=lambda sequences: sum(len(s.split()) for s in sequences),
)
async def process_sequences(self, sequences: List[str]):
# Return word count for each sequence
return [len(s.split()) for s in sequences]
async def __call__(self, sequence):
return await self.process_sequences(sequence)
handle = serve.run(TokenBatcher.bind())
# Create sequences with different token counts
sequences = [
"This is a short sequence", # 5 tokens
"This is a much longer sequence with many more words in it", # 12 tokens
"Short", # 1 token
"A B C D E F G H I J", # 10 tokens
]
result_futures = [handle.remote(s) for s in sequences]
results = [future.result() for future in result_futures]
# Verify results are correct
assert results == [5, 12, 1, 10]
def test_batch_size_fn_validation():
"""Test that batch_size_fn validation works correctly."""
from ray.serve.batching import batch
# Test with non-callable batch_size_fn
with pytest.raises(TypeError, match="batch_size_fn must be a callable or None"):
@batch(batch_size_fn="not_a_function")
async def my_batch_handler(items):
return items
def test_batch_size_fn_default_behavior(serve_instance):
"""Test that default behavior (batch_size_fn=None) still works as expected."""
@serve.deployment
class DefaultBatcher:
@serve.batch(max_batch_size=5, batch_wait_timeout_s=0.5)
async def handle_batch(self, requests):
return [r * 2 for r in requests]
async def __call__(self, request):
return await self.handle_batch(request)
handle = serve.run(DefaultBatcher.bind())
# Send 10 requests
result_futures = [handle.remote(i) for i in range(10)]
results = [future.result() for future in result_futures]
# Verify all results are correct
assert results == [i * 2 for i in range(10)]
def test_batch_size_fn_oversized_item_raises_error(serve_instance):
app = FastAPI()
@serve.deployment
@serve.ingress(app)
class OversizedItemBatcher:
@serve.batch(
max_batch_size=10,
batch_wait_timeout_s=0.5,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def handle_batch(self, requests: List):
return [req["value"] for req in requests]
@app.post("/")
async def f(self, request: Request):
body = await request.json()
return await self.handle_batch(body)
serve.run(OversizedItemBatcher.bind())
# Send a request with size > max_batch_size (15 > 10)
# This should return a 500 error with RuntimeError message
url = f"{get_application_url(use_localhost=True)}/"
response = httpx.post(url, json={"size": 15, "value": "too_large"}, timeout=5)
assert response.status_code == 500
def test_batch_size_fn_deferred_item_processed(serve_instance):
@serve.deployment(max_ongoing_requests=15)
class DeferredItemBatcher:
def __init__(self):
self.batch_sizes = []
@serve.batch(
max_batch_size=10,
batch_wait_timeout_s=0.5,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def handle_batch(self, requests: List):
# Record actual batch sizes for verification
total_size = sum(req["size"] for req in requests)
self.batch_sizes.append(total_size)
return [req["value"] for req in requests]
async def __call__(self, request):
return await self.handle_batch(request)
def get_batch_sizes(self):
return self.batch_sizes
handle = serve.run(DeferredItemBatcher.bind())
# Send requests where some will need to be deferred:
# Request 1: size=6 (fits)
# Request 2: size=6 (would make total 12 > 10, deferred)
# Request 3: size=3 (fits with request 1, total 9)
# Request 4: size=4 (would make total 13 > 10, deferred)
requests = [
{"size": 6, "value": "a"},
{"size": 6, "value": "b"},
{"size": 3, "value": "c"},
{"size": 4, "value": "d"},
]
result_futures = [handle.remote(req) for req in requests]
results = [future.result() for future in result_futures]
# All requests should be processed successfully
assert set(results) == {"a", "b", "c", "d"}
# Verify total size processed equals sum of all request sizes
batch_sizes = handle.get_batch_sizes.remote().result()
total_processed = sum(batch_sizes)
expected_total = sum(req["size"] for req in requests) # 6 + 6 + 3 + 4 = 19
assert (
total_processed == expected_total
), f"Total processed {total_processed} != expected {expected_total}"
def test_batch_size_fn_mixed_normal_and_large_items(serve_instance):
@serve.deployment
class MixedSizeBatcher:
def __init__(self):
self.batches_processed = []
@serve.batch(
max_batch_size=100,
batch_wait_timeout_s=0.5,
batch_size_fn=lambda items: sum(item["tokens"] for item in items),
)
async def handle_batch(self, requests: List):
batch_info = {
"total_tokens": sum(req["tokens"] for req in requests),
"num_items": len(requests),
}
self.batches_processed.append(batch_info)
return [f"processed_{req['id']}" for req in requests]
async def __call__(self, request):
return await self.handle_batch(request)
def get_batches(self):
return self.batches_processed
handle = serve.run(MixedSizeBatcher.bind())
# Mix of small and larger items
requests = [
{"id": 1, "tokens": 10}, # Small
{"id": 2, "tokens": 20}, # Small
{"id": 3, "tokens": 50}, # Medium
{"id": 4, "tokens": 15}, # Small
{"id": 5, "tokens": 90}, # Large (near limit)
{"id": 6, "tokens": 5}, # Small
]
result_futures = [handle.remote(req) for req in requests]
results = [future.result() for future in result_futures]
# All requests should be processed
expected_results = [f"processed_{i}" for i in range(1, 7)]
assert set(results) == set(expected_results)
# Verify total tokens processed equals sum of all request tokens
batches = handle.get_batches.remote().result()
total_tokens_processed = sum(batch["total_tokens"] for batch in batches)
expected_total = sum(req["tokens"] for req in requests) # 10+20+50+15+90+5 = 190
assert (
total_tokens_processed == expected_total
), f"Total tokens {total_tokens_processed} != expected {expected_total}"
# Verify total items processed equals number of requests
total_items = sum(batch["num_items"] for batch in batches)
assert total_items == len(
requests
), f"Total items {total_items} != expected {len(requests)}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
+206
View File
@@ -0,0 +1,206 @@
import os
import sys
import pytest
from ray import serve
def test_broadcast_basic(serve_instance):
"""Test that broadcast() calls every replica."""
@serve.deployment(num_replicas=3)
class D:
def get_pid(self):
return os.getpid()
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
# broadcast() internally waits for replicas to be available.
pids = handle.broadcast("get_pid").results(timeout_s=10)
assert len(pids) == 3
# Each replica should have a unique PID.
assert len(set(pids)) == 3
def test_broadcast_with_args(serve_instance):
"""Test broadcast with positional and keyword arguments."""
@serve.deployment(num_replicas=2)
class D:
def add(self, a, b=0):
return a + b
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
results = handle.broadcast("add", 1, b=2).results(timeout_s=10)
assert len(results) == 2
assert all(r == 3 for r in results)
def test_broadcast_stateful(serve_instance):
"""Test broadcast for state mutation (the cache-reset use case)."""
@serve.deployment(num_replicas=2)
class D:
def __init__(self):
self.cache = {"key": "value"}
def reset_cache(self):
self.cache.clear()
return "cleared"
def get_cache_size(self):
return len(self.cache)
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
# All replicas should start with cache size 1.
sizes = handle.broadcast("get_cache_size").results(timeout_s=10)
assert all(s == 1 for s in sizes)
# Broadcast cache reset.
results = handle.broadcast("reset_cache").results(timeout_s=10)
assert all(r == "cleared" for r in results)
# All replicas should now have empty caches.
sizes = handle.broadcast("get_cache_size").results(timeout_s=10)
assert all(s == 0 for s in sizes)
@pytest.mark.asyncio
async def test_broadcast_async(serve_instance):
"""Test the async results path."""
@serve.deployment(num_replicas=2)
class D:
def get_pid(self):
return os.getpid()
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
pids = await handle.broadcast("get_pid").results_async()
assert len(pids) == 2
assert len(set(pids)) == 2
def test_broadcast_single_replica(serve_instance):
"""Test broadcast with a single replica."""
@serve.deployment(num_replicas=1)
class D:
def ping(self):
return "pong"
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
results = handle.broadcast("ping").results(timeout_s=10)
assert results == ["pong"]
def test_broadcast_ignores_streaming_handle_option(serve_instance):
"""Test broadcast on a handle configured with stream=True."""
@serve.deployment(num_replicas=2)
class D:
def ping(self):
return "pong"
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default").options(stream=True)
results = handle.broadcast("ping").results(timeout_s=10)
assert len(results) == 2
assert all(r == "pong" for r in results)
def test_broadcast_return_exceptions_sync(serve_instance):
"""Test best-effort sync result collection with exceptions."""
@serve.deployment(num_replicas=2)
class D:
def fail(self):
raise RuntimeError("boom")
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
with pytest.raises(Exception):
handle.broadcast("fail").results(timeout_s=10)
results = handle.broadcast("fail").results(
timeout_s=10,
return_exceptions=True,
)
assert len(results) == 2
assert all(isinstance(r, Exception) for r in results)
@pytest.mark.asyncio
async def test_broadcast_return_exceptions_async(serve_instance):
"""Test best-effort async result collection with exceptions."""
@serve.deployment(num_replicas=2)
class D:
def fail(self):
raise RuntimeError("boom")
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
with pytest.raises(Exception):
await handle.broadcast("fail").results_async()
results = await handle.broadcast("fail").results_async(return_exceptions=True)
assert len(results) == 2
assert all(isinstance(r, Exception) for r in results)
def test_broadcast_sync_timeout(serve_instance):
"""Test sync timeout handling for broadcast results collection."""
@serve.deployment(num_replicas=2)
class D:
def slow(self):
import time
time.sleep(10)
return "done"
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
with pytest.raises(TimeoutError):
handle.broadcast("slow").results(timeout_s=0.5)
@pytest.mark.asyncio
async def test_broadcast_async_timeout(serve_instance):
"""Test async timeout handling for broadcast results collection."""
@serve.deployment(num_replicas=2)
class D:
def slow(self):
import time
time.sleep(10)
return "done"
serve.run(D.bind())
handle = serve.get_deployment_handle("D", "default")
with pytest.raises(TimeoutError):
await handle.broadcast("slow").results_async(timeout_s=0.5)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+272
View File
@@ -0,0 +1,272 @@
import importlib
import logging
import os
import sys
from typing import Any, Dict, Generator
import httpx
import pytest
import starlette
from starlette.middleware import Middleware
import ray
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.exceptions import RayActorError
from ray.serve._private.test_utils import get_application_url
from ray.serve._private.utils import call_function_from_import_path
from ray.serve.config import HTTPOptions, gRPCOptions
from ray.serve.context import _get_global_client
from ray.serve.schema import (
LoggingConfig,
ProxyStatus,
ServeInstanceDetails,
)
# ==== Callbacks used in this test ====
class ASGIMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
scope.get("headers").append((b"custom_header_key", "custom_header_value"))
await self.app(scope, receive, send)
def add_middleware():
return [Middleware(ASGIMiddleware)]
class MyServeFormatter(logging.Formatter):
def format(self, record: logging.LogRecord):
log_msg = super().format(record)
return "MyCustom message: hello " + log_msg
def add_logger():
ray_logger = logging.getLogger("ray.serve")
handler = logging.StreamHandler()
handler.setFormatter(MyServeFormatter())
ray_logger.addHandler(handler)
def raise_error_callback():
raise RuntimeError("this is from raise_error_callback")
def return_bad_objects():
return [1, 2, 3]
ADD_MIDDLEWARE_IMPORT_PATH = "ray.serve.tests.test_callback.add_middleware"
ADD_LOGGER_IMPORT_PATH = "ray.serve.tests.test_callback.add_logger"
RAISE_ERROR_IMPORT_PATH = "ray.serve.tests.test_callback.raise_error_callback"
RETURN_BAD_OBJECTS_IMPORT_PATH = "ray.serve.tests.test_callback.return_bad_objects"
NOT_CALLABLE_OBJECT = 1
# ==== end ====
@pytest.fixture()
def ray_instance(
request: pytest.FixtureRequest,
) -> Generator[Dict[str, Any], None, None]:
"""Starts and stops a Ray instance for this test.
Args:
request: request.param should contain a dictionary of env vars and
their values. The Ray instance will be started with these env vars.
Yields:
Dict[str, Any]: The dict returned by ``ray.init`` for the started cluster.
"""
original_env_vars = os.environ.copy()
try:
requested_env_vars = request.param
except AttributeError:
requested_env_vars = {}
os.environ.update(requested_env_vars)
importlib.reload(ray.serve._private.constants)
importlib.reload(ray.serve._private.controller)
importlib.reload(ray.serve._private.proxy)
yield ray.init()
serve.shutdown()
# wait_for_processes=True blocks until the raylet/GCS/etc. subprocesses
# have fully exited. Without it, this teardown races the next test's
# ray.init() and the new raylet can fail to register the driver.
ray.shutdown(wait_for_processes=True)
os.environ.clear()
os.environ.update(original_env_vars)
def test_call_function_from_import_path():
"""Basic test for call_function_from_import_path"""
# basic
assert [1, 2, 3] == call_function_from_import_path(RETURN_BAD_OBJECTS_IMPORT_PATH)
# rasie exception when callback function raise exception
with pytest.raises(RuntimeError, match="this is from raise_error_callback"):
call_function_from_import_path(RAISE_ERROR_IMPORT_PATH)
# raise exception when providing invalid import path
with pytest.raises(ValueError, match="cannot be imported"):
call_function_from_import_path("not_exist")
# raise exception when providing non callable object
with pytest.raises(TypeError, match="is not callable"):
call_function_from_import_path(
"ray.serve.tests.test_callback.NOT_CALLABLE_OBJECT"
)
@pytest.mark.parametrize(
"ray_instance",
[
{
"RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH": ADD_LOGGER_IMPORT_PATH,
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": ADD_MIDDLEWARE_IMPORT_PATH,
},
],
indirect=True,
)
def test_callback(ray_instance, capsys):
"""Test callback function works in http proxy and controller"""
serve.start(
http_options=HTTPOptions(
host="0.0.0.0",
request_timeout_s=500,
),
)
@serve.deployment
class Model:
def __call__(self, request: starlette.requests.Request):
headers = request.scope.get("headers")
for k, v in headers:
if k == b"custom_header_key":
return v
return "Not found custom headers"
serve.run(Model.bind())
url = get_application_url()
resp = httpx.get(url)
assert resp.text == "custom_header_value"
captured = capsys.readouterr()
assert "MyCustom message: hello" in captured.err
@pytest.mark.parametrize(
"ray_instance",
[
{
"RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH": "not_exist",
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RAISE_ERROR_IMPORT_PATH,
},
],
indirect=True,
)
def test_callback_fail(ray_instance):
"""Test actor call call_function_from_import_path rasing exception.
Actor will fail to be started and further call will raise RayActorError.
"""
actor_def = ray.serve._private.proxy.ProxyActor
handle = actor_def.remote(
http_options=HTTPOptions(host="http_proxy", root_path="/", port=123),
grpc_options=gRPCOptions(),
node_ip_address="127.0.0.1",
node_id="123",
logging_config=LoggingConfig(),
long_poll_client="fake_client",
)
with pytest.raises(RayActorError, match="this is from raise_error_callback"):
ray.get(handle.ready.remote())
serve_controller = ray.serve._private.controller.ServeController
actor_def = ray.actor._make_actor(serve_controller, {})
handle = actor_def.remote(
http_options=HTTPOptions(),
grpc_options=gRPCOptions(),
global_logging_config=LoggingConfig(),
)
with pytest.raises(RayActorError, match="cannot be imported"):
ray.get(handle.check_alive.remote())
@pytest.mark.parametrize(
"ray_instance",
[
{
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RETURN_BAD_OBJECTS_IMPORT_PATH,
},
],
indirect=True,
)
def test_http_proxy_return_aribitary_objects(ray_instance):
"""Test invalid callback path in http proxy"""
actor_def = ray.serve._private.proxy.ProxyActor
handle = actor_def.remote(
http_options=HTTPOptions(host="http_proxy", root_path="/", port=123),
grpc_options=gRPCOptions(),
node_ip_address="127.0.0.1",
node_id="123",
logging_config=LoggingConfig(),
long_poll_client="fake_client",
)
with pytest.raises(
RayActorError, match="must return a list of Starlette middlewares"
):
ray.get(handle.ready.remote())
@pytest.mark.parametrize(
"ray_instance",
[
{
"RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH": RAISE_ERROR_IMPORT_PATH,
},
],
indirect=True,
)
def test_http_proxy_callback_failures(ray_instance, capsys):
"""Test http proxy keeps restarting when callback function fails"""
try:
serve.start()
except RayActorError:
# serve.start will fail because the http proxy is not started successfully
# and client use proxy handle to check the proxy readiness, so it will raise
# RayActorError.
pass
client = _get_global_client()
def check_http_proxy_keep_restarting():
# The proxy will be under "STARTING" status and keep restarting.
prev_actor_id = None
while True:
serve_details = ServeInstanceDetails(**client.get_serve_details())
for _, proxy_info in serve_details.proxies.items():
if proxy_info.status != ProxyStatus.STARTING:
return False
if prev_actor_id is None:
prev_actor_id = proxy_info.actor_id
break
elif prev_actor_id != proxy_info.actor_id:
return True
wait_for_condition(check_http_proxy_keep_restarting)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,880 @@
import sys
import uuid
from collections import Counter
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.constants import SERVE_DEPLOYMENT_ACTOR_PREFIX, SERVE_NAMESPACE
from ray.serve._private.test_utils import check_running
from ray.serve.config import DeploymentActorConfig, RequestRouterConfig
from ray.serve.context import _get_internal_replica_context
from ray.serve.experimental.capacity_queue import (
CapacityQueue,
)
def _deploy_capacity_queue_app(
num_replicas: int = 3,
max_ongoing_requests: int = 5,
acquire_timeout_s: float = 0.5,
token_ttl_s: float = 5,
):
"""Deploy a simple app with CapacityQueue deployment actor and CapacityQueueRouter."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": acquire_timeout_s,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
initial_backoff_s=0.01,
backoff_multiplier=2.0,
max_backoff_s=0.1,
),
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
ray_actor_options={"num_cpus": 0},
)
class App:
def __init__(self):
context = _get_internal_replica_context()
self.replica_id = context.replica_id
self.unique_id = context.replica_id.unique_id
async def __call__(self):
return self.unique_id
handle = serve.run(App.bind())
return handle
def _deploy_blocking_capacity_queue_app(
signal_actor_name: str,
num_replicas: int = 2,
max_ongoing_requests: int = 5,
):
"""Deploy an app whose requests block until a SignalActor is triggered."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
initial_backoff_s=0.01,
backoff_multiplier=2.0,
max_backoff_s=0.1,
),
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
ray_actor_options={"num_cpus": 0},
)
class BlockingApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self):
signal = ray.get_actor(signal_actor_name)
await signal.wait.remote()
return self.unique_id
handle = serve.run(BlockingApp.bind())
return handle
def _find_capacity_queue_handle():
"""Find the CapacityQueue deployment actor."""
actors = ray.util.list_named_actors(all_namespaces=True)
for actor_info in actors:
if (
actor_info["namespace"] == SERVE_NAMESPACE
and "capacity_queue" in actor_info["name"]
and SERVE_DEPLOYMENT_ACTOR_PREFIX in actor_info["name"]
):
return ray.get_actor(actor_info["name"], namespace=SERVE_NAMESPACE)
return None
class TestCapacityQueueRouterBasic:
"""Basic integration tests for the capacity queue router."""
def test_single_request(self, serve_instance):
"""A single request should be routed to one of the replicas."""
handle = _deploy_capacity_queue_app(num_replicas=2)
# Wait for deployment to be healthy
wait_for_condition(check_running, timeout=30)
response = handle.remote().result(timeout_s=10)
assert isinstance(response, str)
assert len(response) > 0
def test_multiple_requests_distributed(self, serve_instance):
"""Requests should be distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send enough requests that all replicas should receive at least one
num_requests = 30
responses = []
for _ in range(num_requests):
r = handle.remote().result(timeout_s=10)
responses.append(r)
unique_replicas = set(responses)
# All replicas should have received at least one request
assert len(unique_replicas) == num_replicas, (
f"Expected {num_replicas} unique replicas, got {len(unique_replicas)}: "
f"{unique_replicas}"
)
def test_concurrent_requests(self, serve_instance):
"""Concurrent requests should be distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send concurrent requests
refs = [handle.remote() for _ in range(30)]
responses = [ref.result(timeout_s=30) for ref in refs]
unique_replicas = set(responses)
assert len(unique_replicas) == num_replicas
def test_capacity_queue_stats(self, serve_instance):
"""The capacity queue should track stats correctly.
Some early requests may fall back to power-of-two-choices before the
router discovers the queue, so we assert >= rather than exact counts.
"""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue_handle = _find_capacity_queue_handle()
assert queue_handle is not None, "CapacityQueue deployment actor not found"
# Wait for queue to have replicas before sending requests so most
# go through the queue path (not power-of-two-choices fallback).
wait_for_condition(
lambda: ray.get(queue_handle.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Send some requests
for _ in range(10):
handle.remote().result(timeout_s=10)
# Wait for all releases to settle (on_request_completed is async)
def _stats_settled():
stats = ray.get(queue_handle.get_stats.remote())
assert stats.num_replicas == 2
assert stats.total_in_flight == 0
# Most requests should go through the queue. Some may fall back
# to power-of-two-choices, so use >= with a lower bound.
assert stats.total_acquires >= 5
assert stats.total_releases >= 5
return True
wait_for_condition(_stats_settled, timeout=10)
class TestCapacityQueueRouterLoadBalancing:
"""Tests for load balancing behavior."""
def test_least_loaded_balancing(self, serve_instance):
"""Requests should be balanced across replicas (least-loaded)."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send sequential requests - should round-robin approximately
num_requests = 60
responses = []
for _ in range(num_requests):
r = handle.remote().result(timeout_s=10)
responses.append(r)
counter = Counter(responses)
# Each replica should get roughly equal share
expected_per_replica = num_requests / num_replicas
for replica_id, count in counter.items():
assert (
count >= expected_per_replica * 0.3
), f"Replica {replica_id} got {count} requests, expected ~{expected_per_replica}"
class TestCapacityQueueRouterWithSingleReplica:
"""Tests with a single replica to verify basic token flow."""
def test_single_replica_all_requests(self, serve_instance):
"""With one replica, all requests should go to the same replica."""
handle = _deploy_capacity_queue_app(num_replicas=1)
wait_for_condition(check_running, timeout=30)
responses = set()
for _ in range(10):
r = handle.remote().result(timeout_s=10)
responses.add(r)
assert len(responses) == 1
class TestCapacityQueueRouterPowerOfTwoFallback:
"""Tests that the router falls back to power-of-two-choices when the
queue is unavailable."""
def test_requests_succeed_without_queue(self, serve_instance):
"""Requests succeed via power-of-two-choices even when the queue is
killed immediately."""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Kill the queue so all subsequent requests must use fallback.
ray.kill(queue)
for _ in range(5):
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
def test_requests_distributed_without_queue(self, serve_instance):
"""In fallback mode, requests are still distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == num_replicas,
timeout=15,
)
# Kill the queue.
ray.kill(queue)
responses = []
for _ in range(30):
r = handle.remote().result(timeout_s=15)
responses.append(r)
unique_replicas = set(responses)
assert len(unique_replicas) == num_replicas
class TestCapacityQueueRouterFailures:
def test_unreleased_token_recovered_by_ttl(self, serve_instance):
"""Leaked tokens are automatically reclaimed after the TTL expires.
When a token is acquired but never released (e.g. a router process
dies between acquire() and release()), the queue's in_flight count
stays elevated. With token_ttl_s configured, a background reaper
reclaims expired tokens and restores full capacity.
"""
token_ttl_s = 2.0
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=3,
ray_actor_options={"num_cpus": 0},
)
class TtlApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self):
return self.unique_id
handle = serve.run(TtlApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Simulate a router acquiring a token then crashing (never releases).
leaked = ray.get(queue.acquire.remote(timeout_s=5))
assert leaked is not None
stats = ray.get(queue.get_stats.remote())
assert stats.total_in_flight == 1
assert stats.queue_size == 2 # 3 capacity - 1 leaked
# Remaining capacity still serves requests.
resp = handle.remote().result(timeout_s=10)
assert isinstance(resp, str)
# After the TTL expires, the reaper reclaims the leaked token and
# full capacity is restored.
def _capacity_restored():
s = ray.get(queue.get_stats.remote())
return s.total_in_flight == 0 and s.queue_size == 3
wait_for_condition(_capacity_restored, timeout=token_ttl_s + 5)
def test_replica_death_releases_token_and_recovers(self, serve_instance):
"""When a replica dies mid-request, its token is released and
the queue stops routing to it after the long-poll update."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=2,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class CrashApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, crash: bool = False):
if crash:
import os
os._exit(1)
return self.unique_id
handle = serve.run(CrashApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Crash one replica by sending a request that exits the process.
try:
handle.remote(crash=True).result(timeout_s=5)
except Exception:
pass # Expected — the replica died.
# The controller detects the death, removes the replica, and starts
# a replacement. Long poll updates the queue. Eventually the queue
# should recover to 2 replicas (the survivor + the replacement)
# with full capacity and no leaked in-flight counts.
def _cluster_fully_recovered():
stats = ray.get(queue.get_stats.remote())
assert stats.num_replicas == 2
assert stats.total_capacity == 4 # 2 replicas * max_ongoing_requests=2
assert stats.total_in_flight == 0
return True
wait_for_condition(_cluster_fully_recovered, timeout=30)
# Requests still succeed — routed to the surviving / replacement replica.
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
def test_capacity_queue_death_and_recovery(self, serve_instance):
"""When the CapacityQueue actor dies, the router falls back to
power-of-two-choices and requests continue to succeed. Once the
controller recreates the queue, the router rediscovers it and
resumes token-based routing.
"""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Verify requests work before the kill.
resp = handle.remote().result(timeout_s=10)
assert isinstance(resp, str)
# Kill the capacity queue actor.
ray.kill(queue)
# Requests should STILL succeed via power-of-two-choices fallback
# even while the queue is dead.
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
# The controller recreates the deployment actor. The new queue starts
# fresh and gets replicas via long poll. Wait for it to appear.
def _queue_recovered():
new_q = _find_capacity_queue_handle()
if new_q is None:
return False
stats = ray.get(new_q.get_stats.remote())
return stats.num_replicas == 2
wait_for_condition(_queue_recovered, timeout=30)
# After recovery, requests go through the queue again. Verify the
# new queue is being used by checking that acquires increase.
new_queue = _find_capacity_queue_handle()
stats_before = ray.get(new_queue.get_stats.remote())
for _ in range(3):
handle.remote().result(timeout_s=10)
def _queue_used():
stats = ray.get(new_queue.get_stats.remote())
return stats.total_acquires > stats_before.total_acquires
wait_for_condition(_queue_used, timeout=10)
def test_capacity_queue_restarts_with_full_capacity(self, serve_instance):
"""
After a queue restart, it bootstraps with full capacity even though
replicas may have in-flight requests from before the crash.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
handle = _deploy_blocking_capacity_queue_app(
signal_actor_name=signal_name,
num_replicas=1,
max_ongoing_requests=2,
)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Send a blocking request — occupies 1 of 2 slots on the replica.
ref = handle.remote()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 1,
timeout=10,
)
# Kill the capacity queue.
ray.kill(queue)
# Wait for the controller to recreate it.
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
# The new queue shows full capacity (2) even though the replica still
# has 1 in-flight request from before the crash.
new_queue = _find_capacity_queue_handle()
stats = ray.get(new_queue.get_stats.remote())
assert stats.total_capacity == 2
assert stats.total_in_flight == 0 # Queue doesn't know about the old request
# Release the signal so the blocked request finishes.
ray.get(signal.send.remote())
try:
ref.result(timeout_s=10)
except Exception:
pass # May fail since the queue died mid-request
# Cleanup
ray.kill(signal)
def test_queue_converges_after_restart(self, serve_instance):
"""After the queue restarts, its per-replica token view converges to
match actual replica capacity.
Setup: 1 replica, max_ongoing_requests=5, 3 blocked requests.
1. Send 3 blocking requests occupying 3/5 slots. Queue correctly
shows in_flight=3, available_tokens=2 for the replica.
2. Kill the queue — it restarts with in_flight=0, thinking the
replica has 5 available tokens (stale).
3. The router sends requests via the stale queue. Tokens for the
3 occupied slots get rejected. Unreleased rejection tokens
ratchet in_flight up, teaching the queue the correct state.
4. Release the blocking requests — replica frees all 5 slots.
5. TTL reaper clears phantom in_flight entries from rejections.
6. Assert per-replica convergence: available_tokens == max_capacity.
"""
token_ttl_s = 2.0
max_ongoing = 5
num_blocked = 3
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=max_ongoing,
ray_actor_options={"num_cpus": 0},
)
class ConvergeApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(ConvergeApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Step 1: Occupy 3 of 5 slots with blocking requests.
blocking_refs = [handle.remote(block=True) for _ in range(num_blocked)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == num_blocked,
timeout=15,
)
# Verify pre-crash per-replica state: in_flight=3, capacity=5.
replica_info = ray.get(queue.get_replica_in_flight.remote())
assert len(replica_info) == 1
for rid, (in_flight, max_cap) in replica_info.items():
assert max_cap == max_ongoing
assert in_flight == num_blocked
assert max_cap - in_flight == 2 # 2 available tokens
# Step 2: Kill the queue. It restarts with in_flight=0.
ray.kill(queue)
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
new_queue = _find_capacity_queue_handle()
# Verify stale state: queue thinks replica has 5 available tokens.
stale_info = ray.get(new_queue.get_replica_in_flight.remote())
for rid, (in_flight, max_cap) in stale_info.items():
assert in_flight == 0
assert max_cap == max_ongoing
# Step 3 & 4: Release the blocking requests so the replica frees up.
ray.get(signal.send.remote())
for ref in blocking_refs:
try:
ref.result(timeout_s=15)
except Exception:
pass # May fail — queue died while these were in flight.
# Send requests to exercise the queue and trigger any rejection-based
# learning for the stale window.
for _ in range(5):
handle.remote().result(timeout_s=15)
# Step 5 & 6: Wait for TTL reaper, then verify per-replica convergence.
# available_tokens (max_capacity - in_flight) must equal max_capacity
# because the replica has 0 real in-flight after the signal release.
def _per_replica_converged():
info = ray.get(new_queue.get_replica_in_flight.remote())
if len(info) != 1:
return False
for in_flight, max_cap in info.values():
if max_cap - in_flight != max_ongoing:
return False
return True
wait_for_condition(_per_replica_converged, timeout=token_ttl_s + 10)
# Final assertion: in_flight is exactly 0, all 5 tokens available.
final_info = ray.get(new_queue.get_replica_in_flight.remote())
for rid, (in_flight, max_cap) in final_info.items():
assert (
in_flight == 0
), f"Replica {rid}: expected converged in_flight=0, got {in_flight}"
assert max_cap - in_flight == max_ongoing
ray.kill(signal)
def test_capacity_depleted_backoff_and_recovery(self, serve_instance):
"""
When all replicas are at capacity, the router backs off and
retries until capacity frees up.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class DepletedApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(DepletedApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=10,
)
# Fill both slots with blocking requests.
blocking_refs = [handle.remote(block=True) for _ in range(2)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 2,
timeout=10,
)
# Send a third request — will be blocked waiting for capacity.
waiting_ref = handle.remote(block=False)
# Wait for at least one CQ timeout — proves the router hit the
# depleted path and backed off (total_timeouts is 0 before this
# since the blocking requests were acquired via the fast path).
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_timeouts >= 1,
timeout=30,
)
# Release the blockers so capacity frees up.
ray.get(signal.send.remote())
for ref in blocking_refs:
ref.result(timeout_s=15)
# The waiting request should complete once capacity is available.
result = waiting_ref.result(timeout_s=15)
assert isinstance(result, str)
ray.kill(signal)
def test_rejection_teaches_cq_after_restart(self, serve_instance):
"""
After a CQ restart, rejected tokens are NOT released back to the
CQ, so in_flight stays elevated and the CQ learns the replica is busy.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class RejectApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(RejectApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Step 1: Saturate the replica — 2/2 slots occupied.
blocking_refs = [handle.remote(block=True) for _ in range(2)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 2,
timeout=10,
)
# Step 2: Kill the CQ. It restarts with in_flight=0.
ray.kill(queue)
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
new_queue = _find_capacity_queue_handle()
stale_stats = ray.get(new_queue.get_stats.remote())
assert stale_stats.total_in_flight == 0 # Stale: thinks replica is idle
# Step 3: Send a non-blocking request. The stale CQ issues a token,
# the replica rejects (full), the router retries. The rejected token
# is NOT released, so the CQ's in_flight ratchets up.
new_ref = handle.remote(block=False)
# Step 4: The CQ should have learned — in_flight > 0 because the
# rejected token was not released.
def _cq_learned():
stats = ray.get(new_queue.get_stats.remote())
return stats.total_in_flight > 0
wait_for_condition(_cq_learned, timeout=15)
# Step 5: Release the blockers so all requests complete.
ray.get(signal.send.remote())
for ref in blocking_refs:
try:
ref.result(timeout_s=15)
except Exception:
pass
result = new_ref.result(timeout_s=15)
assert isinstance(result, str)
ray.kill(signal)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "-s"]))
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDfTCCAmWgAwIBAgIUYcUOt0aN1Ml/1WnFPB9gveNNniQwDQYJKoZIhvcNAQEL
BQAwZzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcM
DVNhbiBGcmFuY2lzY28xFzAVBgNVBAoMDlJheSBTZXJ2ZSBUZXN0MRIwEAYDVQQD
DAlsb2NhbGhvc3QwHhcNMjUwODIwMTgxODUzWhcNMjYwODIwMTgxODUzWjBnMQsw
CQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZy
YW5jaXNjbzEXMBUGA1UECgwOUmF5IFNlcnZlIFRlc3QxEjAQBgNVBAMMCWxvY2Fs
aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKYXcIirTR5AHb5V
T6yijOR8mvc6AXSKkmIKu7n2vaJ3Jrt7d6mPz/ScXlLYxq+mgt4avX/VozES0ARM
NcbqlHOcahgfyyN+/02q/Aimwbaf/FwiS5qyQfMXzFg70kydqlDlUsyE49qdFHEv
xx4ostLnTeyIpS7AS14qJXGeg5NE9Pm+XSs0HVBPZBaM6VCJl8/Pjog0qqffovGo
/qN8gVxnydg4ayTZ9nl+NNMivFJ/f5MUXmJiuFYAoZnwMiCy2QAU9TmdA5mCOGNZ
pv/KSSdqkVh7X6JNGB6OLgikCsObWxAJqq7WZgiHoc2WlXuN+U2SLuA0JLZZZr+t
zpw1DH0CAwEAAaMhMB8wHQYDVR0OBBYEFIey4ZBoVICZ7kAJv7K5kY/SHP6wMA0G
CSqGSIb3DQEBCwUAA4IBAQAg47MfYFykzDdynJnKf/Aqlp4bnT3GVEW3lRk8AMv9
yrjwQeVKihiQLgC6b7ChyLUQWxcxJPqhzAIe/+sn9bAxz448oGMtU6ghHtxt13T2
9VKsyyrjgZ3fbiFT5AFMYxwYlcaf1hJPE+PKKU3oUhYxUlEBKweDjTw7+7xym/Ix
hNYv36lDst/zwA1HKmvorDhCVOT3Y90deVA31NxFQbqNpeCjG6uiURAtO3jMan50
m9U60cHjJBkSxCKCw4SQXOan9VKePIsHnZgIiDPmO25KYSJxeat92sHVtI3FZfrh
pN3cjQaXhMbJFO9ySv5tqr0KxUbymN56ynWkScMGbI0W
-----END CERTIFICATE-----
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDfTCCAmWgAwIBAgIUYcUOt0aN1Ml/1WnFPB9gveNNniQwDQYJKoZIhvcNAQEL
BQAwZzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcM
DVNhbiBGcmFuY2lzY28xFzAVBgNVBAoMDlJheSBTZXJ2ZSBUZXN0MRIwEAYDVQQD
DAlsb2NhbGhvc3QwHhcNMjUwODIwMTgxODUzWhcNMjYwODIwMTgxODUzWjBnMQsw
CQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZy
YW5jaXNjbzEXMBUGA1UECgwOUmF5IFNlcnZlIFRlc3QxEjAQBgNVBAMMCWxvY2Fs
aG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKYXcIirTR5AHb5V
T6yijOR8mvc6AXSKkmIKu7n2vaJ3Jrt7d6mPz/ScXlLYxq+mgt4avX/VozES0ARM
NcbqlHOcahgfyyN+/02q/Aimwbaf/FwiS5qyQfMXzFg70kydqlDlUsyE49qdFHEv
xx4ostLnTeyIpS7AS14qJXGeg5NE9Pm+XSs0HVBPZBaM6VCJl8/Pjog0qqffovGo
/qN8gVxnydg4ayTZ9nl+NNMivFJ/f5MUXmJiuFYAoZnwMiCy2QAU9TmdA5mCOGNZ
pv/KSSdqkVh7X6JNGB6OLgikCsObWxAJqq7WZgiHoc2WlXuN+U2SLuA0JLZZZr+t
zpw1DH0CAwEAAaMhMB8wHQYDVR0OBBYEFIey4ZBoVICZ7kAJv7K5kY/SHP6wMA0G
CSqGSIb3DQEBCwUAA4IBAQAg47MfYFykzDdynJnKf/Aqlp4bnT3GVEW3lRk8AMv9
yrjwQeVKihiQLgC6b7ChyLUQWxcxJPqhzAIe/+sn9bAxz448oGMtU6ghHtxt13T2
9VKsyyrjgZ3fbiFT5AFMYxwYlcaf1hJPE+PKKU3oUhYxUlEBKweDjTw7+7xym/Ix
hNYv36lDst/zwA1HKmvorDhCVOT3Y90deVA31NxFQbqNpeCjG6uiURAtO3jMan50
m9U60cHjJBkSxCKCw4SQXOan9VKePIsHnZgIiDPmO25KYSJxeat92sHVtI3FZfrh
pN3cjQaXhMbJFO9ySv5tqr0KxUbymN56ynWkScMGbI0W
-----END CERTIFICATE-----
@@ -0,0 +1,17 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICrDCCAZQCAQAwZzELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWEx
FjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xFzAVBgNVBAoMDlJheSBTZXJ2ZSBUZXN0
MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCmF3CIq00eQB2+VU+soozkfJr3OgF0ipJiCru59r2idya7e3epj8/0nF5S
2MavpoLeGr1/1aMxEtAETDXG6pRznGoYH8sjfv9NqvwIpsG2n/xcIkuaskHzF8xY
O9JMnapQ5VLMhOPanRRxL8ceKLLS503siKUuwEteKiVxnoOTRPT5vl0rNB1QT2QW
jOlQiZfPz46INKqn36LxqP6jfIFcZ8nYOGsk2fZ5fjTTIrxSf3+TFF5iYrhWAKGZ
8DIgstkAFPU5nQOZgjhjWab/ykknapFYe1+iTRgeji4IpArDm1sQCaqu1mYIh6HN
lpV7jflNki7gNCS2WWa/rc6cNQx9AgMBAAGgADANBgkqhkiG9w0BAQsFAAOCAQEA
igYR2ZQ4fmp339T/BGvXSDIjQQkecd9MeifdcXuN/2FZ7dhyfDWHjQadtohgXSZw
LwfUx43L+JcebMY8GyN/4JIAKA5hVqqvAiaMb+vRUItgku5M2WIpnPLVKQJHTUGC
aaDq6u7aS4eFcvuYGaFTUD7tNMOfRP8SfQL/sk2UqZVOCIxCFX9gLS/p4IyorUsb
VjdQBHRvOZnZCFMwmisquXXeGxtAPabUWMPLvSqcP/93WdjFwtrcscyY68s+AC6o
9sx1x3qjnTxnx+a8ho5f0p/JSUqye+G/gzqzB5WMZK5U7oiYgP0rEajU9odGIPSK
AqzWpVDtZBSr8FFamw4uqQ==
-----END CERTIFICATE REQUEST-----
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCmF3CIq00eQB2+
VU+soozkfJr3OgF0ipJiCru59r2idya7e3epj8/0nF5S2MavpoLeGr1/1aMxEtAE
TDXG6pRznGoYH8sjfv9NqvwIpsG2n/xcIkuaskHzF8xYO9JMnapQ5VLMhOPanRRx
L8ceKLLS503siKUuwEteKiVxnoOTRPT5vl0rNB1QT2QWjOlQiZfPz46INKqn36Lx
qP6jfIFcZ8nYOGsk2fZ5fjTTIrxSf3+TFF5iYrhWAKGZ8DIgstkAFPU5nQOZgjhj
Wab/ykknapFYe1+iTRgeji4IpArDm1sQCaqu1mYIh6HNlpV7jflNki7gNCS2WWa/
rc6cNQx9AgMBAAECggEAFj7SHLaiJ+i7KHCcBj7ok1Bjyl8OLizCebfUTY5QTH/x
mRoVUd7oIcFbxMmHUE6t/STPDV3GHgmAq5gFeonqrigHRwnjFvL91h+OOB5q7ZSJ
+VEX7TVDg1VEUkEDjq1t+qhsVDuBmm3VfL9tx4qjQNTSvq536UYUvMefp5MX2P54
/7IDM9osP5VgeFIUx/d7QYymhgmVaSv+xcxxlZCwT3ib/wW7eU964FjkuRG8eein
zlyOwRufmg+eEvOUHN/4Fth0AUUirCMpflgRdcQtKs77FARiG8LybMGyDDsE7YBt
5f/UBZea2TQG9q4aGNUIHA869CCNKg2R27AtBpTtBQKBgQDd95GDIZMlEmR3GzpJ
6rqRHtfXj7BHUlzew+RCI1KhWkjRZqv2bavmeqRLpRdKd36aC+l+QallSW/MME+7
JSgRMqqdQK2myLJnZOIcONjMlOn9xzEQGYUsKL4IiPkdP0lWdzJ6iqAHm/Xq7GxE
BJF5XkYD1NP2+y3dlZYNrmUGHwKBgQC/jrOCV7Y34IriUPHSQA1JaPZQDBBxwiNo
ifPYkRc5C0zwskiELnJGF34Y/fK06n73JyBh6WqMdu7+V/QKNCEgcKU45n+pnlAL
vx+xflfMknWEOhLdT31ca0kvxtGEomOD1MNV+b1cRYBlL/oMC2IpIKd0N/HFa3Nc
pDmLcBWB4wKBgAIHXD4dlXG2TFLGXe8FBTWEWaavuoW8W/rxQWnVVtEAuT+ot5Om
BvcxUcUbOi5FD1QrHbQ4t2qklDAClQf52/bkRqjvSWcH2JGXW3W0k06zYbwfEPS7
tvrjWHFNhzFcPbhbmIuELthC9alzBb5NaGL6mJs6W8GbJB0tW9S+LlAzAoGBAIlB
h/B6Rs+s7fcSBuQfDyYttmhO7K2GbPan+niQJfKy3TOOm5VS7oC4rprbw7/MUqNn
frWJmdYCFmdawDtbdO0Yqdqmlo0EKdjw3pXAsMqdmuTe88tt/KZvHWbFcDU4YlQA
7OI662slRcW7ZdChi3lqs3H78BoETwnvhmgaLN7/AoGBAIVtEVcieOsasQ3Cje4L
mZxo9WFwtX4llH/CTZZeyek6VZBEWP8b3i1uh0uOzeiR7nDiwGEbHfXdvIvWrZqf
IC9Lo1D24uzE14XcKypFsYL5GAwtNhTAuP52tfV9V7DlS2QmxQt6hzx0/MhtdM3X
1XCsMrmi/WleIy611H2j0gUj
-----END PRIVATE KEY-----
File diff suppressed because it is too large Load Diff
+377
View File
@@ -0,0 +1,377 @@
import json
import os
import re
import signal
import subprocess
import sys
from typing import Dict, Optional, Pattern
import httpx
import pytest
import yaml
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_HA_PROXY,
SERVE_DEFAULT_APP_NAME,
)
from ray.serve._private.test_utils import get_application_url
from ray.util.state import list_actors
CONNECTION_ERROR_MSG = "connection error"
def ping_endpoint(app_name: str = SERVE_DEFAULT_APP_NAME, params: str = ""):
try:
url = get_application_url("HTTP", app_name=app_name)
return httpx.get(f"{url}/{params}").text
except httpx.HTTPError:
return CONNECTION_ERROR_MSG
def check_app_status(app_name: str, expected_status: str):
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)["applications"]
assert status[app_name]["status"] == expected_status
return True
def check_app_running(app_name: str):
return check_app_status(app_name, "RUNNING")
def check_http_response(expected_text: str, json: Optional[Dict] = None):
url = get_application_url("HTTP")
resp = httpx.post(url, json=json)
assert resp.text == expected_text
return True
def test_start_shutdown(ray_start_stop):
subprocess.check_output(["serve", "start"])
# deploy a simple app
import_path = "ray.serve.tests.test_config_files.arg_builders.build_echo_app"
deploy_response = subprocess.check_output(["serve", "deploy", import_path])
assert b"Sent deploy request successfully." in deploy_response
wait_for_condition(
check_http_response,
expected_text="DEFAULT",
timeout=15,
)
ret = subprocess.check_output(["serve", "shutdown", "-y"])
assert b"Sent shutdown request; applications will be deleted asynchronously" in ret
def check_no_apps():
status = subprocess.check_output(["serve", "status"])
return b"applications: {}" in status
wait_for_condition(check_no_apps, timeout=15)
# Test shutdown when no Serve instance is running
ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
assert b"No Serve instance found running" in ret
def test_start_shutdown_without_serve_running(ray_start_stop):
# Test shutdown when no Serve instance is running
ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
assert b"No Serve instance found running" in ret
# def test_start_shutdown_without_ray_running():
# # Test shutdown when Ray is not running
# ret = subprocess.check_output(["serve", "shutdown", "-y"], stderr=subprocess.STDOUT)
# assert b"Unable to shutdown Serve on the cluster" in ret
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_shutdown(ray_start_stop):
"""Deploys a config file and shuts down the Serve application."""
# Check that `serve shutdown` works even if no Serve app is running
subprocess.check_output(["serve", "shutdown", "-y"])
def num_live_deployments():
status_response = subprocess.check_output(["serve", "status"])
serve_status = yaml.safe_load(status_response)["applications"][
SERVE_DEFAULT_APP_NAME
]
return len(serve_status["deployments"])
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph.yaml"
)
# Check idempotence
num_iterations = 2
for iteration in range(1, num_iterations + 1):
print(f"*** Starting Iteration {iteration}/{num_iterations} ***\n")
print("Deploying config.")
subprocess.check_output(["serve", "deploy", config_file_name])
wait_for_condition(lambda: num_live_deployments() == 2, timeout=15)
print("Deployment successful. Deployments are live.")
# `serve config` and `serve status` should print non-empty schemas
config_response = subprocess.check_output(["serve", "config"])
yaml.safe_load(config_response)
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
assert len(status["applications"])
print("`serve config` and `serve status` print non-empty responses.\n")
print("Deleting Serve app.")
subprocess.check_output(["serve", "shutdown", "-y"])
# `serve config` and `serve status` should print messages indicating
# nothing is deployed
def serve_config_empty_warning():
config_response = subprocess.check_output(["serve", "config"]).decode(
"utf-8"
)
return config_response == "No configuration was found.\n"
def serve_status_empty():
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
return len(status["applications"]) == 0
wait_for_condition(serve_config_empty_warning)
wait_for_condition(serve_status_empty)
print("`serve config` and `serve status` print empty responses.\n")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_deploy_with_http_options(ray_start_stop):
"""Deploys config with host and port options specified"""
f1 = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph_http.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
with open(f1, "r") as config_file:
config = yaml.safe_load(config_file)
deploy_response = subprocess.check_output(["serve", "deploy", f1])
assert success_message_fragment in deploy_response
wait_for_condition(
lambda: httpx.post("http://localhost:8005/", json=None).text
== "wonderful world",
timeout=15,
)
# Config should contain matching host and port options
info_response = subprocess.check_output(["serve", "config"])
info = yaml.safe_load(info_response)
# TODO(zcin): the assertion should just be `info == config` here but the output
# formatting removes a lot of info.
assert info == config["applications"][0]
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
@pytest.mark.parametrize("use_command", [True, False])
def test_idempotence_after_controller_death(ray_start_stop, use_command: bool):
"""Check that CLI is idempotent even if controller dies."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "basic_graph.yaml"
)
success_message_fragment = b"Sent deploy request successfully."
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
expected_num_actors = 4
if RAY_SERVE_ENABLE_HA_PROXY:
# fallback proxy
expected_num_actors += 1
serve.start()
wait_for_condition(
lambda: len(list_actors(filters=[("state", "=", "ALIVE")]))
== expected_num_actors,
timeout=15,
)
# Kill controller
if use_command:
subprocess.check_output(["serve", "shutdown", "-y"])
else:
serve.shutdown()
status_response = subprocess.check_output(["serve", "status"])
status_info = yaml.safe_load(status_response)
assert len(status_info["applications"]) == 0
deploy_response = subprocess.check_output(["serve", "deploy", config_file_name])
assert success_message_fragment in deploy_response
# Restore testing controller
serve.start()
wait_for_condition(
lambda: len(list_actors(filters=[("state", "=", "ALIVE")]))
== expected_num_actors,
timeout=15,
)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_control_c_shutdown_serve_components(ray_start_stop):
"""Test ctrl+c after `serve run` shuts down serve components."""
p = subprocess.Popen(["serve", "run", "ray.serve.tests.test_cli_3.echo_app"])
# Make sure Serve components are up and running
wait_for_condition(check_app_running, app_name=SERVE_DEFAULT_APP_NAME)
assert httpx.get("http://localhost:8000/-/healthz").text == "success"
assert json.loads(httpx.get("http://localhost:8000/-/routes").text) == {
"/": "default"
}
assert httpx.get("http://localhost:8000/").text == "hello"
# Send ctrl+c to shutdown Serve components
p.send_signal(signal.SIGINT)
p.wait()
# Make sure Serve components are shutdown
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)
assert status == {"applications": {}, "proxies": {}, "target_capacity": None}
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
@pytest.mark.parametrize(
"ray_start_stop_in_specific_directory",
[
os.path.join(os.path.dirname(__file__), "test_config_files"),
],
indirect=True,
)
def test_deploy_with_access_to_current_directory(ray_start_stop_in_specific_directory):
"""Test serve deploy using modules in the current directory succeeds.
There was an issue where dashboard client doesn't add the current directory to
the sys.path and failed to deploy a Serve app defined in the directory. This
test ensures that files in the current directory can be accessed and deployed.
See: https://github.com/ray-project/ray/issues/43889
"""
# Deploy Serve application with a config in the current directory.
subprocess.check_output(["serve", "deploy", "use_current_working_directory.yaml"])
# Ensure serve deploy eventually succeeds.
def check_deploy_successfully():
status_response = subprocess.check_output(["serve", "status"])
assert b"RUNNING" in status_response
return True
wait_for_condition(check_deploy_successfully, timeout=5)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
class TestRayReinitialization:
@pytest.fixture
def import_file_name(self) -> str:
return "ray.serve.tests.test_config_files.ray_already_initialized:app"
@pytest.fixture
def pattern(self) -> Pattern:
return re.compile(r"Connecting to existing Ray cluster at address: (.*)\.\.\.")
@pytest.fixture
def ansi_escape(self) -> Pattern:
return re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
def test_run_without_address(self, import_file_name, ray_start_stop):
"""Test serve run with ray already initialized and run without address argument.
When the imported file already initialized a ray instance and serve doesn't run
with address argument, then serve does not reinitialize another ray instance and
cause error.
"""
p = subprocess.Popen(["serve", "run", import_file_name])
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
def test_run_with_address_same_address(self, import_file_name, ray_start_stop):
"""Test serve run with ray already initialized and run with address argument
that has the same address as existing ray instance.
When the imported file already initialized a ray instance and serve runs with
address argument same as the ray instance, then serve does not reinitialize
another ray instance and cause error.
"""
p = subprocess.Popen(
["serve", "run", "--address=127.0.0.1:6379", import_file_name]
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
def test_run_with_address_different_address(
self, import_file_name, pattern, ansi_escape, ray_start_stop
):
"""Test serve run with ray already initialized and run with address argument
that has the different address as existing ray instance.
When the imported file already initialized a ray instance and serve runs with
address argument different as the ray instance, then serve does not reinitialize
another ray instance and cause error and logs warning to the user.
"""
p = subprocess.Popen(
["serve", "run", "--address=ray://123.45.67.89:50005", import_file_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
process_output, _ = p.communicate()
logs = process_output.decode("utf-8").strip()
ray_address = ansi_escape.sub("", pattern.search(logs).group(1))
expected_warning_message = (
"An address was passed to `serve run` but the imported module also "
f"connected to Ray at a different address: '{ray_address}'. You do not "
"need to call `ray.init` in your code when using `serve run`."
)
assert expected_warning_message in logs
def test_run_with_auto_address(
self, import_file_name, pattern, ansi_escape, ray_start_stop
):
"""Test serve run with ray already initialized and run with "auto" address
argument.
When the imported file already initialized a ray instance and serve runs with
address argument same as the ray instance, then serve does not reinitialize
another ray instance and cause error.
"""
p = subprocess.Popen(
["serve", "run", "--address=auto", import_file_name],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
wait_for_condition(lambda: ping_endpoint() == "foobar", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
process_output, _ = p.communicate()
logs = process_output.decode("utf-8").strip()
ray_address = ansi_escape.sub("", pattern.search(logs).group(1))
expected_warning_message = (
"An address was passed to `serve run` but the imported module also "
f"connected to Ray at a different address: '{ray_address}'. You do not "
"need to call `ray.init` in your code when using `serve run`."
)
assert expected_warning_message not in logs
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+613
View File
@@ -0,0 +1,613 @@
import json
import os
import signal
import subprocess
import sys
import time
from typing import Union
import httpx
import pytest
import yaml
from pydantic import BaseModel
from ray import serve
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME
from ray.serve.handle import DeploymentHandle
from ray.serve.tests.common.remote_uris import (
TEST_DAG_PINNED_URI,
TEST_DEPLOY_GROUP_PINNED_URI,
)
CONNECTION_ERROR_MSG = "connection error"
def ping_endpoint(endpoint: str, params: str = ""):
endpoint = endpoint.lstrip("/")
try:
return httpx.get(f"http://localhost:8000/{endpoint}{params}").text
except httpx.HTTPError:
return CONNECTION_ERROR_MSG
def check_app_status(app_name: str, expected_status: str):
status_response = subprocess.check_output(["serve", "status"])
status = yaml.safe_load(status_response)["applications"]
assert status[app_name]["status"] == expected_status
return True
def check_app_running(app_name: str):
return check_app_status(app_name, "RUNNING")
@serve.deployment
def parrot(request):
return request.query_params["sound"]
parrot_node = parrot.bind()
@serve.deployment
class MetalDetector:
def __call__(self, *args):
return os.environ.get("buried_item", "no dice")
metal_detector_node = MetalDetector.bind()
@serve.deployment
class ConstructorFailure:
def __init__(self):
raise RuntimeError("Intentionally failing.")
constructor_failure_node = ConstructorFailure.bind()
@serve.deployment
class Macaw:
def __init__(self, color, name="Mulligan", surname=None):
self.color = color
self.name = name
self.surname = surname
def __call__(self):
if self.surname is not None:
return f"{self.name} {self.surname} is {self.color}!"
else:
return f"{self.name} is {self.color}!"
molly_macaw = Macaw.bind("green", name="Molly")
@serve.deployment
def global_f(*args):
return "wonderful world"
@serve.deployment
class NoArgDriver:
def __init__(self, h: DeploymentHandle):
self._h = h
async def __call__(self):
return await self._h.remote()
TestBuildFNode = global_f.bind()
TestBuildDagNode = NoArgDriver.bind(TestBuildFNode)
TestApp1Node = global_f.options(name="app1").bind()
TestApp2Node = NoArgDriver.options(name="app2").bind(global_f.bind())
@serve.deployment
class Echo:
def __init__(self, message: str):
print("Echo message:", message)
self._message = message
def __call__(self, *args):
return self._message
echo_app = Echo.bind("hello")
def build_echo_app(args):
return Echo.bind(args.get("message", "DEFAULT"))
class TypedArgs(BaseModel):
message: str = "DEFAULT"
def build_echo_app_typed(args: TypedArgs):
return Echo.bind(args.message)
k8sFNode = global_f.options(
num_replicas=2, ray_actor_options={"num_cpus": 2, "num_gpus": 1}
).bind()
class TestRun:
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"proxy_location,expected",
[
(
None,
"EveryNode",
), # default ProxyLocation `EveryNode` is used as http_options.location is not specified
("EveryNode", "EveryNode"),
("HeadOnly", "HeadOnly"),
("Disabled", "Disabled"),
],
)
def test_proxy_location(self, ray_start_stop, tmp_path, proxy_location, expected):
# when the `serve run` cli command is executed
# without serve already running (for the first time)
# `proxy_location` should be set from the config file if specified
def is_proxy_location_correct(expected_proxy_location: str) -> bool:
try:
response = httpx.get(
"http://localhost:8265/api/serve/applications/"
).text
response_json = json.loads(response)
print("response_json")
print(response_json)
return response_json["proxy_location"] == expected_proxy_location
except httpx.HTTPError:
return False
def arithmetic_config(with_proxy_location: Union[str, None]) -> str:
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
)
with open(config_file_name, "r") as config_file:
arithmetic_config_dict = yaml.safe_load(config_file)
config_path = tmp_path / "config.yaml"
if with_proxy_location:
arithmetic_config_dict["proxy_location"] = with_proxy_location
with open(config_path, "w") as f:
yaml.dump(arithmetic_config_dict, f)
return str(config_path)
config_path = arithmetic_config(with_proxy_location=proxy_location)
p = subprocess.Popen(["serve", "run", config_path])
wait_for_condition(
lambda: is_proxy_location_correct(expected_proxy_location=expected),
timeout=10,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.parametrize("number_of_kill_signals", (1, 2))
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_application(self, ray_start_stop, number_of_kill_signals):
"""Deploys valid config file and import path via `serve run`."""
# Deploy via config file
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "arithmetic.yaml"
)
print('Running config file "arithmetic.yaml".')
p = subprocess.Popen(["serve", "run", "--address=auto", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/", json=["ADD", 0]).json() == 1,
timeout=15,
)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/", json=["SUB", 5]).json() == 3,
timeout=15,
)
print(
"Run successful! Deployments are live and reachable over HTTP. Killing run."
)
for _ in range(number_of_kill_signals):
p.send_signal(signal.SIGINT)
# Mimic realistic human Ctrl-C timing. Without a gap, two
# back-to-back SIGINTs can land before the KeyboardInterrupt
# handler in `serve run` has a chance to run, aborting the
# process before graceful shutdown begins.
time.sleep(0.1)
p.wait()
with pytest.raises(httpx.HTTPError):
httpx.post("http://localhost:8000/", json=["ADD", 0]).json()
print("Kill successful! Deployments are not reachable over HTTP.")
print('Running node at import path "ray.serve.tests.test_cli_3.parrot_node".')
# Deploy via import path
p = subprocess.Popen(
["serve", "run", "--address=auto", "ray.serve.tests.test_cli_3.parrot_node"]
)
wait_for_condition(
lambda: ping_endpoint("/", params="?sound=squawk") == "squawk"
)
print(
"Run successful! Deployment is live and reachable over HTTP. Killing run."
)
p.send_signal(signal.SIGINT) # Equivalent to ctrl-C
p.wait()
assert ping_endpoint("/", params="?sound=squawk") == CONNECTION_ERROR_MSG
print("Kill successful! Deployment is not reachable over HTTP.")
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_multi_app(self, ray_start_stop):
"""Deploys valid multi-app config file via `serve run`."""
# Deploy via config file
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", "pizza_world.yaml"
)
print('Running config file "pizza_world.yaml".')
p = subprocess.Popen(["serve", "run", "--address=auto", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app1").text == "wonderful world",
timeout=15,
)
print('Application "app1" is reachable over HTTP.')
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app2", json=["ADD", 2]).text
== "12 pizzas please!",
timeout=15,
)
wait_for_condition(
lambda: httpx.post("http://localhost:8000/app2", json=["MUL", 2]).text
== "20 pizzas please!",
timeout=15,
)
print(
"Run successful! Deployments are live and reachable over HTTP. Killing run."
)
p.send_signal(signal.SIGINT) # Equivalent to ctrl-C
p.wait()
with pytest.raises(httpx.HTTPError):
_ = httpx.post("http://localhost:8000/app1").text
with pytest.raises(httpx.HTTPError):
_ = httpx.post("http://localhost:8000/app2", json=["ADD", 0]).text
print("Kill successful! Deployments are not reachable over HTTP.")
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_deployment_node(self, ray_start_stop):
"""Test `serve run` with bound args and kwargs."""
# Deploy via import path
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.molly_macaw",
]
)
wait_for_condition(lambda: ping_endpoint("/") == "Molly is green!", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"import_path",
[
"ray.serve.tests.test_cli_3.build_echo_app",
"ray.serve.tests.test_cli_3.build_echo_app_typed",
],
)
def test_run_builder_with_args(self, ray_start_stop, import_path: str):
"""Test `serve run` with args passed into a builder function.
Tests both the untyped and typed args cases.
"""
# First deploy without any arguments, should get default response.
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
import_path,
]
)
wait_for_condition(lambda: ping_endpoint("") == "DEFAULT", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
# Now deploy passing a message as an argument, should get passed message.
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
import_path,
"message=hello world",
]
)
wait_for_condition(lambda: ping_endpoint("") == "hello world", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("/") == CONNECTION_ERROR_MSG
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_runtime_env(self, ray_start_stop):
"""Test `serve run` with runtime_env passed in."""
# With import path
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.metal_detector_node",
"--runtime-env-json",
('{"env_vars": {"buried_item": "lucky coin"} }'),
]
)
wait_for_condition(
lambda: ping_endpoint("MetalDetector") == "lucky coin", timeout=10
)
p.send_signal(signal.SIGINT)
p.wait()
# With config
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
os.path.join(
os.path.dirname(__file__),
"test_config_files",
"missing_runtime_env.yaml",
),
"--runtime-env-json",
json.dumps(
{
"py_modules": [TEST_DEPLOY_GROUP_PINNED_URI],
"working_dir": "http://nonexistentlink-q490123950ni34t",
}
),
"--working-dir",
TEST_DAG_PINNED_URI,
]
)
wait_for_condition(lambda: ping_endpoint("") == "wonderful world", timeout=15)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize("config_file", ["basic_graph.yaml", "basic_multi.yaml"])
def test_run_config_port1(self, ray_start_stop, config_file):
"""Test that `serve run` defaults to port 8000."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", config_file
)
p = subprocess.Popen(["serve", "run", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8000/").text == "wonderful world",
timeout=15,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
@pytest.mark.parametrize(
"config_file", ["basic_graph_http.yaml", "basic_multi_http.yaml"]
)
def test_run_config_port2(self, ray_start_stop, config_file):
"""If config file specifies a port, the default port value should not be used."""
config_file_name = os.path.join(
os.path.dirname(__file__), "test_config_files", config_file
)
p = subprocess.Popen(["serve", "run", config_file_name])
wait_for_condition(
lambda: httpx.post("http://localhost:8005/").text == "wonderful world",
timeout=15,
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_teardown(self, ray_start_stop):
"""Consecutive serve runs should tear down controller so logs can always be seen."""
logs = subprocess.check_output(
["serve", "run", "ray.serve.tests.test_cli_3.constructor_failure_node"],
stderr=subprocess.STDOUT,
timeout=30,
).decode()
assert "Intentionally failing." in logs
logs = subprocess.check_output(
["serve", "run", "ray.serve.tests.test_cli_3.constructor_failure_node"],
stderr=subprocess.STDOUT,
timeout=30,
).decode()
assert "Intentionally failing." in logs
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_route_prefix_and_name_default(self, ray_start_stop):
"""Test `serve run` without route_prefix and name options."""
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"ray.serve.tests.test_cli_3.echo_app",
]
)
wait_for_condition(check_app_running, app_name=SERVE_DEFAULT_APP_NAME)
assert ping_endpoint("/") == "hello"
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_route_prefix_and_name_override(self, ray_start_stop):
"""Test `serve run` with route prefix option."""
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"--route-prefix=/hello",
"--name=hello_app",
"ray.serve.tests.test_cli_3.echo_app",
],
)
wait_for_condition(check_app_running, app_name="hello_app")
assert "Path '/' not found" in ping_endpoint("/")
assert ping_endpoint("/hello") == "hello"
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_config_request_timeout(self, ray_start_stop):
"""Test running serve with request timeout in http_options.
The config file has 0.1s as the `request_timeout_s` in the `http_options`. First
case checks that when the query runs longer than the 0.1s, the deployment returns a
task failed message. The second case checks that when the query takes less than
0.1s, the deployment returns a success message.
"""
config_file_name = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"http_option_request_timeout_s.yaml",
)
p = subprocess.Popen(["serve", "run", config_file_name])
# Ensure the http request is killed and failed when the deployment runs longer than
# the 0.1 request_timeout_s set in in the config yaml
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1?sleep_s=0.11").status_code
== 408,
)
# Ensure the http request returned the correct response when the deployment runs
# shorter than the 0.1 request_timeout_s set up in the config yaml
wait_for_condition(
lambda: httpx.get("http://localhost:8000/app1?sleep_s=0.09").text
== "Task Succeeded!",
)
p.send_signal(signal.SIGINT)
p.wait()
@pytest.mark.skipif(
sys.platform == "win32", reason="File path incorrect on Windows."
)
def test_run_reload_basic(self, ray_start_stop, tmp_path):
"""Test `serve run` with reload."""
code_template = """
from ray import serve
@serve.deployment
class MessageDeployment:
def __init__(self, msg):
{invalid_suffix}
self.msg = msg
def __call__(self):
return self.msg
msg_app = MessageDeployment.bind("Hello {message}!")
"""
def write_file(message: str, invalid_suffix: str = ""):
with open(os.path.join(tmp_path, "reload_serve.py"), "w") as f:
code = code_template.format(
invalid_suffix=invalid_suffix, message=message
)
print(f"Writing updated code:\n{code}")
f.write(code)
f.flush()
write_file("World")
p = subprocess.Popen(
[
"serve",
"run",
"--address=auto",
"--app-dir",
tmp_path,
"--reload",
"reload_serve:msg_app",
]
)
wait_for_condition(lambda: ping_endpoint("") == "Hello World!", timeout=10)
# Sleep to ensure the `serve run` command is in the file watching loop when we
# write the change, else it won't be picked up.
time.sleep(5)
# Write the file: an update should be auto-triggered.
write_file("Updated")
wait_for_condition(lambda: ping_endpoint("") == "Hello Updated!", timeout=10)
# Ensure a bad change doesn't shut down serve and serve reports deploy failed.
write_file(message="update1", invalid_suffix="foobar")
wait_for_condition(
condition_predictor=check_app_status,
app_name="default",
expected_status="DEPLOY_FAILED",
)
# Ensure the following reload happens as expected.
write_file("Updated2")
wait_for_condition(lambda: ping_endpoint("") == "Hello Updated2!", timeout=10)
p.send_signal(signal.SIGINT)
p.wait()
assert ping_endpoint("") == CONNECTION_ERROR_MSG
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+190
View File
@@ -0,0 +1,190 @@
import os
import subprocess
import sys
from tempfile import NamedTemporaryFile
import grpc
import httpx
import pytest
from ray._common.test_utils import wait_for_condition
from ray.serve._private.constants import (
RAY_SERVE_ENABLE_DIRECT_INGRESS,
)
from ray.serve._private.test_utils import (
get_application_url,
ping_fruit_stand,
ping_grpc_another_method,
ping_grpc_call_method,
ping_grpc_healthz,
ping_grpc_list_applications,
ping_grpc_model_multiplexing,
ping_grpc_streaming,
)
from ray.serve.generated import serve_pb2, serve_pb2_grpc
from ray.serve.tests.test_cli_2 import check_app_running, ping_endpoint
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_build_multi_app(ray_start_stop):
with NamedTemporaryFile(mode="w+", suffix=".yaml") as tmp:
print('Building nodes "TestApp1Node" and "TestApp2Node".')
# Build an app
grpc_servicer_func_root = "ray.serve.generated.serve_pb2_grpc"
subprocess.check_output(
[
"serve",
"build",
"ray.serve.tests.test_cli_3.TestApp1Node",
"ray.serve.tests.test_cli_3.TestApp2Node",
"ray.serve.tests.test_config_files.grpc_deployment.g",
"--grpc-servicer-functions",
f"{grpc_servicer_func_root}.add_UserDefinedServiceServicer_to_server",
"-o",
tmp.name,
]
)
print("Build succeeded! Deploying node.")
subprocess.check_output(["serve", "deploy", tmp.name])
print("Deploy succeeded!")
wait_for_condition(
lambda: ping_endpoint("app1") == "wonderful world", timeout=15
)
print("App 1 is live and reachable over HTTP.")
wait_for_condition(
lambda: ping_endpoint("app2") == "wonderful world", timeout=15
)
print("App 2 is live and reachable over HTTP.")
app_name = "app3"
channel = grpc.insecure_channel(get_application_url("gRPC", app_name=app_name))
stub = serve_pb2_grpc.UserDefinedServiceStub(channel)
request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
metadata = (("application", app_name),)
response = stub.__call__(request=request, metadata=metadata)
assert response.greeting == "Hello foo from bar"
print("App 3 is live and reachable over gRPC.")
print("Deleting applications.")
app_urls = [
get_application_url("HTTP", app_name=app) for app in ["app1", "app2"]
]
subprocess.check_output(["serve", "shutdown", "-y"])
def check_no_apps():
for url in app_urls:
with pytest.raises(httpx.HTTPError):
_ = httpx.get(url).text
return True
wait_for_condition(check_no_apps, timeout=15)
print("Delete succeeded! Node is no longer reachable over HTTP.")
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_serving_request_through_grpc_proxy(ray_start_stop):
"""Test serving request through gRPC proxy
When Serve runs with a gRPC deployment, the app should be deployed successfully,
both ListApplications and Healthz methods returning success response, and registered
gRPC methods are routing to the correct replica and return the correct response.
"""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"deploy_grpc_app.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
app1 = "app1"
app_names = [app1]
# Wait for the application to be RUNNING before sending requests.
wait_for_condition(check_app_running, app_name=app1)
channel = grpc.insecure_channel(get_application_url("gRPC", app_name=app1))
# Ensures ListApplications method succeeding.
ping_grpc_list_applications(channel, app_names)
# Ensures Healthz method succeeding.
ping_grpc_healthz(channel)
# Ensures a custom defined method is responding correctly.
ping_grpc_call_method(channel, app1)
# Ensures another custom defined method is responding correctly.
ping_grpc_another_method(channel, app1)
# TODO: gRPC streaming is not supported in direct ingress / haproxy
if not RAY_SERVE_ENABLE_DIRECT_INGRESS:
# Ensure Streaming method is responding correctly.
ping_grpc_streaming(channel, app1)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
@pytest.mark.skipif(
RAY_SERVE_ENABLE_DIRECT_INGRESS,
reason="Model multiplexing is not supported on the ingress deployment when "
"direct ingress / HAProxy is enabled (the multiplexed model ID is not "
"propagated to the replica).",
)
def test_serving_grpc_proxy_model_multiplexing(ray_start_stop):
"""Test model multiplexing over gRPC when deployed via the CLI."""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"deploy_grpc_multiplexed_app.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
app1 = "app1"
# Wait for the application to be RUNNING before sending requests.
wait_for_condition(check_app_running, app_name=app1)
channel = grpc.insecure_channel(get_application_url("gRPC", app_name=app1))
# Ensures model multiplexing is responding correctly.
ping_grpc_model_multiplexing(channel, app1)
@pytest.mark.skipif(sys.platform == "win32", reason="File path incorrect on Windows.")
def test_grpc_proxy_model_composition(ray_start_stop):
"""Test serving request through gRPC proxy
When Serve runs with a gRPC deployment, the app should be deployed successfully,
both ListApplications and Healthz methods returning success response, and model
composition should work correctly.
"""
config_file = os.path.join(
os.path.dirname(__file__),
"test_config_files",
"deploy_grpc_model_composition.yaml",
)
subprocess.check_output(["serve", "deploy", config_file], stderr=subprocess.STDOUT)
app = "app1"
app_names = [app]
# Wait for the application to be RUNNING before sending requests.
wait_for_condition(check_app_running, app_name=app)
channel = grpc.insecure_channel(get_application_url("gRPC", app_name=app))
# Ensures ListApplications method succeeding.
ping_grpc_list_applications(channel, app_names)
# Ensures Healthz method succeeding.
ping_grpc_healthz(channel)
# Ensure model composition is responding correctly.
ping_fruit_stand(channel, app)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+723
View File
@@ -0,0 +1,723 @@
import os
import sys
import time
from collections import defaultdict
import httpx
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.cluster_utils import Cluster
from ray.exceptions import RayActorError
from ray.serve._private.common import DeploymentID, DeploymentStatus, ReplicaState
from ray.serve._private.constants import (
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY,
SERVE_DEFAULT_APP_NAME,
SERVE_NAMESPACE,
)
from ray.serve._private.deployment_state import ReplicaStartupStatus
from ray.serve._private.test_utils import (
check_deployment_status,
expected_proxy_actors,
skip_if_haproxy,
)
from ray.serve._private.utils import calculate_remaining_timeout, get_head_node_id
from ray.serve.config import GangSchedulingConfig
from ray.serve.context import _get_global_client
from ray.serve.handle import DeploymentHandle
from ray.serve.schema import ServeDeploySchema
from ray.util.state import list_actors
def get_pids(expected, deployment_name="D", app_name="default", timeout=30):
handle = serve.get_deployment_handle(deployment_name, app_name)
pids = set()
start = time.time()
while len(pids) < expected:
for r in [handle.remote() for _ in range(10)]:
try:
pids.add(
r.result(
timeout_s=calculate_remaining_timeout(
timeout_s=timeout,
start_time_s=start,
curr_time_s=time.time(),
)
)
)
except RayActorError:
# Handle sent request to dead actor before running replicas were updated
# This can happen because health check period = 1s
pass
if time.time() - start >= timeout:
raise TimeoutError("Timed out waiting for pids.")
return pids
@serve.deployment(health_check_period_s=1, max_ongoing_requests=1)
def pid():
time.sleep(0.1)
return os.getpid()
pid_app = pid.bind()
def test_scale_up(ray_cluster):
cluster = ray_cluster
cluster.add_node(num_cpus=1)
cluster.connect(namespace=SERVE_NAMESPACE)
# By default, Serve controller and proxy actors use 0 CPUs,
# so initially there should only be room for 1 replica.
app_config = {
"name": "default",
"import_path": "ray.serve.tests.test_cluster.pid_app",
"deployments": [{"name": "pid", "num_replicas": 1}],
}
serve.start()
client = serve.context._connect()
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
client._wait_for_application_running("default")
pids1 = get_pids(1, deployment_name="pid", app_name="default")
app_config["deployments"][0]["num_replicas"] = 3
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
# Check that a new replica has not started in 1.0 seconds. This
# doesn't guarantee that a new replica won't ever be started, but
# 1.0 seconds is a reasonable upper bound on replica startup time.
with pytest.raises(TimeoutError):
client._wait_for_application_running("default", timeout_s=1)
assert get_pids(1, deployment_name="pid", app_name="default") == pids1
# Add a node with another CPU, another replica should get placed.
cluster.add_node(num_cpus=1)
with pytest.raises(TimeoutError):
client._wait_for_application_running("default", timeout_s=1)
pids2 = get_pids(2, deployment_name="pid", app_name="default")
assert pids1.issubset(pids2)
# Add a node with another CPU, the final replica should get placed
# and the deploy goal should be done.
cluster.add_node(num_cpus=1)
client._wait_for_application_running("default")
pids3 = get_pids(3, deployment_name="pid", app_name="default")
assert pids2.issubset(pids3)
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_node_failure(ray_cluster):
cluster = ray_cluster
cluster.add_node(num_cpus=3)
cluster.connect(namespace=SERVE_NAMESPACE)
# NOTE(edoakes): we need to start serve before adding the worker node to
# guarantee that the controller is placed on the head node (we should be
# able to tolerate being placed on workers, but there's currently a bug).
# We should add an explicit test for that in the future when it's fixed.
serve.start()
worker_node = cluster.add_node(num_cpus=2)
@serve.deployment(num_replicas=5, health_check_period_s=1, max_ongoing_requests=1)
def D(*args):
time.sleep(0.1)
return os.getpid()
print("Initial deploy.")
serve.run(D.bind())
pids1 = get_pids(5)
# Remove the node. There should still be three replicas running.
print("Kill node.")
cluster.remove_node(worker_node)
pids2 = get_pids(3)
assert pids2.issubset(pids1)
# Add a worker node back. One replica should get placed.
print("Add back first node.")
cluster.add_node(num_cpus=1)
pids3 = get_pids(4)
assert pids2.issubset(pids3)
# Add another worker node. One more replica should get placed.
print("Add back second node.")
cluster.add_node(num_cpus=1)
pids4 = get_pids(5)
assert pids3.issubset(pids4)
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_replica_startup_status_transitions(ray_cluster):
cluster = ray_cluster
cluster.add_node(num_cpus=1)
cluster.connect(namespace=SERVE_NAMESPACE)
serve.start()
client = _get_global_client()
signal = SignalActor.remote()
@serve.deployment(ray_actor_options={"num_cpus": 2})
class E:
async def __init__(self):
await signal.wait.remote()
serve._run(E.bind(), _blocking=False)
def get_replicas(replica_state):
controller = client._controller
replicas = ray.get(
controller._dump_replica_states_for_testing.remote(
DeploymentID(name=E.name)
)
)
return replicas.get([replica_state])
# wait for serve to start the replica
wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0)
# currently there are no resources to allocate the replica
def get_starting_replica():
replicas = get_replicas(ReplicaState.STARTING)
return replicas[0] if replicas else None
def is_pending_allocation():
replica = get_starting_replica()
if replica is None:
return False
return replica.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION
wait_for_condition(is_pending_allocation)
# add the necessary resources to allocate the replica
cluster.add_node(num_cpus=4)
wait_for_condition(lambda: (ray.cluster_resources().get("CPU", 0) >= 4))
wait_for_condition(lambda: (ray.available_resources().get("CPU", 0) >= 2))
def is_replica_pending_initialization():
replica = get_starting_replica()
if replica is None:
return False
status, _ = replica.check_started()
return status == ReplicaStartupStatus.PENDING_INITIALIZATION
wait_for_condition(is_replica_pending_initialization, timeout=25)
# send signal to complete replica initialization
ray.get(signal.send.remote())
def check_succeeded():
# After initialization succeeds, replica transitions to RUNNING state
# So check both STARTING and RUNNING states
replica = get_starting_replica()
if replica:
status, _ = replica.check_started()
if status == ReplicaStartupStatus.SUCCEEDED:
return True
# Check if replica has moved to RUNNING state (which means it succeeded)
running_replicas = get_replicas(ReplicaState.RUNNING)
if running_replicas and len(running_replicas) > 0:
return True
return False
wait_for_condition(check_succeeded)
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
def test_gang_replica_startup_status_transitions(ray_cluster):
cluster = ray_cluster
# Start with only 1 CPU — not enough for a gang of 2 replicas each needing 0.75 CPUs.
cluster.add_node(num_cpus=1)
cluster.connect(namespace=SERVE_NAMESPACE)
serve.start()
client = _get_global_client()
signal = SignalActor.remote()
@serve.deployment(
ray_actor_options={"num_cpus": 0.75},
num_replicas=2,
gang_scheduling_config=GangSchedulingConfig(gang_size=2),
)
class GangDeployment:
async def __init__(self):
await signal.wait.remote()
serve._run(GangDeployment.bind(), _blocking=False)
def get_replicas(replica_state):
controller = client._controller
replicas = ray.get(
controller._dump_replica_states_for_testing.remote(
DeploymentID(name="GangDeployment")
)
)
return replicas.get([replica_state])
# Wait for replicas to be created in STARTING state.
wait_for_condition(lambda: len(get_replicas(ReplicaState.STARTING)) > 0)
# With only 1 CPU available and each replica needing 0.75, replicas should
# be stuck in PENDING_ALLOCATION.
def is_pending_allocation():
replicas = get_replicas(ReplicaState.STARTING)
if not replicas:
return False
return all(
r.check_started()[0] == ReplicaStartupStatus.PENDING_ALLOCATION
for r in replicas
)
wait_for_condition(is_pending_allocation)
# Add enough resources for the gang
cluster.add_node(num_cpus=1)
wait_for_condition(lambda: ray.cluster_resources().get("CPU", 0) == 2)
# Replicas should transition to PENDING_INITIALIZATION
def is_pending_initialization():
replicas = get_replicas(ReplicaState.STARTING)
if not replicas:
return False
return all(
r.check_started()[0] == ReplicaStartupStatus.PENDING_INITIALIZATION
for r in replicas
)
wait_for_condition(is_pending_initialization, timeout=30)
# Complete initialization
ray.get(signal.send.remote())
# Replicas should transition to RUNNING
def check_running():
running_replicas = get_replicas(ReplicaState.RUNNING)
return len(running_replicas) == 2
wait_for_condition(check_running, timeout=30)
@serve.deployment
def f():
pass
f_app = f.bind()
def test_intelligent_scale_down(ray_cluster):
cluster = ray_cluster
# Head node
cluster.add_node(num_cpus=0)
cluster.connect(namespace=SERVE_NAMESPACE)
cluster.add_node(num_cpus=2)
cluster.add_node(num_cpus=2)
serve.start()
client = _get_global_client()
def get_actor_distributions():
node_to_actors = defaultdict(list)
for actor in list_actors(
address=cluster.address, filters=[("STATE", "=", "ALIVE")]
):
if "ServeReplica" not in actor.class_name:
continue
node_to_actors[actor.node_id].append(actor)
return set(map(len, node_to_actors.values()))
def check_app_running_with_replicas(num_replicas):
status = serve.status().applications["default"]
assert status.status == "RUNNING"
assert status.deployments["f"].replica_states["RUNNING"] == num_replicas
return True
app_config = {
"name": "default",
"import_path": "ray.serve.tests.test_cluster.f_app",
"deployments": [{"name": "f", "num_replicas": 3}],
}
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(check_app_running_with_replicas, num_replicas=3)
assert get_actor_distributions() == {2, 1}
app_config["deployments"][0]["num_replicas"] = 2
client.deploy_apps(ServeDeploySchema(**{"applications": [app_config]}))
wait_for_condition(check_app_running_with_replicas, num_replicas=2)
assert get_actor_distributions() == {2}
@pytest.mark.skipif(sys.platform == "win32", reason="Flaky on Windows.")
@pytest.mark.skipif(
RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY, reason="Needs spread strategy."
)
def test_replica_spread(ray_cluster):
cluster = ray_cluster
cluster.add_node(num_cpus=2)
# NOTE(edoakes): we need to start serve before adding the worker node to
# guarantee that the controller is placed on the head node (we should be
# able to tolerate being placed on workers, but there's currently a bug).
# We should add an explicit test for that in the future when it's fixed.
cluster.connect(namespace=SERVE_NAMESPACE)
serve.start()
worker_node = cluster.add_node(num_cpus=2)
@serve.deployment(
num_replicas=2,
health_check_period_s=1,
)
def D():
return "hi"
serve.run(D.bind())
def get_num_nodes():
client = _get_global_client()
details = client.get_serve_details()
dep = details["applications"]["default"]["deployments"]["D"]
nodes = {r["node_id"] for r in dep["replicas"]}
print("replica nodes", nodes)
return len(nodes)
# Check that the two replicas are spread across the two nodes.
wait_for_condition(lambda: get_num_nodes() == 2)
# Kill the worker node. The second replica should get rescheduled on
# the head node.
print("Removing worker node. Replica should be rescheduled.")
cluster.remove_node(worker_node)
# Check that the replica on the dead node can be rescheduled.
wait_for_condition(lambda: get_num_nodes() == 1)
def test_autoscale_upscaling_stuck_then_healthy(ray_cluster):
"""Test that deployment stuck in upscaling (due to insufficient cluster resources)
recovers to healthy when ongoing requests drop to zero.
Setup: Head with 0 CPUs + 1 worker with 1 CPU. 1 replica using 1 CPU,
target_ongoing_requests=1. Send 2 requests via handle -> autoscaler wants 2
replicas but can't add one (no CPU). Deployment stuck in UPSCALING.
Release requests -> deployment HEALTHY.
"""
cluster = ray_cluster
cluster.add_node(num_cpus=0) # Head node (controller/proxy use 0 CPU)
cluster.connect(namespace=SERVE_NAMESPACE)
serve.start() # Start before adding worker so controller goes on head
cluster.add_node(num_cpus=1) # Worker with 1 CPU for replica
cluster.wait_for_nodes()
signal = SignalActor.remote()
@serve.deployment(
autoscaling_config={
"min_replicas": 1,
"max_replicas": 2,
"target_ongoing_requests": 1,
"metrics_interval_s": 0.1,
"look_back_period_s": 0.5,
"upscale_delay_s": 0,
# If delay is large then the test will be stuck in UPSCALING state.
"downscale_delay_s": 1,
},
max_ongoing_requests=1,
ray_actor_options={"num_cpus": 1},
graceful_shutdown_timeout_s=2,
)
def blocking_replica():
ray.get(signal.wait.remote())
return "ok"
handle = serve.run(blocking_replica.bind())
wait_for_condition(
check_deployment_status,
name="blocking_replica",
expected_status=DeploymentStatus.HEALTHY,
)
# Send 2 requests - first occupies the replica, second queues. With
# target_ongoing_requests=1 and 1 replica, 2 requests triggers scale to 2.
responses = [handle.remote() for _ in range(2)]
# Deployment should get stuck in UPSCALING: autoscaler wants 2 replicas
# but cluster only has 1 CPU (replica uses it all).
wait_for_condition(
check_deployment_status,
name="blocking_replica",
expected_status=DeploymentStatus.UPSCALING,
timeout=15,
)
# Release the signal so running requests complete and go to zero.
ray.get(signal.send.remote())
for r in responses:
assert r.result() == "ok"
# Deployment should recover to HEALTHY as load drops (may go through
# DOWNSCALING first if a second replica was briefly added).
wait_for_condition(
check_deployment_status,
name="blocking_replica",
expected_status=DeploymentStatus.HEALTHY,
timeout=30,
)
def test_handle_prefers_replicas_on_same_node(ray_cluster):
"""Verify that handle calls prefer replicas on the same node when possible.
If all replicas on the same node are occupied (at `max_ongoing_requests` limit),
requests should spill to other nodes.
"""
cluster = ray_cluster
cluster.add_node(num_cpus=1)
cluster.add_node(num_cpus=1)
signal = SignalActor.remote()
@serve.deployment(num_replicas=2, max_ongoing_requests=1)
def inner(block_on_signal):
if block_on_signal:
ray.get(signal.wait.remote())
return ray.get_runtime_context().get_node_id()
@serve.deployment(num_replicas=1, ray_actor_options={"num_cpus": 0})
class Outer:
def __init__(self, inner_handle: DeploymentHandle):
self._h = inner_handle.options(_prefer_local_routing=True)
def get_node_id(self) -> str:
return ray.get_runtime_context().get_node_id()
async def call_inner(self, block_on_signal: bool = False) -> str:
return await self._h.remote(block_on_signal)
# The inner deployment's two replicas will be spread across the two nodes and
# the outer deployment's single replica will be placed on one of them.
h = serve.run(Outer.bind(inner.bind()))
# When sending requests sequentially, all requests to the inner deployment should
# go to the replica on the same node as the outer deployment replica.
outer_node_id = h.get_node_id.remote().result()
for _ in range(10):
assert h.call_inner.remote().result() == outer_node_id
# Make a blocking request to the inner deployment replica on the same node.
blocked_response = h.call_inner.remote(block_on_signal=True)
with pytest.raises(TimeoutError):
blocked_response.result(timeout_s=1)
# Because there's a blocking request and `max_ongoing_requests` is set to 1, all
# requests should now spill to the other node.
for _ in range(10):
assert h.call_inner.remote().result() != outer_node_id
ray.get(signal.send.remote())
assert blocked_response.result() == outer_node_id
# TODO: HAProxy's default ingress balances across all replicas with no
# node-local preference. prefer-local routing could be wired under HAProxy via
# the ingress_request_router use-server delegation, then this skip dropped.
@skip_if_haproxy("balances across replicas without node-local preference")
@pytest.mark.parametrize("set_flag", [True, False])
def test_proxy_prefers_replicas_on_same_node(ray_cluster: Cluster, set_flag):
"""When the feature flag is turned on via env var, verify that http proxy routes to
replicas on the same node when possible. Otherwise if env var is not set, http proxy
should route to all replicas equally.
"""
if not set_flag:
os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"] = "0"
cluster = ray_cluster
cluster.add_node(num_cpus=1)
cluster.add_node(num_cpus=1)
# Only start one HTTP proxy on the head node.
serve.start(http_options={"location": "HeadOnly"})
head_node_id = get_head_node_id()
@serve.deployment(num_replicas=2, max_ongoing_requests=1)
def f():
return ray.get_runtime_context().get_node_id()
# The deployment's two replicas will be spread across the two nodes
serve.run(f.bind())
# Since they're sent sequentially, all requests should be routed to
# the replica on the head node
responses = [httpx.post("http://localhost:8000").text for _ in range(10)]
if set_flag:
assert all(resp == head_node_id for resp in responses)
else:
assert len(set(responses)) == 2
if "RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING" in os.environ:
del os.environ["RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING"]
class TestHealthzAndRoutes:
def test_head_node_proxy_healthy(self, ray_cluster: Cluster):
"""When a new cluster is started with no replicas, head node proxy should
respond with 200 at /-/healthz and /-/routes"""
cluster = ray_cluster
cluster.add_node(num_cpus=0) # Head node
cluster.wait_for_nodes()
ray.init(address=cluster.address)
serve.start(http_options={"location": "EveryNode"})
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Dummy:
pass
serve.run(Dummy.bind())
# Head node proxy /-/healthz and /-/routes should return 200
r = httpx.post("http://localhost:8000/-/healthz")
assert r.status_code == 200
r = httpx.post("http://localhost:8000/-/routes")
assert r.status_code == 200
def test_head_and_worker_nodes_no_replicas(self, ray_cluster: Cluster):
"""Test `/-/healthz` and `/-/routes` return the correct responses for head and
worker nodes.
When there are replicas on all nodes, `/-/healthz` and `/-/routes` on all nodes
should return 200. When there are no replicas on any nodes, `/-/healthz` and
`/-/routes` on the head node should continue to return 200. `/-/healthz` and
`/-/routes` on the worker node should start to return 503
"""
# Setup worker http proxy to be pointing to port 8001. Head node http proxy will
# continue to be pointing to the default port 8000.
cluster = ray_cluster
cluster.add_node(num_cpus=0)
cluster.add_node(
num_cpus=2, env_vars={"RAY_SERVE_WORKER_PROXY_HTTP_PORT": "8001"}
)
cluster.wait_for_nodes()
ray.init(address=cluster.address)
serve.start(http_options={"location": "EveryNode"})
# Deploy 2 replicas, both should be on the worker node.
@serve.deployment(num_replicas=2)
class HelloModel:
def __call__(self):
return "hello"
model = HelloModel.bind()
serve.run(target=model)
# Ensure worker node has both replicas.
def check_replicas_on_worker_nodes():
return (
len(
{
a.node_id
for a in list_actors(address=cluster.address)
if a.class_name.startswith("ServeReplica")
}
)
== 1
)
wait_for_condition(check_replicas_on_worker_nodes)
# Total alive actors: EveryNode proxies on both nodes + 1 controller +
# 2 replicas. Under HAProxy each proxy node runs an HAProxyManager and
# the head node adds a fallback ProxyActor.
expected_num_actors = (
sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1 + 2
)
wait_for_condition(
lambda: len(list_actors(address=cluster.address)) == expected_num_actors
)
assert len(ray.nodes()) == 2
# Ensure `/-/healthz` and `/-/routes` return 200 and expected responses
# on both nodes.
def check_request(url: str, expected_code: int, expected_text: str):
req = httpx.get(url)
assert req.status_code == expected_code
assert req.text == expected_text
return True
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8000/-/healthz",
expected_code=200,
expected_text="success",
)
assert httpx.get("http://127.0.0.1:8000/-/routes").status_code == 200
assert httpx.get("http://127.0.0.1:8000/-/routes").text == '{"/":"default"}'
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8001/-/healthz",
expected_code=200,
expected_text="success",
)
assert httpx.get("http://127.0.0.1:8001/-/routes").status_code == 200
assert httpx.get("http://127.0.0.1:8001/-/routes").text == '{"/":"default"}'
# Deleting the deployment drops the replicas on all nodes. The proxies and
# controller stay alive (the worker proxy drains), so the count is the
# pre-delete total minus the 2 replicas.
serve.delete(name=SERVE_DEFAULT_APP_NAME)
expected_num_actors_after_delete = (
sum(expected_proxy_actors(num_proxy_nodes=2).values()) + 1
)
wait_for_condition(
lambda: len(
list_actors(address=cluster.address, filters=[("STATE", "=", "ALIVE")])
)
== expected_num_actors_after_delete,
)
# Ensure head node `/-/healthz` and `/-/routes` continue to
# return 200 and expected responses. Also, the worker node
# `/-/healthz` and `/-/routes` should return 503 and unavailable
# responses.
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8000/-/healthz",
expected_code=200,
expected_text="success",
)
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8000/-/routes",
expected_code=200,
expected_text="{}",
)
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8001/-/healthz",
expected_code=503,
expected_text="This node is being drained.",
)
wait_for_condition(
condition_predictor=check_request,
url="http://127.0.0.1:8001/-/routes",
expected_code=503,
expected_text="This node is being drained.",
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,54 @@
import pytest
import ray
from ray._raylet import GcsClient
from ray.serve._private.default_impl import create_cluster_node_info_cache
from ray.serve._private.test_utils import get_node_id
from ray.tests.conftest import * # noqa
def test_get_alive_nodes(ray_start_cluster):
cluster = ray_start_cluster
cluster.add_node(resources={"head": 1})
ray.init(address=cluster.address)
worker_node = cluster.add_node(resources={"worker": 1})
cluster.wait_for_nodes()
head_node_id = ray.get(get_node_id.options(resources={"head": 1}).remote())
worker_node_id = ray.get(get_node_id.options(resources={"worker": 1}).remote())
gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address)
cluster_node_info_cache = create_cluster_node_info_cache(gcs_client)
cluster_node_info_cache.update()
assert set(cluster_node_info_cache.get_alive_nodes()) == {
(head_node_id, ray.nodes()[0]["NodeName"], ""),
(worker_node_id, ray.nodes()[0]["NodeName"], ""),
}
assert cluster_node_info_cache.get_alive_node_ids() == {
head_node_id,
worker_node_id,
}
assert (
cluster_node_info_cache.get_alive_node_ids()
== cluster_node_info_cache.get_active_node_ids()
)
cluster.remove_node(worker_node)
cluster.wait_for_nodes()
# The killed worker node shouldn't show up in the alive node list.
cluster_node_info_cache.update()
assert cluster_node_info_cache.get_alive_nodes() == [
(head_node_id, ray.nodes()[0]["NodeName"], "")
]
assert cluster_node_info_cache.get_alive_node_ids() == {head_node_id}
assert (
cluster_node_info_cache.get_alive_node_ids()
== cluster_node_info_cache.get_active_node_ids()
)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,17 @@
applications:
- name: untyped_default
route_prefix: /untyped_default
import_path: ray.serve.tests.test_config_files.arg_builders.build_echo_app
- name: untyped_hello
route_prefix: /untyped_hello
import_path: ray.serve.tests.test_config_files.arg_builders.build_echo_app
args:
message: hello
- name: typed_default
route_prefix: /typed_default
import_path: ray.serve.tests.test_config_files.arg_builders.build_echo_app_typed
- name: typed_hello
route_prefix: /typed_hello
import_path: ray.serve.tests.test_config_files.arg_builders.build_echo_app_typed
args:
message: hello
@@ -0,0 +1,25 @@
from pydantic import BaseModel
from ray import serve
class TypedArgs(BaseModel):
message: str = "DEFAULT"
@serve.deployment(ray_actor_options={"num_cpus": 0})
class Echo:
def __init__(self, message: str):
print("Echo message:", message)
self._message = message
def __call__(self, *args):
return self._message
def build_echo_app(args):
return Echo.bind(args.get("message", "DEFAULT"))
def build_echo_app_typed(args: TypedArgs):
return Echo.bind(args.message)
@@ -0,0 +1,20 @@
applications:
- name: default
import_path: "dir.subdir.a.add_and_sub.serve_dag"
runtime_env:
# Keep these pinned remote URIs in sync with tests/common/remote_uris.py.
working_dir: "https://github.com/ray-project/test_dag/archive/203140040e4ab50b9d35b4773ec5c22615c034b3.zip"
deployments:
- name: "Router"
graceful_shutdown_timeout_s: 0.0001
- name: "Add"
graceful_shutdown_timeout_s: 0.0001
- name: "Subtract"
graceful_shutdown_timeout_s: 0.0001
ray_actor_options:
runtime_env:
# Keep these pinned remote URIs in sync with tests/common/remote_uris.py.
py_modules:
- "https://github.com/ray-project/test_module/archive/aa6f366f7daa78c98408c27d917a983caa9f888b.zip"
@@ -0,0 +1,25 @@
applications:
- name: "app1"
route_prefix: "/app1"
import_path: ray.serve.tests.test_config_files.pizza.serve_dag
deployments:
- name: Multiplier
user_config:
factor: 1
- name: Adder
user_config:
increment: 1
- name: "app2"
# Route prefixes should be unique across all apps!
route_prefix: "/app1"
import_path: ray.serve.tests.test_config_files.pizza.serve_dag
deployments:
- name: Multiplier
user_config:
factor: 2
- name: Adder
user_config:
increment: 3
@@ -0,0 +1,4 @@
applications:
- name: default
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
runtime_env: {"working_dir": "s3://does_not_exist.zip"}
@@ -0,0 +1,4 @@
applications:
- name: default
route_prefix: /
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,7 @@
http_options:
host: 127.0.0.1
port: 8005
applications:
- name: default
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,3 @@
applications:
- name: "app1"
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,6 @@
http_options:
port: 8005
applications:
- name: "app1"
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,26 @@
import ray.cloudpickle as pickle
from ray import serve
class NonserializableException(Exception):
"""This exception cannot be serialized."""
def __reduce__(self):
raise RuntimeError("This exception cannot be serialized!")
# Confirm that NonserializableException cannot be serialized.
try:
pickle.dumps(NonserializableException())
except RuntimeError as e:
assert "This exception cannot be serialized!" in repr(e)
raise NonserializableException("custom exception info")
@serve.deployment
def f():
pass
app = f.bind()
@@ -0,0 +1,20 @@
"""Deployment that uses deployment actors but defines them only via config override.
The deployment has no deployment_actors in the @serve.deployment decorator;
they are added purely through the declarative config override.
"""
import ray
from ray import serve
@serve.deployment(ray_actor_options={"num_cpus": 0.1})
class ConfigOnlyDriver:
"""Uses get_deployment_actor; deployment_actors come from config only."""
def __call__(self):
counter = serve.get_deployment_actor("counter")
return str(ray.get(counter.get.remote()))
app = ConfigOnlyDriver.bind()
@@ -0,0 +1,13 @@
import asyncio
from ray import serve
@serve.deployment
class A:
async def __del__(self):
while True:
await asyncio.sleep(0.1)
app = A.bind()
@@ -0,0 +1,8 @@
grpc_options:
port: 9000
grpc_servicer_functions:
- ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server
applications:
- name: app1
import_path: ray.serve.tests.test_config_files.grpc_deployment:g
@@ -0,0 +1,8 @@
grpc_options:
port: 9000
grpc_servicer_functions:
- ray.serve.generated.serve_pb2_grpc.add_FruitServiceServicer_to_server
applications:
- name: app1
import_path: ray.serve.tests.test_config_files.grpc_deployment:g2
@@ -0,0 +1,8 @@
grpc_options:
port: 9000
grpc_servicer_functions:
- ray.serve.generated.serve_pb2_grpc.add_UserDefinedServiceServicer_to_server
applications:
- name: app1
import_path: ray.serve.tests.test_config_files.grpc_deployment:multiplexed_g
@@ -0,0 +1,11 @@
applications:
- name: default
route_prefix: /
import_path: ray.serve.tests.test_config_files.config_only_deployment_actor:app
deployments:
- name: ConfigOnlyDriver
deployment_actors:
- name: counter
actor_class: ray.serve.tests.test_deployment_actors:SharedCounter
init_kwargs:
start: 88
@@ -0,0 +1,3 @@
applications:
- name: default
import_path: ray.serve.tests.test_config_files.fail.node
@@ -0,0 +1,3 @@
applications:
- name: default
import_path: ray.serve.tests.test_config_files.fail_2.node
@@ -0,0 +1,4 @@
applications:
- name: default
route_prefix: /
import_path: ray.serve.tests.test_config_files.test_dag.hello_serve:model
@@ -0,0 +1,8 @@
applications:
- name: app1
route_prefix: /a
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
- name: app1
route_prefix: /b
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,8 @@
applications:
- name: app1
route_prefix: /alice
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
- name: app2
route_prefix: /alice
import_path: ray.serve.tests.test_config_files.test_dag.basic_dag.DagNode
@@ -0,0 +1,10 @@
from ray import serve
@serve.deployment
class A:
def __init__(self):
_ = 1 / 0
node = A.bind()
@@ -0,0 +1,13 @@
import time
from ray import serve
@serve.deployment
class A:
def __init__(self):
time.sleep(5)
_ = 1 / 0
node = A.bind()
@@ -0,0 +1,16 @@
from fastapi import FastAPI
from ray import serve
app = FastAPI(docs_url="/my_docs")
@serve.deployment
@serve.ingress(app)
class FastAPIDeployment:
@app.get("/hello")
def incr(self):
return "Hello world!"
node = FastAPIDeployment.bind()
@@ -0,0 +1,29 @@
"""Test fixture: raises on first FLAKY_BUILD_FAIL_COUNT imports, then succeeds.
A counter file persists state across the fresh worker processes that
``build_serve_application``'s ``max_calls=1`` decorator spawns on each retry.
"""
import os
from ray import serve
_COUNTER_FILE = os.environ["FLAKY_BUILD_COUNTER_FILE"]
_FAIL_COUNT = int(os.environ.get("FLAKY_BUILD_FAIL_COUNT", "3"))
with open(_COUNTER_FILE, "r") as f:
_attempts = int(f.read().strip() or "0")
with open(_COUNTER_FILE, "w") as f:
f.write(str(_attempts + 1))
if _attempts < _FAIL_COUNT:
raise RuntimeError(f"flaky build failure on attempt {_attempts + 1}")
@serve.deployment
class FlakyApp:
def __call__(self):
return "ok"
node = FlakyApp.bind()
@@ -0,0 +1,21 @@
"""Flexible driver that works with or without deployment actors.
Used by declarative redeployment tests that need to transition from
no-deployment-actors to having them.
"""
import ray
from ray import serve
@serve.deployment(ray_actor_options={"num_cpus": 0.1})
class FlexDriver:
def __call__(self):
try:
actor = serve.get_deployment_actor("counter")
return str(ray.get(actor.get.remote()))
except Exception:
return "no_actor"
app = FlexDriver.bind()
@@ -0,0 +1,15 @@
import json
import os
from ray import serve
@serve.deployment
class GangApp:
def __call__(self, *args):
ctx = serve.context._get_internal_replica_context()
gc = ctx.gang_context
return json.dumps({"pid": os.getpid(), "gang_id": gc.gang_id if gc else None})
app = GangApp.bind()
@@ -0,0 +1,12 @@
applications:
- name: gang_app
route_prefix: /
import_path: ray.serve.tests.test_config_files.gang_scheduling:app
deployments:
- name: GangApp
num_replicas: 4
ray_actor_options:
num_cpus: 0.25
gang_scheduling_config:
gang_size: 2
gang_placement_strategy: PACK
@@ -0,0 +1,12 @@
applications:
- name: gang_app
route_prefix: /
import_path: ray.serve.tests.test_config_files.gang_scheduling:app
deployments:
- name: GangApp
num_replicas: 2
ray_actor_options:
num_cpus: 0.25
gang_scheduling_config:
gang_size: 2
gang_placement_strategy: PACK
@@ -0,0 +1,12 @@
applications:
- name: gang_app
route_prefix: /
import_path: ray.serve.tests.test_config_files.gang_scheduling:app
deployments:
- name: GangApp
num_replicas: 4
ray_actor_options:
num_cpus: 0.25
gang_scheduling_config:
gang_size: 2
gang_placement_strategy: PACK
@@ -0,0 +1,29 @@
import os
import ray
from ray import serve
from ray.serve.handle import DeploymentHandle
@serve.deployment
class A:
def __init__(self, b: DeploymentHandle):
self.b = b
self.signal = ray.get_actor("signal_A", namespace="default_test_namespace")
async def __call__(self):
await self.signal.wait.remote()
return os.getpid()
@serve.deployment
class B:
def __init__(self):
self.signal = ray.get_actor("signal_B", namespace="default_test_namespace")
async def __call__(self):
await self.signal.wait.remote()
return os.getpid()
app = A.bind(B.bind())
@@ -0,0 +1,15 @@
import os
import ray
from ray import serve
@serve.deployment
class A:
async def __call__(self):
signal = ray.get_actor("signal123")
await signal.wait.remote()
return os.getpid()
app = A.bind()
@@ -0,0 +1,125 @@
from typing import Dict
# Users need to include their custom message type which will be embedded in the request.
from ray import serve
from ray.serve.generated import serve_pb2
from ray.serve.handle import DeploymentHandle
@serve.deployment
class GrpcDeployment:
def __call__(self, user_message):
greeting = f"Hello {user_message.name} from {user_message.foo}"
num_x2 = user_message.num * 2
user_response = serve_pb2.UserDefinedResponse(
greeting=greeting,
num_x2=num_x2,
)
return user_response
def Method1(self, user_message):
greeting = f"Hello {user_message.name} from method1"
num_x2 = user_message.num * 3
user_response = serve_pb2.UserDefinedResponse(
greeting=greeting,
num_x2=num_x2,
)
return user_response
def Streaming(self, user_message):
for i in range(10):
greeting = f"{i}: Hello {user_message.name} from {user_message.foo}"
num_x2 = user_message.num * 2 + i
user_response = serve_pb2.UserDefinedResponse(
greeting=greeting,
num_x2=num_x2,
)
yield user_response
g = GrpcDeployment.options(name="grpc-deployment").bind()
# NOTE: model multiplexing is kept on a separate deployment (not the shared `g`)
# because it is not supported on the ingress deployment when direct ingress /
# HAProxy is enabled (the multiplexed model ID is not propagated to the replica).
# Tests that exercise it must be skipped under those modes.
@serve.deployment
class MultiplexedGrpcDeployment:
def __call__(self, user_message):
greeting = f"Hello {user_message.name} from {user_message.foo}"
return serve_pb2.UserDefinedResponse(greeting=greeting)
@serve.multiplexed(max_num_models_per_replica=1)
async def get_model(self, model_id: str) -> str:
return f"loading model: {model_id}"
async def Method2(self, user_message):
model_id = serve.get_multiplexed_model_id()
model = await self.get_model(model_id)
user_response = serve_pb2.UserDefinedResponse(
greeting=f"Method2 called model, {model}",
)
return user_response
multiplexed_g = MultiplexedGrpcDeployment.options(name="grpc-deployment").bind()
@serve.deployment(ray_actor_options={"num_cpus": 0})
class FruitMarket:
def __init__(
self,
_orange_stand: DeploymentHandle,
_apple_stand: DeploymentHandle,
):
self.directory = {
"ORANGE": _orange_stand,
"APPLE": _apple_stand,
}
async def FruitStand(self, fruit_amounts_proto):
fruit_amounts = {}
if fruit_amounts_proto.orange:
fruit_amounts["ORANGE"] = fruit_amounts_proto.orange
if fruit_amounts_proto.apple:
fruit_amounts["APPLE"] = fruit_amounts_proto.apple
if fruit_amounts_proto.banana:
fruit_amounts["BANANA"] = fruit_amounts_proto.banana
costs = await self.check_price(fruit_amounts)
return serve_pb2.FruitCosts(costs=costs)
async def check_price(self, inputs: Dict[str, int]) -> float:
costs = 0
for fruit, amount in inputs.items():
if fruit not in self.directory:
return
fruit_stand = self.directory[fruit]
costs += await fruit_stand.remote(int(amount))
return costs
@serve.deployment(ray_actor_options={"num_cpus": 0})
class OrangeStand:
def __init__(self):
self.price = 2.0
def __call__(self, num_oranges: int):
return num_oranges * self.price
@serve.deployment(ray_actor_options={"num_cpus": 0})
class AppleStand:
def __init__(self):
self.price = 3.0
def __call__(self, num_oranges: int):
return num_oranges * self.price
orange_stand = OrangeStand.bind()
apple_stand = AppleStand.bind()
g2 = FruitMarket.options(name="grpc-deployment-model-composition").bind(
orange_stand, apple_stand
)
@@ -0,0 +1,13 @@
import ray
from ray import serve
signal = ray.get_actor("signal123")
ray.get(signal.wait.remote())
@serve.deployment(ray_actor_options={"num_cpus": 0.1})
def f():
return "hello world"
app = f.bind()
@@ -0,0 +1,14 @@
import time
from ray import serve
@serve.deployment
def sleep(request):
sleep_s = float(request.query_params.get("sleep_s", 0))
print(f"sleep_s: {sleep_s}")
time.sleep(sleep_s)
return "Task Succeeded!"
sleep_node = sleep.bind()
@@ -0,0 +1,7 @@
http_options:
request_timeout_s: 0.1
applications:
- name: "app1"
import_path: ray.serve.tests.test_config_files.http_option_app_sleeps.sleep_node
route_prefix: /app1
@@ -0,0 +1,11 @@
from ray import serve
_ = 1 / 0
@serve.deployment(ray_actor_options={"num_cpus": 0.1})
def f(*args):
return "hello world"
app = f.bind()
@@ -0,0 +1,62 @@
import logging
import ray
from ray import serve
from ray.exceptions import RayActorError
from ray.serve.context import _get_global_client
logger = logging.getLogger("ray.serve")
@serve.deployment
class Model:
def __call__(self):
logger.debug("this_is_debug_info")
logger.info("this_is_access_log", extra={"serve_access_log": True})
log_file = logger.handlers[1].target.baseFilename
return {
"log_file": log_file,
"replica": serve.get_replica_context().replica_id.to_full_id_str(),
"log_level": logger.level,
"num_handlers": len(logger.handlers),
}
@serve.deployment
class Router:
def __init__(self, handle):
self.handle = handle
async def __call__(self):
logger.debug("this_is_debug_info_from_router")
log_info = await self.handle.remote()
if len(logger.handlers) == 2:
log_info["router_log_file"] = logger.handlers[1].target.baseFilename
else:
log_info["router_log_file"] = None
log_info["router_log_level"] = logger.level
try:
# Add controller log file path
client = _get_global_client()
_, log_file_path = ray.get(client._controller._get_logging_config.remote())
except RayActorError:
log_file_path = None
log_info["controller_log_file"] = log_file_path
return log_info
model = Router.bind(Model.bind())
@serve.deployment(logging_config={"log_level": "DEBUG"})
class ModelWithConfig:
def __call__(self):
logger.debug("this_is_debug_info")
log_file = logger.handlers[1].target.baseFilename
return {"log_file": log_file}
model2 = ModelWithConfig.bind()
@@ -0,0 +1,10 @@
from ray import serve
@serve.deployment
class D:
def __call__(self, *args):
return "hi"
app = D.bind()
@@ -0,0 +1,15 @@
applications:
- name: valid
route_prefix: /valid
import_path: ray.serve.tests.test_config_files.max_replicas_per_node.app
deployments:
- name: D
max_replicas_per_node: 2
- name: invalid
route_prefix: /invalid
import_path: ray.serve.tests.test_config_files.max_replicas_per_node.app
deployments:
- name: D
# Non-positive max_replicas_per_node.
max_replicas_per_node: 0
@@ -0,0 +1,4 @@
applications:
- name: default
import_path: "basic_dag.DagNode"
route_prefix: /
@@ -0,0 +1,28 @@
from fastapi import FastAPI
from ray import serve
from ray.serve.handle import DeploymentHandle
app1 = FastAPI()
app2 = FastAPI()
@serve.deployment
@serve.ingress(app2)
class SubModel:
def add(self, a: int):
return a + 1
@serve.deployment
@serve.ingress(app1)
class Model:
def __init__(self, submodel: DeploymentHandle):
self.submodel = submodel
@app1.get("/{a}")
async def func(self, a: int):
return await self.submodel.add.remote(a)
invalid_model = Model.bind(SubModel.bind())
@@ -0,0 +1,78 @@
import asyncio
import os
import time
from ray import serve
@serve.deployment
class f:
def __init__(self, async_wait: bool = False):
# whether to use wait() (busy spin) or async_wait() (busy spin
# while yielding event loop)
self._async = async_wait
# to be updated through reconfigure()
self.name = "default_name"
# used to block calls to __call__()
self.ready = True
# used to check how many times __call__() has been called
self.counter = 0
# used to block calls to health_check()
self.health_check_ready = True
# used to check how many times health_check() has been called
self.health_check_counter = 0
async def get_counter(self, health_check=False) -> int:
if health_check:
return self.health_check_counter
else:
return self.counter
def send(self, clear=False, health_check=False):
if health_check:
self.health_check_ready = not clear
else:
self.ready = not clear
def wait(self, _health_check=False):
if _health_check:
while not self.health_check_ready:
time.sleep(0.1)
else:
while not self.ready:
time.sleep(0.1)
async def async_wait(self, _health_check=False):
if _health_check:
while not self.health_check_ready:
await asyncio.sleep(0.1)
else:
while not self.ready:
await asyncio.sleep(0.1)
def reconfigure(self, config: dict):
self.name = config.get("name", "default_name")
async def __call__(self):
self.counter += 1
if self._async:
await self.async_wait()
else:
self.wait()
return os.getpid(), self.name
async def check_health(self):
self.health_check_counter += 1
if self._async:
await self.async_wait(_health_check=True)
else:
self.wait(_health_check=True)
node = f.bind()
dup_node = f.bind()
async_node = f.bind(async_wait=True)
@@ -0,0 +1,79 @@
from enum import Enum
from typing import Dict, List
import starlette.requests
from ray import serve
from ray.serve.handle import DeploymentHandle
class Operation(str, Enum):
ADDITION = "ADD"
MULTIPLICATION = "MUL"
@serve.deployment(ray_actor_options={"num_cpus": 0.15})
class Router:
def __init__(self, multiplier: DeploymentHandle, adder: DeploymentHandle):
self.adder = adder
self.multiplier = multiplier
async def route(self, op: Operation, input: int) -> str:
if op == Operation.ADDITION:
amount = await self.adder.add.remote(input)
elif op == Operation.MULTIPLICATION:
amount = await self.multiplier.multiply.remote(input)
return f"{amount} pizzas please!"
async def __call__(self, request: starlette.requests.Request) -> str:
op, input = await request.json()
return await self.route(op, input)
@serve.deployment(
user_config={
"factor": 3,
},
ray_actor_options={"num_cpus": 0.15},
)
class Multiplier:
def __init__(self, factor: int):
self.factor = factor
def reconfigure(self, config: Dict):
self.factor = config.get("factor", -1)
def multiply(self, input_factor: int) -> int:
return input_factor * self.factor
@serve.deployment(
user_config={
"increment": 2,
},
ray_actor_options={"num_cpus": 0.15},
)
class Adder:
def __init__(self, increment: int):
self.increment = increment
def reconfigure(self, config: Dict):
self.increment = config.get("increment", -1)
def add(self, input: int) -> int:
return input + self.increment
async def json_resolver(request: starlette.requests.Request) -> List:
return await request.json()
# Overwritten by user_config
ORIGINAL_INCREMENT = 1
ORIGINAL_FACTOR = 1
multiplier = Multiplier.bind(ORIGINAL_FACTOR)
adder = Adder.bind(ORIGINAL_INCREMENT)
serve_dag = Router.bind(multiplier, adder)
@@ -0,0 +1,20 @@
applications:
- name: default
route_prefix: /
import_path: ray.serve.tests.test_config_files.test_dag.conditional_dag.serve_dag
deployments:
- name: Router
graceful_shutdown_timeout_s: 0.0001
- name: Multiplier
graceful_shutdown_timeout_s: 0.0001
user_config:
factor: 1
- name: Adder
graceful_shutdown_timeout_s: 0.0001
ray_actor_options:
runtime_env:
env_vars:
override_increment: '1'
@@ -0,0 +1,16 @@
applications:
- name: "app1"
route_prefix: "/app1"
import_path: ray.serve.tests.test_config_files.world.DagNode
- name: "app2"
route_prefix: "/app2"
import_path: ray.serve.tests.test_config_files.pizza.serve_dag
deployments:
- name: Multiplier
user_config:
factor: 10
- name: Adder
user_config:
increment: 10
@@ -0,0 +1,12 @@
import ray
from ray import serve
ray.init(address="auto")
@serve.deployment
def f():
return "foobar"
app = f.bind()
@@ -0,0 +1,10 @@
from ray import serve
@serve.deployment
class D:
def __call__(self, *args):
return "hi"
app = D.bind()
@@ -0,0 +1,27 @@
applications:
- name: valid
route_prefix: /valid
import_path: ray.serve.tests.test_config_files.replica_placement_groups.app
deployments:
- name: D
placement_group_bundles:
- {"CPU": 1}
placement_group_strategy: STRICT_PACK
- name: invalid_bundles
route_prefix: /invalid_bundles
import_path: ray.serve.tests.test_config_files.replica_placement_groups.app
deployments:
- name: D
# Insufficient resources for the replica actor.
placement_group_bundles:
- {"CPU": 0.1}
- name: invalid_strategy
route_prefix: /invalid_strategy
import_path: ray.serve.tests.test_config_files.replica_placement_groups.app
deployments:
- name: D
placement_group_bundles:
- {"CPU": 1}
placement_group_strategy: FAKE_NEWS
@@ -0,0 +1,15 @@
from ray import serve
@serve.deployment
class TestDeployment:
def __init__(self):
import pymysql
from sqlalchemy import create_engine
pymysql.install_as_MySQLdb()
create_engine("mysql://some_wrong_url:3306").connect()
app = TestDeployment.bind()
@@ -0,0 +1,11 @@
applications:
- name: default
import_path: ray.serve.tests.test_config_files.sqlalchemy.app
deployments:
- name: TestDeployment
num_replicas: 1
ray_actor_options:
runtime_env:
pip:
- PyMySQL
- sqlalchemy==1.3.19
@@ -0,0 +1 @@
x = (1 + 2
@@ -0,0 +1,3 @@
applications:
- name: default
import_path: ray.serve.tests.test_config_files.syntax_error.app
@@ -0,0 +1,28 @@
from ray import serve
from ray.serve.handle import DeploymentHandle
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
def f(*args):
return "wonderful world"
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
class BasicDriver:
def __init__(self, h: DeploymentHandle):
self._h = h
async def __call__(self):
return await self._h.remote()
FNode = f.bind()
DagNode = BasicDriver.bind(FNode)
@@ -0,0 +1,97 @@
import os
from enum import Enum
from typing import Dict
import starlette.requests
from ray import serve
from ray.serve.handle import DeploymentHandle
class Operation(str, Enum):
ADDITION = "ADD"
MULTIPLICATION = "MUL"
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
class Router:
def __init__(self, multiplier: DeploymentHandle, adder: DeploymentHandle):
self.adder = adder
self.multiplier = multiplier
async def route(self, op: Operation, input: int) -> int:
if op == Operation.ADDITION:
amount = await self.adder.add.remote(input)
elif op == Operation.MULTIPLICATION:
amount = await self.multiplier.multiply.remote(input)
return f"{amount} pizzas please!"
async def __call__(self, request: starlette.requests.Request) -> str:
op, input = await request.json()
return await self.route(op, input)
@serve.deployment(
user_config={
"factor": 3,
},
ray_actor_options={
"num_cpus": 0.1,
"runtime_env": {
"env_vars": {
"override_factor": "-2",
}
},
},
)
class Multiplier:
def __init__(self, factor: int):
self.factor = factor
def reconfigure(self, config: Dict):
self.factor = config.get("factor", -1)
def multiply(self, input_factor: int) -> int:
if os.getenv("override_factor") is not None:
return input_factor * int(os.getenv("override_factor"))
return input_factor * self.factor
@serve.deployment(
user_config={
"increment": 2,
},
ray_actor_options={
"num_cpus": 0.1,
"runtime_env": {
"env_vars": {
"override_increment": "-2",
}
},
},
)
class Adder:
def __init__(self, increment: int):
self.increment = increment
def reconfigure(self, config: Dict):
self.increment = config.get("increment", -1)
def add(self, input: int) -> int:
if os.getenv("override_increment") is not None:
return input + int(os.getenv("override_increment"))
return input + self.increment
# Overwritten by user_config
ORIGINAL_INCREMENT = 1
ORIGINAL_FACTOR = 1
multiplier = Multiplier.bind(ORIGINAL_FACTOR)
adder = Adder.bind(ORIGINAL_INCREMENT)
serve_dag = Router.bind(multiplier, adder)
@@ -0,0 +1,65 @@
from enum import Enum
import starlette.requests
from ray import serve
from ray.serve.handle import DeploymentHandle
class Operation(str, Enum):
ADD = "ADD"
SUBTRACT = "SUB"
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
class Add:
# Requires the test_dag repo as a py_module:
# https://github.com/ray-project/test_dag
def add(self, input: int) -> int:
from dir2.library import add_one
return add_one(input)
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
class Subtract:
# Requires the test_module repo as a py_module:
# https://github.com/ray-project/test_module
def subtract(self, input: int) -> int:
from test_module.test import one
return input - one() # Returns input - 2
@serve.deployment(
ray_actor_options={
"num_cpus": 0.1,
}
)
class Router:
def __init__(self, adder: DeploymentHandle, subtractor: DeploymentHandle):
self.adder = adder
self.subtractor = subtractor
async def __call__(self, request: starlette.requests.Request) -> int:
op, input = await request.json()
if op == Operation.ADD:
return await self.adder.add.remote(input)
elif op == Operation.SUBTRACT:
return await self.subtractor.subtract.remote(input)
adder = Add.bind()
subtractor = Subtract.bind()
serve_dag = Router.bind(adder, subtractor)
@@ -0,0 +1,2 @@
def add_one(inp):
return inp + 1
@@ -0,0 +1,13 @@
from starlette.requests import Request
from ray import serve
from ray.serve.tests.test_config_files.test_dag.utils.test import hello
@serve.deployment
class HelloModel:
async def __call__(self, starlette_request: Request) -> None:
return hello()
model = HelloModel.bind()
@@ -0,0 +1,2 @@
def hello():
return "hello_from_utils"
@@ -0,0 +1,24 @@
applications:
- name: "app1"
route_prefix: "/app1"
import_path: ray.serve.tests.test_config_files.pizza.serve_dag
deployments:
- name: Multiplier
user_config:
factor: 1
- name: Adder
user_config:
increment: 1
- name: "app2"
route_prefix: "/app2"
import_path: ray.serve.tests.test_config_files.pizza.serve_dag
deployments:
- name: Multiplier
user_config:
factor: 2
- name: Adder
user_config:
increment: 3
@@ -0,0 +1,9 @@
from ray import serve
@serve.deployment
def f():
return "hi"
app = f.bind()
@@ -0,0 +1,6 @@
applications:
- name: app1
route_prefix: /
import_path: use_current_working_directory:app
deployments:
- name: f
@@ -0,0 +1,16 @@
applications:
- name: app1
route_prefix: /
import_path: ray.serve.tests.test_config_files.use_custom_autoscaling_policy:app
deployments:
- name: CustomAutoscalingPolicy
num_replicas: auto
ray_actor_options:
num_cpus: 0.0
autoscaling_config:
min_replicas: 1
max_replicas: 2
upscale_delay_s: 1
downscale_delay_s: 2
policy:
policy_function: ray.serve.tests.test_config_files.use_custom_autoscaling_policy.custom_autoscaling_policy
@@ -0,0 +1,16 @@
from ray import serve
from ray.serve.config import AutoscalingContext
def custom_autoscaling_policy(ctx: AutoscalingContext):
print("custom_autoscaling_policy")
return 2, {}
@serve.deployment
class CustomAutoscalingPolicy:
def __call__(self):
return "hello_from_custom_autoscaling_policy"
app = CustomAutoscalingPolicy.bind()
@@ -0,0 +1,47 @@
import random
from typing import (
List,
Optional,
)
from ray import serve
from ray.serve.context import _get_internal_replica_context
from ray.serve.request_router import (
PendingRequest,
ReplicaID,
ReplicaResult,
RequestRouter,
RunningReplica,
)
class UniformRequestRouter(RequestRouter):
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> List[List[RunningReplica]]:
print("UniformRequestRouter routing request")
index = random.randint(0, len(candidate_replicas) - 1)
return [[candidate_replicas[index]]]
def on_request_routed(
self,
pending_request: PendingRequest,
replica_id: ReplicaID,
result: ReplicaResult,
):
print("on_request_routed callback is called!!")
@serve.deployment
class UniformRequestRouterApp:
def __init__(self):
context = _get_internal_replica_context()
self.replica_id: ReplicaID = context.replica_id
async def __call__(self):
return "hello_from_custom_request_router"
app = UniformRequestRouterApp.bind()

Some files were not shown because too many files have changed in this diff Show More