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
+115
View File
@@ -0,0 +1,115 @@
load("@rules_python//python:defs.bzl", "py_library")
load("//bazel:python.bzl", "py_test_module_list", "py_test_module_list_with_env_variants", "py_test_run_all_subdirectory")
py_library(
name = "conftest",
srcs = ["conftest.py"],
)
py_test_run_all_subdirectory(
size = "small",
include = glob(["test_*.py"]),
exclude = [
"test_haproxy_binary.py",
"test_round_robin_router.py",
],
extra_srcs = [],
tags = ["team:serve"],
deps = [
":conftest",
"//python/ray/serve:serve_lib",
"//python/ray/serve/tests:common",
],
)
py_test_module_list(
size = "small",
files = ["test_round_robin_router.py"],
tags = ["team:serve"],
deps = [
":conftest",
"//python/ray/serve:serve_lib",
"//python/ray/serve/tests:common",
],
)
py_test_module_list(
size = "small",
files = ["test_haproxy_binary.py"],
tags = [
"no_windows",
"team:serve",
],
deps = [
":conftest",
"//python/ray/serve:serve_lib",
"//python/ray/serve/tests:common",
],
)
py_test_module_list(
size = "small",
timeout = "short",
env = {
"RAY_SERVE_USE_PACK_SCHEDULING_STRATEGY": "1",
"RAY_SERVE_FAIL_ON_RANK_ERROR": "1",
},
files = [
"test_deployment_scheduler.py",
"test_deployment_state.py",
],
name_suffix = "_with_pack_scheduling",
tags = [
"no_windows",
"team:serve",
],
deps = [
":conftest",
"//python/ray/serve:serve_lib",
"//python/ray/serve/tests:common",
],
)
py_test_module_list_with_env_variants(
size = "small",
env_variants = {
"metr_disab": {
"env": {
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "0",
"RAY_SERVE_FAIL_ON_RANK_ERROR": "1",
},
"name_suffix": "_metr_disab",
},
"metr_agg_at_controller": {
"env": {
"RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER": "1",
"RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE": "1",
"RAY_SERVE_FAIL_ON_RANK_ERROR": "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",
"RAY_SERVE_FAIL_ON_RANK_ERROR": "1",
},
"name_suffix": "_metr_agg_at_controller_and_replicas",
},
},
files = [
"test_autoscaling_policy.py",
"test_controller.py",
"test_deployment_state.py",
"test_router.py",
],
tags = [
"no_windows",
"team:serve",
],
deps = [
":conftest",
"//python/ray/serve:serve_lib",
"//python/ray/serve/tests:common",
],
)
+1
View File
@@ -0,0 +1 @@
This directory should only contain unit tests that do not depend on running a Ray instance.
+94
View File
@@ -0,0 +1,94 @@
from typing import Tuple
from unittest.mock import Mock, patch
import pytest
import ray
from ray._raylet import NodeID
from ray.serve._private.autoscaling_state import AutoscalingStateManager
from ray.serve._private.deployment_state import DeploymentStateManager
from ray.serve._private.test_utils import (
MockClusterNodeInfoCache,
MockDeploymentActorWrapper,
MockKVStore,
MockReplicaActorWrapper,
MockTimer,
dead_replicas_context,
replica_rank_context,
uninitialized_replicas_context,
)
@pytest.fixture(autouse=True)
def disallow_ray_init(monkeypatch):
def raise_on_init():
raise RuntimeError("Unit tests should not depend on Ray being initialized.")
monkeypatch.setattr(ray, "init", raise_on_init)
# Unit tests don't run on a Ray cluster, so stub the runtime context
# that callers consult for worker/actor ids.
monkeypatch.setattr(ray, "get_runtime_context", Mock())
@pytest.fixture
def mock_deployment_state_manager(
request,
) -> Tuple[DeploymentStateManager, MockTimer, Mock, Mock]:
"""Fully mocked deployment state manager.
i.e kv store and gcs client is mocked so we don't need to initialize
ray. Also, since this is used for some recovery tests, this yields a
method for creating a new mocked deployment state manager.
"""
timer = MockTimer()
with patch(
"ray.serve._private.deployment_state.ActorReplicaWrapper",
new=MockReplicaActorWrapper,
), patch(
"ray.serve._private.deployment_state.DeploymentActorWrapper",
new=MockDeploymentActorWrapper,
), patch(
"time.time", new=timer.time
), patch(
"ray.serve._private.long_poll.LongPollHost"
) as mock_long_poll, patch(
"ray.get_runtime_context"
):
kv_store = MockKVStore()
cluster_node_info_cache = MockClusterNodeInfoCache()
cluster_node_info_cache.add_node(NodeID.from_random().hex())
autoscaling_state_manager = AutoscalingStateManager()
def create_deployment_state_manager(
actor_names=None,
placement_group_names=None,
create_placement_group_fn_override=None,
):
if actor_names is None:
actor_names = []
if placement_group_names is None:
placement_group_names = []
return DeploymentStateManager(
kv_store,
mock_long_poll,
actor_names,
placement_group_names,
cluster_node_info_cache,
autoscaling_state_manager,
head_node_id_override="fake-head-node-id",
create_placement_group_fn_override=create_placement_group_fn_override,
)
yield (
create_deployment_state_manager,
timer,
cluster_node_info_cache,
autoscaling_state_manager,
)
dead_replicas_context.clear()
replica_rank_context.clear()
uninitialized_replicas_context.clear()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,959 @@
import asyncio
import logging
import time
from typing import List
import pytest
import ray
from ray import serve
from ray._common.utils import get_or_create_event_loop
from ray.serve._private.common import DeploymentID, ReplicaID
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.constants import SERVE_LOGGER_NAME
from ray.serve.batching import _BatchQueue
from ray.serve.exceptions import RayServeException
# Setup the global replica context for the test.
default_deployment_config = DeploymentConfig()
ray.serve.context._set_internal_replica_context(
replica_id=ReplicaID(unique_id="test", deployment_id=DeploymentID(name="test")),
servable_object=None,
_deployment_config=default_deployment_config,
rank=0,
world_size=1,
)
class FakeStream:
def __init__(self):
self.messages = []
def write(self, buf):
self.messages.append(buf)
def reset_message(self):
self.messages = []
# We use a single event loop for the entire test session. Without this
# fixture, the event loop is sometimes prematurely terminated by pytest.
@pytest.fixture(scope="session")
def event_loop():
loop = get_or_create_event_loop()
yield loop
loop.close()
@pytest.mark.asyncio
async def test_decorator_validation():
@serve.batch
async def function():
pass
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.5)
async def function2():
pass
class Class:
@serve.batch
async def method(self):
pass
class Class2:
@serve.batch(max_batch_size=10, batch_wait_timeout_s=1.5)
async def method(self):
pass
with pytest.raises(TypeError, match="async def"):
@serve.batch
def non_async_function():
pass
with pytest.raises(TypeError, match="async def"):
class NotAsync:
@serve.batch
def method(self, requests):
pass
with pytest.raises(ValueError):
class ZeroBatch:
@serve.batch(max_batch_size=0)
async def method(self, requests):
pass
with pytest.raises(TypeError):
class FloatNonIntBatch:
@serve.batch(max_batch_size=1.1)
async def method(self, requests):
pass
class FloatIntegerBatch:
@serve.batch(max_batch_size=1.0)
async def method(self, requests):
pass
with pytest.raises(ValueError):
class NegativeTimeout:
@serve.batch(batch_wait_timeout_s=-0.1)
async def method(self, requests):
pass
class FloatZeroTimeout:
@serve.batch(batch_wait_timeout_s=0.0)
async def method(self, requests):
pass
class IntZeroTimeout:
@serve.batch(batch_wait_timeout_s=0)
async def method(self, requests):
pass
with pytest.raises(TypeError):
class NonTimeout:
@serve.batch(batch_wait_timeout_s="a")
async def method(self, requests):
pass
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
async def test_batch_size_one_long_timeout(use_class):
@serve.batch(max_batch_size=1, batch_wait_timeout_s=1000)
async def long_timeout(requests):
if "raise" in requests:
_ = 1 / 0
return requests
class LongTimeout:
@serve.batch(max_batch_size=1, batch_wait_timeout_s=1000)
async def long_timeout(self, requests):
if "raise" in requests:
_ = 1 / 0
return requests
cls = LongTimeout()
async def call(arg):
if use_class:
return await cls.long_timeout(arg)
else:
return await long_timeout(arg)
assert await call("hi") == "hi"
with pytest.raises(ZeroDivisionError):
await call("raise")
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
async def test_batch_size_multiple_zero_timeout(use_class):
block_execution_event = asyncio.Event()
@serve.batch(max_batch_size=2, batch_wait_timeout_s=0)
async def zero_timeout(requests):
await block_execution_event.wait()
if "raise" in requests:
_ = 1 / 0
return requests
class ZeroTimeout:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=0)
async def zero_timeout(self, requests):
await block_execution_event.wait()
if "raise" in requests:
_ = 1 / 0
return requests
cls = ZeroTimeout()
async def call(arg):
if use_class:
return await cls.zero_timeout(arg)
else:
return await zero_timeout(arg)
block_execution_event.set()
assert await call("hi") == "hi"
with pytest.raises(ZeroDivisionError):
await call("raise")
block_execution_event.clear()
# Check that 2 requests will be executed together if available.
# The first should cause a size-one batch to be executed, then
# the next two should be executed together (signaled by both
# having the exception).
t1 = get_or_create_event_loop().create_task(call("hi1"))
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(t1), timeout=0.001)
t2 = get_or_create_event_loop().create_task(call("hi2"))
t3 = get_or_create_event_loop().create_task(call("raise"))
block_execution_event.set()
assert await t1 == "hi1"
with pytest.raises(ZeroDivisionError):
await t2
with pytest.raises(ZeroDivisionError):
await t3
@pytest.mark.asyncio
async def test_batch_timeout_empty_queue():
"""Check that Serve waits when creating batches.
Serve should wait a full batch_wait_timeout_s after receiving the first
request in the next batch before processing the batch.
"""
@serve.batch(max_batch_size=10, batch_wait_timeout_s=0.25)
async def no_op(requests):
return ["No-op"] * len(requests)
num_iterations = 2
for iteration in range(num_iterations):
tasks = [get_or_create_event_loop().create_task(no_op(None)) for _ in range(9)]
done, _ = await asyncio.wait(tasks, timeout=0.05)
# Due to the long timeout, none of the tasks should finish until a tenth
# request is submitted
assert len(done) == 0
tasks.append(get_or_create_event_loop().create_task(no_op(None)))
done, _ = await asyncio.wait(tasks, timeout=0.05)
# All the timeout tasks should be finished
assert set(tasks) == set(done)
assert all(t.result() == "No-op" for t in tasks)
if iteration < num_iterations - 1:
# Leave queue empty for batch_wait_timeout_s between batches
time.sleep(0.25)
@pytest.mark.asyncio
async def test_batch_wait_queue_exceeds_batch_size_race_condition():
"""Check that the wait queue can exceed the batch size.
This test was added to guard against a race condition documented in
https://github.com/ray-project/ray/pull/42705#discussion_r1466653910.
"""
@serve.batch(max_batch_size=2, batch_wait_timeout_s=10000)
async def no_op(requests):
return ["No-op"] * len(requests)
tasks = [get_or_create_event_loop().create_task(no_op(None)) for _ in range(10)]
# All the tasks should finish.
done, pending = await asyncio.wait(tasks, timeout=0.5)
assert len(done) == len(tasks)
assert len(pending) == 0
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
async def test_batch_size_multiple_long_timeout(use_class):
@serve.batch(max_batch_size=3, batch_wait_timeout_s=1000)
async def long_timeout(requests):
if "raise" in requests:
_ = 1 / 0
return requests
class LongTimeout:
@serve.batch(max_batch_size=3, batch_wait_timeout_s=1000)
async def long_timeout(self, requests):
if "raise" in requests:
_ = 1 / 0
return requests
cls = LongTimeout()
async def call(arg):
if use_class:
return await cls.long_timeout(arg)
else:
return await long_timeout(arg)
t1 = get_or_create_event_loop().create_task(call("hi1"))
t2 = get_or_create_event_loop().create_task(call("hi2"))
done, pending = await asyncio.wait([t1, t2], timeout=0.1)
assert len(done) == 0
t3 = get_or_create_event_loop().create_task(call("hi3"))
done, pending = await asyncio.wait([t1, t2, t3], timeout=100)
assert set(done) == {t1, t2, t3}
assert [t1.result(), t2.result(), t3.result()] == ["hi1", "hi2", "hi3"]
t1 = get_or_create_event_loop().create_task(call("hi1"))
t2 = get_or_create_event_loop().create_task(call("raise"))
done, pending = await asyncio.wait([t1, t2], timeout=0.1)
assert len(done) == 0
t3 = get_or_create_event_loop().create_task(call("hi3"))
done, pending = await asyncio.wait([t1, t2, t3], timeout=100)
assert set(done) == {t1, t2, t3}
assert all(isinstance(t.exception(), ZeroDivisionError) for t in done)
with pytest.raises(ZeroDivisionError):
t1.result()
with pytest.raises(ZeroDivisionError):
t2.result()
with pytest.raises(ZeroDivisionError):
t3.result()
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["args", "kwargs", "mixed", "out-of-order"])
@pytest.mark.parametrize("use_class", [True, False])
async def test_batch_args_kwargs(mode, use_class):
if use_class:
class MultipleArgs:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def method(self, key1, key2):
return [(key1[i], key2[i]) for i in range(len(key1))]
instance = MultipleArgs()
func = instance.method
else:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def func(key1, key2):
return [(key1[i], key2[i]) for i in range(len(key1))]
if mode == "args":
coros = [func("hi1", "hi2"), func("hi3", "hi4")]
elif mode == "kwargs":
coros = [func(key1="hi1", key2="hi2"), func(key1="hi3", key2="hi4")]
elif mode == "mixed":
coros = [func("hi1", key2="hi2"), func("hi3", key2="hi4")]
elif mode == "out-of-order":
coros = [func(key2="hi2", key1="hi1"), func(key2="hi4", key1="hi3")]
result = await asyncio.gather(*coros)
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
@pytest.mark.parametrize("use_gen", [True, False])
async def test_batch_cancellation(use_class, use_gen):
block_requests = asyncio.Event()
request_was_cancelled = True
async def unary_implementation(key1, key2):
nonlocal block_requests, request_was_cancelled
await block_requests.wait()
request_was_cancelled = False
return [(key1[i], key2[i]) for i in range(len(key1))]
async def streaming_implementation(key1, key2):
nonlocal block_requests, request_was_cancelled
await block_requests.wait()
request_was_cancelled = False
yield [(key1[i], key2[i]) for i in range(len(key1))]
if use_class:
class MultipleArgs:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def unary_method(self, key1, key2):
return await unary_implementation(key1, key2)
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def streaming_method(self, key1, key2):
async for value in streaming_implementation(key1, key2):
yield value
instance = MultipleArgs()
if use_gen:
func = instance.streaming_method
else:
func = instance.unary_method
else:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def unary_func(key1, key2):
return await unary_implementation(key1, key2)
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def streaming_func(key1, key2):
async for value in streaming_implementation(key1, key2):
yield value
if use_gen:
func = streaming_func
else:
func = unary_func
if use_gen:
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
else:
tasks = [
asyncio.create_task(func("hi1", "hi2")),
asyncio.create_task(func("hi3", "hi4")),
]
print("Submitted requests.")
# The requests should be blocked on the long request_timeout
done, pending = await asyncio.wait(tasks, timeout=0.01)
assert len(done) == 0
assert len(pending) == 2
print("Requests are blocked, as expected.")
# Cancel the first request. The second request should still be blocked on
# the long request_timeout
tasks[0].cancel()
pending, done = await asyncio.wait(tasks, timeout=0.01)
assert len(done) == 1
assert len(pending) == 1
print("Cancelled first request.")
# Cancel the second request. Both requests should be done.
tasks[1].cancel()
done, pending = await asyncio.wait(tasks, timeout=0.01)
assert len(done) == 2
assert len(pending) == 0
print("Cancelled second request. Sending new requests with no timeout.")
# Sanity check that the request was actually cancelled.
assert request_was_cancelled
# Unblock requests. The requests should succeed.
block_requests.set()
if use_gen:
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
else:
tasks = [
asyncio.create_task(func("hi1", "hi2")),
asyncio.create_task(func("hi3", "hi4")),
]
result = await asyncio.gather(*tasks)
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
@pytest.mark.parametrize("use_gen", [True, False])
async def test_cancellation_after_error(use_class, use_gen):
"""Cancelling a request after it errors should be supported."""
raise_error = asyncio.Event()
async def unary_implementation(key1, key2):
if not raise_error.is_set():
raise ValueError()
return [(key1[i], key2[i]) for i in range(len(key1))]
async def streaming_implementation(key1, key2):
if not raise_error.is_set():
raise ValueError()
yield [(key1[i], key2[i]) for i in range(len(key1))]
if use_class:
class MultipleArgs:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def unary_method(self, key1, key2):
return await unary_implementation(key1, key2)
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def streaming_method(self, key1, key2):
async for value in streaming_implementation(key1, key2):
yield value
instance = MultipleArgs()
if use_gen:
func = instance.streaming_method
else:
func = instance.unary_method
else:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def unary_func(key1, key2):
return await unary_implementation(key1, key2)
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def streaming_func(key1, key2):
async for value in streaming_implementation(key1, key2):
yield value
if use_gen:
func = streaming_func
else:
func = unary_func
# Submit requests and then cancel them.
if use_gen:
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
else:
tasks = [
asyncio.create_task(func("hi1", "hi2")),
asyncio.create_task(func("hi3", "hi4")),
]
print("Submitted initial batch of requests.")
for task in tasks:
task.cancel()
print("Closed initial batch of requests.")
raise_error.set()
# Submit requests and check that they still work.
if use_gen:
gens = [func("hi1", "hi2"), func("hi3", "hi4")]
tasks = [asyncio.create_task(gen.__anext__()) for gen in gens]
else:
tasks = [
asyncio.create_task(func("hi1", "hi2")),
asyncio.create_task(func("hi3", "hi4")),
]
print("Submitted new batch of requests.")
result = await asyncio.gather(*tasks)
assert result == [("hi1", "hi2"), ("hi3", "hi4")]
@pytest.mark.asyncio
@pytest.mark.parametrize("use_class", [True, False])
async def test_batch_setters(use_class):
if use_class:
class C:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def method(self, key1, key2):
return [(key1[i], key2[i]) for i in range(len(key1))]
instance = C()
func = instance.method
else:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def func(key1, key2):
return [(key1[i], key2[i]) for i in range(len(key1))]
assert func._get_max_batch_size() == 2
assert func._get_batch_wait_timeout_s() == 1000
# @serve.batch should create batches of size 2
tasks = [
get_or_create_event_loop().create_task(func("hi1", "hi2")),
get_or_create_event_loop().create_task(func("hi3", "hi4")),
]
done, pending = await asyncio.wait(tasks, timeout=0.1)
assert len(pending) == 0
assert {task.result() for task in done} == {("hi1", "hi2"), ("hi3", "hi4")}
# Set new values
func.set_max_batch_size(3)
func.set_batch_wait_timeout_s(15000)
assert func._get_max_batch_size() == 3
assert func._get_batch_wait_timeout_s() == 15000
# @serve.batch should create batches of size 3
tasks = [
get_or_create_event_loop().create_task(func("hi1", "hi2")),
get_or_create_event_loop().create_task(func("hi3", "hi4")),
get_or_create_event_loop().create_task(func("hi5", "hi6")),
]
done, pending = await asyncio.wait(tasks, timeout=0.1)
assert len(pending) == 0
assert {task.result() for task in done} == {
("hi1", "hi2"),
("hi3", "hi4"),
("hi5", "hi6"),
}
@pytest.mark.asyncio
async def test_batch_use_earliest_setters():
"""@serve.batch should use the right settings when constructing a batch.
When the @serve.batch setters get called before a batch has started
accumulating, the next batch should use the setters' values. When they
get called while a batch is accumulating, the previous values should be
used.
"""
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def func(key1, key2):
return [(key1[i], key2[i]) for i in range(len(key1))]
assert func._get_max_batch_size() == 2
assert func._get_batch_wait_timeout_s() == 1000
# Set new values
func.set_max_batch_size(3)
func.set_batch_wait_timeout_s(15000)
assert func._get_max_batch_size() == 3
assert func._get_batch_wait_timeout_s() == 15000
# Should create batches of size 3, even if setters are called while
# batch is accumulated
tasks = [
get_or_create_event_loop().create_task(func("hi1", "hi2")),
get_or_create_event_loop().create_task(func("hi3", "hi4")),
]
# Batch should be waiting for last request
done, pending = await asyncio.wait(tasks, timeout=0.1)
assert len(done) == 0 and len(pending) == 2
func.set_max_batch_size(1)
func.set_batch_wait_timeout_s(0)
assert func._get_max_batch_size() == 1
assert func._get_batch_wait_timeout_s() == 0
# Batch should still be waiting for last request
done, pending = await asyncio.wait(pending, timeout=0.1)
assert len(done) == 0 and len(pending) == 2
# Batch should execute after last request
pending.add(get_or_create_event_loop().create_task(func("hi5", "hi6")))
done, pending = await asyncio.wait(pending, timeout=0.1)
assert len(pending) == 0
assert {task.result() for task in done} == {
("hi1", "hi2"),
("hi3", "hi4"),
("hi5", "hi6"),
}
# Next batch should use updated values
tasks = [get_or_create_event_loop().create_task(func("hi1", "hi2"))]
done, pending = await asyncio.wait(tasks, timeout=0.1)
assert len(done) == 1 and len(pending) == 0
assert done.pop().result() == ("hi1", "hi2")
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["args", "kwargs", "mixed", "out-of-order"])
@pytest.mark.parametrize("use_class", [True, False])
@pytest.mark.parametrize("generator_length", [0, 2, 5])
async def test_batch_generator_basic(mode, use_class, generator_length):
if use_class:
class MultipleArgs:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def method(self, key1, key2):
for gen_idx in range(generator_length):
yield [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
instance = MultipleArgs()
func = instance.method
else:
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def func(key1, key2):
for gen_idx in range(generator_length):
yield [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
if mode == "args":
generators = [func("hi1", "hi2"), func("hi3", "hi4")]
elif mode == "kwargs":
generators = [func(key1="hi1", key2="hi2"), func(key1="hi3", key2="hi4")]
elif mode == "mixed":
generators = [func("hi1", key2="hi2"), func("hi3", key2="hi4")]
elif mode == "out-of-order":
generators = [func(key2="hi2", key1="hi1"), func(key2="hi4", key1="hi3")]
results = [
[result async for result in generators[0]],
[result async for result in generators[1]],
]
assert results == [
[(gen_idx, "hi1", "hi2") for gen_idx in range(generator_length)],
[(gen_idx, "hi3", "hi4") for gen_idx in range(generator_length)],
]
@pytest.mark.asyncio
@pytest.mark.parametrize("error_type", ["runtime_error", "mismatched_lengths"])
async def test_batch_generator_exceptions(error_type):
GENERATOR_LENGTH = 5
ERROR_IDX = 2
ERROR_MSG = "Testing error"
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def func(key1, key2):
for gen_idx in range(GENERATOR_LENGTH):
results = [(gen_idx, key1[i], key2[i]) for i in range(len(key1))]
if gen_idx == ERROR_IDX:
if error_type == "runtime_error":
raise RuntimeError(ERROR_MSG)
elif error_type == "mismatched_lengths":
yield results * 2
yield results
generators = [func("hi1", "hi2"), func("hi3", "hi4")]
for generator in generators:
for _ in range(ERROR_IDX):
await generator.__anext__()
if error_type == "runtime_error":
with pytest.raises(RuntimeError, match=ERROR_MSG):
await generator.__anext__()
elif error_type == "mismatched_lengths":
with pytest.raises(RayServeException):
await generator.__anext__()
with pytest.raises(StopAsyncIteration):
await generator.__anext__()
@pytest.mark.asyncio
@pytest.mark.parametrize("stop_token", [StopAsyncIteration, StopIteration])
async def test_batch_generator_early_termination(stop_token):
NUM_CALLERS = 4
event = asyncio.Event()
@serve.batch(max_batch_size=NUM_CALLERS, batch_wait_timeout_s=1000)
async def sequential_terminator(ids: List[int]):
"""Terminates callers one-after-another in order of call."""
for num_finished_callers in range(1, NUM_CALLERS + 1):
event.clear()
responses = [stop_token for _ in range(num_finished_callers)]
responses += [ids[idx] for idx in range(num_finished_callers, NUM_CALLERS)]
yield responses
await event.wait()
ids = list(range(NUM_CALLERS))
generators = [sequential_terminator(id) for id in ids]
for id, generator in zip(ids, generators):
async for result in generator:
assert result == id
# Each terminated caller frees the sequential_terminator to process
# another iteration.
event.set()
@pytest.mark.asyncio
async def test_batch_generator_setters():
"""@serve.batch setters should succeed while the current batch streams."""
@serve.batch(max_batch_size=2, batch_wait_timeout_s=1000)
async def yield_three_times(key1, key2):
for _ in range(3):
yield [(key1[i], key2[i]) for i in range(len(key1))]
assert yield_three_times._get_max_batch_size() == 2
assert yield_three_times._get_batch_wait_timeout_s() == 1000
args_list = [("hi1", "hi2"), ("hi3", "hi4")]
coros = [yield_three_times(*args) for args in args_list]
# Partially consume generators
for coro, expected_result in zip(coros, args_list):
for _ in range(2):
await coro.__anext__() == expected_result
# Set new values
yield_three_times.set_max_batch_size(3)
yield_three_times.set_batch_wait_timeout_s(15000)
assert yield_three_times._get_max_batch_size() == 3
assert yield_three_times._get_batch_wait_timeout_s() == 15000
# Execute three more requests
args_list_2 = [("hi1", "hi2"), ("hi3", "hi4"), ("hi5", "hi6")]
coros_2 = [yield_three_times(*args) for args in args_list_2]
# Finish consuming original requests
for coro, expected_result in zip(coros, args_list):
await coro.__anext__() == expected_result
with pytest.raises(StopAsyncIteration):
await coro.__anext__()
# Consume new requests
for coro, expected_result in zip(coros_2, args_list_2):
for _ in range(3):
await coro.__anext__() == expected_result
with pytest.raises(StopAsyncIteration):
await coro.__anext__()
@pytest.mark.asyncio
async def test_batch_size_fn_deferred_item_early_break():
batches_processed = []
@serve.batch(
max_batch_size=10,
batch_wait_timeout_s=0.05,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def batch_handler(requests):
batches_processed.append([req["value"] for req in requests])
return [req["value"] for req in requests]
# Request 1: size=6 (fits in batch)
# Request 2: size=6 (would make total 12 > 10, should be deferred)
# Each should be processed in its own batch
t1 = get_or_create_event_loop().create_task(
batch_handler({"size": 6, "value": "first"})
)
t2 = get_or_create_event_loop().create_task(
batch_handler({"size": 6, "value": "second"})
)
done, pending = await asyncio.wait([t1, t2], timeout=1.0)
assert len(done) == 2, "Both tasks should complete"
assert len(pending) == 0
results = {t1.result(), t2.result()}
assert results == {"first", "second"}
# Verify they were processed in separate batches due to size constraint
assert (
len(batches_processed) == 2
), f"Expected 2 separate batches, got {batches_processed}"
@pytest.mark.asyncio
async def test_batch_size_fn_fail_to_fit():
batches_processed = []
@serve.batch(
max_batch_size=10,
batch_wait_timeout_s=0.05,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def batch_handler(requests):
batches_processed.append([req["value"] for req in requests])
return [req["value"] for req in requests]
# Request 1: size=6 (fits in batch)
# Request 2: size=6 (would make total 12 > 10, should be deferred)
# Each should be processed in its own batch
t1 = get_or_create_event_loop().create_task(
batch_handler({"size": 6, "value": "first"})
)
t2 = get_or_create_event_loop().create_task(
batch_handler({"size": 12, "value": "second"})
)
t1_result = await t1
assert t1_result == "first"
with pytest.raises(RuntimeError):
await t2
@pytest.mark.asyncio
async def test_batch_size_fn_multiple_items_fit():
"""Test that multiple items are batched together when they fit within max_batch_size."""
batches_seen = []
@serve.batch(
max_batch_size=20,
batch_wait_timeout_s=0.1,
batch_size_fn=lambda items: sum(item["size"] for item in items),
)
async def batch_handler(requests):
batch_values = [req["value"] for req in requests]
batches_seen.append(batch_values)
return batch_values
# All three requests fit: 5 + 6 + 7 = 18 <= 20
t1 = get_or_create_event_loop().create_task(
batch_handler({"size": 5, "value": "a"})
)
t2 = get_or_create_event_loop().create_task(
batch_handler({"size": 6, "value": "b"})
)
t3 = get_or_create_event_loop().create_task(
batch_handler({"size": 7, "value": "c"})
)
done, pending = await asyncio.wait([t1, t2, t3], timeout=1.0)
assert len(done) == 3
assert len(pending) == 0
# All three should be in the same batch since they fit
assert len(batches_seen) == 1, f"Expected 1 batch, got {len(batches_seen)}"
assert set(batches_seen[0]) == {"a", "b", "c"}
def test_warn_if_max_batch_size_exceeds_max_ongoing_requests():
"""Test warn_if_max_batch_size_exceeds_max_ongoing_requests() logged the warning
message correctly.
When the queue starts with or updated `max_batch_size` to be larger than
max_ongoing_requests, log the warning to suggest configuring `max_ongoing_requests`.
When the queue starts with or updated `max_batch_size` to be smaller or equal than
max_ongoing_requests, no warning should be logged.
"""
logger = logging.getLogger(SERVE_LOGGER_NAME)
stream = FakeStream()
stream_handler = logging.StreamHandler(stream)
logger.addHandler(stream_handler)
bound = default_deployment_config.max_ongoing_requests
over_bound = bound + 1
under_bound = bound - 1
over_bound_warning_message = (
f"`max_batch_size` ({over_bound}) * `max_concurrent_batches` "
f"({1}) is larger than `max_ongoing_requests` "
f"({bound}). This means the replica will never achieve "
"the configured `max_batch_size` concurrently. Please update "
"`max_ongoing_requests` to be >= `max_batch_size` * `max_concurrent_batches`.\n"
)
# Start queue above the bound will log warning. Start at under or at the bound will
# not log warning
for max_batch_size in [over_bound, under_bound, bound]:
queue = _BatchQueue(
max_batch_size=max_batch_size,
batch_wait_timeout_s=1000,
max_concurrent_batches=1,
)
if max_batch_size > bound:
assert over_bound_warning_message in stream.messages
else:
assert over_bound_warning_message not in stream.messages
stream.reset_message()
# Update queue above the bound will log warning. Update at under or at the bound
# will not log warning
for max_batch_size in [over_bound, under_bound, bound]:
queue.set_max_batch_size(max_batch_size)
if max_batch_size > bound:
assert over_bound_warning_message in stream.messages
else:
assert over_bound_warning_message not in stream.messages
stream.reset_message()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,857 @@
import sys
from typing import Any, Dict, List, Optional
import pytest
from fastapi import FastAPI
from ray import serve
from ray.serve._private.build_app import (
CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR,
BuiltApplication,
build_app,
)
from ray.serve._private.client import ServeControllerClient
from ray.serve._private.common import DeploymentID
from ray.serve.config import RequestRouterConfig
from ray.serve.deployment import Application, Deployment
from ray.serve.exceptions import RayServeException
from ray.serve.experimental.round_robin_router import RoundRobinRouter
from ray.serve.handle import DeploymentHandle
class FakeDeploymentHandle:
def __init__(self, deployment_name: str, app_name: str):
self.deployment_id = DeploymentID(deployment_name, app_name)
@classmethod
def from_deployment(cls, deployment, app_name: str) -> "FakeDeploymentHandle":
return cls(deployment.name, app_name)
def __eq__(self, other: Any) -> bool:
return (
isinstance(other, FakeDeploymentHandle)
and self.deployment_id == other.deployment_id
)
def _build_and_check(
app: Application,
*,
expected_ingress_name: str,
expected_deployments: List[Deployment],
app_name: str = "default",
default_runtime_env: Optional[Dict[str, Any]] = None,
):
built_app: BuiltApplication = build_app(
app,
name=app_name,
# Each real DeploymentHandle has a unique ID (intentionally), so the below
# equality checks don't work. Use a fake implementation instead.
make_deployment_handle=FakeDeploymentHandle.from_deployment,
default_runtime_env=default_runtime_env,
)
assert built_app.name == app_name
assert built_app.ingress_deployment_name == expected_ingress_name
assert len(built_app.deployments) == len(expected_deployments)
# Check that the returned deployment_handles are populated properly.
assert len(built_app.deployment_handles) == len(expected_deployments)
for d in expected_deployments:
h = built_app.deployment_handles.get(d.name, None)
assert h is not None, f"No handle returned for deployment {d.name}."
assert isinstance(h, FakeDeploymentHandle)
assert h.deployment_id == DeploymentID(d.name, app_name=app_name)
for expected_deployment in expected_deployments:
generated_deployment = None
for d in built_app.deployments:
if d.name == expected_deployment.name:
generated_deployment = d
assert generated_deployment is not None, (
f"Expected a deployment with name '{expected_deployment.name}' "
"to be generated but none was found. All generated names: "
+ str([d.name for d in built_app.deployments])
)
assert expected_deployment == generated_deployment
def test_real_deployment_handle_default():
"""Other tests inject a FakeDeploymentHandle, so check the default behavior."""
@serve.deployment
class D:
pass
built_app: BuiltApplication = build_app(
D.bind(D.options(name="Inner").bind()),
name="app-name",
)
assert len(built_app.deployments) == 2
assert len(built_app.deployments[1].init_args) == 1
assert isinstance(built_app.deployments[1].init_args[0], DeploymentHandle)
assert built_app.deployments[1].init_args[0].deployment_id == DeploymentID(
"Inner", app_name="app-name"
)
def test_single_deployment_basic():
@serve.deployment(
num_replicas=123,
max_ongoing_requests=10,
max_queued_requests=10,
)
class D:
pass
app = D.bind("hello world!", hello="world")
_build_and_check(
app,
expected_ingress_name="D",
expected_deployments=[
D.options(
name="D", _init_args=("hello world!",), _init_kwargs={"hello": "world"}
)
],
)
def test_single_deployment_custom_name():
@serve.deployment(
num_replicas=123,
max_ongoing_requests=10,
max_queued_requests=10,
)
class D:
pass
app = D.options(name="foobar123").bind("hello world!", hello="world")
_build_and_check(
app,
expected_ingress_name="foobar123",
expected_deployments=[
D.options(
name="foobar123",
_init_args=("hello world!",),
_init_kwargs={"hello": "world"},
)
],
)
# Change to `with pytest.raises(ValueError)` when the warning is removed.
with pytest.warns(UserWarning):
@serve.deployment(name="test#deployment")
def my_deployment():
return "Hello!"
with pytest.warns(UserWarning):
@serve.deployment()
def my_deployment():
return "Hello!"
my_deployment.options(name="test#deployment")
def test_multi_deployment_basic():
@serve.deployment(num_replicas=3)
class Inner:
pass
@serve.deployment(num_replicas=1)
class Outer:
pass
app = Outer.bind(Inner.bind(), other=Inner.options(name="Other").bind())
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Inner.options(name="Inner", _init_args=tuple(), _init_kwargs={}),
Inner.options(name="Other", _init_args=tuple(), _init_kwargs={}),
Outer.options(
name="Outer",
_init_args=(
FakeDeploymentHandle(
"Inner",
app_name="default",
),
),
_init_kwargs={
"other": FakeDeploymentHandle(
"Other",
app_name="default",
),
},
),
],
)
def test_multi_deployment_handle_in_nested_obj():
@serve.deployment(num_replicas=3)
class Inner:
pass
@serve.deployment(num_replicas=1)
class Outer:
pass
app = Outer.bind([Inner.bind()])
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Inner.options(name="Inner", _init_args=tuple(), _init_kwargs={}),
Outer.options(
name="Outer",
_init_args=(
[
FakeDeploymentHandle(
"Inner",
app_name="default",
),
],
),
_init_kwargs={},
),
],
)
def test_multi_deployment_custom_app_name():
@serve.deployment(num_replicas=3)
class Inner:
pass
@serve.deployment(num_replicas=1)
class Outer:
pass
app = Outer.bind(Inner.bind())
_build_and_check(
app,
app_name="custom",
expected_ingress_name="Outer",
expected_deployments=[
Inner.options(name="Inner", _init_args=tuple(), _init_kwargs={}),
Outer.options(
name="Outer",
_init_args=(
FakeDeploymentHandle(
"Inner",
app_name="custom",
),
),
_init_kwargs={},
),
],
)
def test_multi_deployment_name_collision():
@serve.deployment
class Inner:
pass
@serve.deployment
class Outer:
pass
app = Outer.bind(
Inner.bind("arg1"),
Inner.bind("arg2"),
)
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Inner.options(name="Inner", _init_args=("arg1",), _init_kwargs={}),
Inner.options(name="Inner_1", _init_args=("arg2",), _init_kwargs={}),
Outer.options(
name="Outer",
_init_args=(
FakeDeploymentHandle(
"Inner",
app_name="default",
),
FakeDeploymentHandle(
"Inner_1",
app_name="default",
),
),
_init_kwargs={},
),
],
)
def test_multi_deployment_same_app_passed_twice():
@serve.deployment
class Shared:
pass
@serve.deployment(num_replicas=3)
class Inner:
pass
@serve.deployment(num_replicas=1)
class Outer:
pass
shared = Shared.bind()
app = Outer.bind(Inner.bind(shared), shared)
shared_handle = FakeDeploymentHandle(
"Shared",
app_name="default",
)
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Shared.options(
name="Shared",
_init_args=tuple(),
_init_kwargs={},
),
Inner.options(name="Inner", _init_args=(shared_handle,), _init_kwargs={}),
Outer.options(
name="Outer",
_init_args=(
FakeDeploymentHandle(
"Inner",
app_name="default",
),
shared_handle,
),
_init_kwargs={},
),
],
)
def test_default_runtime_env():
@serve.deployment
class NoRayActorOptions:
pass
@serve.deployment(ray_actor_options={"num_cpus": 0, "num_gpus": 1})
class NoRuntimeEnv:
pass
@serve.deployment(ray_actor_options={"runtime_env": {"env_vars": {"ENV": "VAR"}}})
class RuntimeEnvNoWorkingDir:
pass
@serve.deployment(
ray_actor_options={"runtime_env": {"working_dir": "s3://test.zip"}}
)
class RuntimeEnvWithWorkingDir:
pass
@serve.deployment
class Outer:
pass
app = Outer.bind(
NoRayActorOptions.bind(),
NoRuntimeEnv.bind(),
RuntimeEnvNoWorkingDir.bind(),
RuntimeEnvWithWorkingDir.bind(),
)
handles = tuple(
FakeDeploymentHandle(name, app_name="default")
for name in [
"NoRayActorOptions",
"NoRuntimeEnv",
"RuntimeEnvNoWorkingDir",
"RuntimeEnvWithWorkingDir",
]
)
# 1) Test behavior when no default_runtime_env is passed.
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Outer.options(name="Outer", _init_args=handles, _init_kwargs={}),
NoRayActorOptions.options(
name="NoRayActorOptions", _init_args=tuple(), _init_kwargs={}
),
NoRuntimeEnv.options(
name="NoRuntimeEnv", _init_args=tuple(), _init_kwargs={}
),
RuntimeEnvNoWorkingDir.options(
name="RuntimeEnvNoWorkingDir", _init_args=tuple(), _init_kwargs={}
),
RuntimeEnvWithWorkingDir.options(
name="RuntimeEnvWithWorkingDir", _init_args=tuple(), _init_kwargs={}
),
],
)
# 2) Test behavior when a default_runtime_env is passed without a working_dir.
default_runtime_env = {"env_vars": {"DEFAULT": "ENV"}}
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Outer.options(
name="Outer",
_init_args=handles,
_init_kwargs={},
ray_actor_options={"num_cpus": 1, "runtime_env": default_runtime_env},
),
NoRayActorOptions.options(
name="NoRayActorOptions",
_init_args=tuple(),
_init_kwargs={},
ray_actor_options={"num_cpus": 1, "runtime_env": default_runtime_env},
),
NoRuntimeEnv.options(
name="NoRuntimeEnv",
_init_args=tuple(),
_init_kwargs={},
ray_actor_options={
"num_cpus": 0,
"num_gpus": 1,
"runtime_env": default_runtime_env,
},
),
# ray_actor_options shouldn't be affected.
RuntimeEnvNoWorkingDir.options(
name="RuntimeEnvNoWorkingDir", _init_args=tuple(), _init_kwargs={}
),
# ray_actor_options shouldn't be affected.
RuntimeEnvWithWorkingDir.options(
name="RuntimeEnvWithWorkingDir", _init_args=tuple(), _init_kwargs={}
),
],
default_runtime_env=default_runtime_env,
)
# 3) Test behavior when a default_runtime_env is passed with a working_dir.
default_runtime_env = {
"working_dir": "s3://default.zip",
"env_vars": {"DEFAULT": "ENV"},
}
_build_and_check(
app,
expected_ingress_name="Outer",
expected_deployments=[
Outer.options(
name="Outer",
_init_args=handles,
_init_kwargs={},
ray_actor_options={"num_cpus": 1, "runtime_env": default_runtime_env},
),
NoRayActorOptions.options(
name="NoRayActorOptions",
_init_args=tuple(),
_init_kwargs={},
ray_actor_options={
"num_cpus": 1,
"runtime_env": default_runtime_env,
},
),
NoRuntimeEnv.options(
name="NoRuntimeEnv",
_init_args=tuple(),
_init_kwargs={},
ray_actor_options={
"num_cpus": 0,
"num_gpus": 1,
"runtime_env": default_runtime_env,
},
),
# Only the working_dir field should be overridden.
RuntimeEnvNoWorkingDir.options(
name="RuntimeEnvNoWorkingDir",
_init_args=tuple(),
_init_kwargs={},
ray_actor_options={
"num_cpus": 1,
"runtime_env": {
"working_dir": "s3://default.zip",
**RuntimeEnvNoWorkingDir.ray_actor_options["runtime_env"],
},
},
),
# ray_actor_options shouldn't be affected.
RuntimeEnvWithWorkingDir.options(
name="RuntimeEnvWithWorkingDir", _init_args=tuple(), _init_kwargs={}
),
],
default_runtime_env=default_runtime_env,
)
def test_external_scaler_enabled():
"""Test that external_scaler_enabled is correctly set in BuiltApplication.
This test verifies that when build_app is called with external_scaler_enabled=True
or external_scaler_enabled=False, the resulting BuiltApplication object correctly
stores the external_scaler_enabled value.
"""
@serve.deployment
class D:
pass
app = D.bind()
# Test with external_scaler_enabled=True
built_app_with_scaler: BuiltApplication = build_app(
app,
name="app-with-scaler",
external_scaler_enabled=True,
)
assert built_app_with_scaler.external_scaler_enabled is True
assert built_app_with_scaler.name == "app-with-scaler"
# Test with external_scaler_enabled=False (explicit)
built_app_without_scaler: BuiltApplication = build_app(
app,
name="app-without-scaler",
external_scaler_enabled=False,
)
assert built_app_without_scaler.external_scaler_enabled is False
assert built_app_without_scaler.name == "app-without-scaler"
# Test with default value (should be False)
built_app_default: BuiltApplication = build_app(
app,
name="app-default",
)
assert built_app_default.external_scaler_enabled is False
assert built_app_default.name == "app-default"
def test_ingress_name_got_modified():
"""Test that the ingress deployment name is modified when there are name
collisions.
"""
@serve.deployment
class D:
pass
app = D.bind(D.bind(D.bind()))
built_app: BuiltApplication = build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
# The ingress deployment name should be modified to avoid collision.
assert built_app.ingress_deployment_name == "D_2"
# The ingress deployment should be the last one.
assert len(built_app.deployments) == 3
assert built_app.deployments[-1].name == "D_2"
def test_build_app_keeps_ingress_request_router_separate_from_app_deployments(
monkeypatch,
):
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
@serve.deployment
class Ingress:
pass
@serve.deployment
class IngressRequestRouter:
pass
ingress_app = Ingress.bind()
app = ingress_app._with_ingress_request_router(
IngressRequestRouter.bind(llm_deployment=ingress_app)
)
built_app: BuiltApplication = build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
assert [deployment.name for deployment in built_app.deployments] == ["Ingress"]
assert set(built_app.deployment_handles) == {"Ingress"}
assert built_app.ingress_request_router_deployment is not None
assert built_app.ingress_request_router_deployment.name == "IngressRequestRouter"
def test_build_app_requires_ingress_request_router_to_be_single_deployment(
monkeypatch,
):
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
@serve.deployment
class Ingress:
pass
@serve.deployment
class RouterChild:
pass
@serve.deployment
class IngressRequestRouter:
def __init__(self, child):
self._child = child
with pytest.raises(
ValueError,
match=(
"`ingress_request_router` to build into exactly one standalone "
"deployment"
),
):
ingress_app = Ingress.bind()
app = ingress_app._with_ingress_request_router(
IngressRequestRouter.bind(RouterChild.bind())
)
build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
def test_build_app_rejects_ingress_request_router_in_main_app_graph(monkeypatch):
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
@serve.deployment
class IngressRequestRouter:
pass
@serve.deployment
class Ingress:
def __init__(self, router):
self._router = router
ingress_request_router = IngressRequestRouter.bind()
ingress_app = Ingress.bind(ingress_request_router)
app = ingress_app._with_ingress_request_router(ingress_request_router)
with pytest.raises(
ValueError,
match="did not produce any new deployments",
):
build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
def test_ingress_validation_excludes_ingress_request_router_fastapi_app(monkeypatch):
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
ingress_api = FastAPI()
router_api = FastAPI()
@serve.deployment
@serve.ingress(ingress_api)
class Ingress:
pass
@serve.deployment
@serve.ingress(router_api)
class IngressRequestRouter:
pass
ingress_app = Ingress.bind()
app = ingress_app._with_ingress_request_router(
IngressRequestRouter.bind(llm_deployment=ingress_app)
)
built_app: BuiltApplication = build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
client = object.__new__(ServeControllerClient)
client._check_ingress_deployments([built_app])
def test_callable_uses_multiplexing_static():
"""Detects `@serve.multiplexed` on a method/function statically (class input)."""
from ray.serve._private.utils import _callable_uses_multiplexing
class MultiplexedClass:
@serve.multiplexed(max_num_models_per_replica=2)
async def load_model(self, model_id: str) -> str:
return model_id
@serve.multiplexed
async def standalone(model_id: str) -> str:
return model_id
class Plain:
async def __call__(self, request) -> str:
return "ok"
assert _callable_uses_multiplexing(MultiplexedClass)
assert _callable_uses_multiplexing(standalone)
assert not _callable_uses_multiplexing(Plain)
def test_callable_uses_multiplexing_dynamic_instance():
"""Detects multiplexing wired up dynamically at init (e.g. LLMServer LoRA).
This is only visible on a constructed instance, not on the class statically.
"""
from ray.serve._private.utils import _callable_uses_multiplexing
class DynamicMultiplexed:
def __init__(self):
async def _load(model_id: str) -> str:
return model_id
self._load_model = serve.multiplexed(max_num_models_per_replica=2)(_load)
# Not detectable on the class (the wrapper only exists after construction)...
assert not _callable_uses_multiplexing(DynamicMultiplexed)
# ...but detectable on the constructed instance.
assert _callable_uses_multiplexing(DynamicMultiplexed())
def test_callable_uses_multiplexing_ignores_handle_attrs():
"""A composed ingress holding DeploymentHandles must not be flagged.
`DeploymentHandle.__getattr__` returns a (truthy) handle for any attribute name,
so the instance-attribute scan must check the marker by identity (`is True`) to
avoid false positives that would break every composed app under direct ingress.
"""
from ray.serve._private.utils import _callable_uses_multiplexing
class FakeHandle:
# Mirrors DeploymentHandle: returns a truthy object for any attribute.
def __getattr__(self, name):
return self
class Ingress:
def __init__(self):
self._backend = FakeHandle()
async def __call__(self, request):
return await self._backend.remote()
assert not _callable_uses_multiplexing(Ingress())
@pytest.mark.parametrize(
"haproxy_enabled, request_router_class, rejected",
[
# A custom router on the ingress is rejected only under HAProxy, which
# load-balances ingress traffic and bypasses the Serve request router.
(True, RoundRobinRouter, True),
(False, RoundRobinRouter, False),
# The default router (left unset) is not custom.
(True, None, False),
],
)
def test_build_app_rejects_only_custom_ingress_request_router_under_haproxy(
monkeypatch, haproxy_enabled, request_router_class, rejected
):
"""A custom ingress router is rejected only under HAProxy. The default
router and the no-HAProxy case build."""
monkeypatch.setattr(
"ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", haproxy_enabled
)
options = {}
if request_router_class is not None:
options["request_router_config"] = RequestRouterConfig(
request_router_class=request_router_class
)
@serve.deployment
class Ingress:
pass
app = Ingress.options(**options).bind()
def build():
return build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
if rejected:
with pytest.raises(
RayServeException, match=CUSTOM_INGRESS_REQUEST_ROUTER_UNSUPPORTED_ERROR
):
build()
else:
assert [deployment.name for deployment in build().deployments] == ["Ingress"]
def test_build_app_allows_custom_ingress_request_router_in_direct_streaming(
monkeypatch,
):
"""Direct streaming attaches an ingress_request_router that delegates replica
selection back to the ingress deployment's router, so a custom router is
allowed even under HAProxy."""
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
@serve.deployment(
request_router_config=RequestRouterConfig(request_router_class=RoundRobinRouter)
)
class Ingress:
pass
@serve.deployment
class IngressRequestRouter:
pass
ingress_app = Ingress.bind()
app = ingress_app._with_ingress_request_router(
IngressRequestRouter.bind(server=ingress_app)
)
built_app: BuiltApplication = build_app(
app,
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
assert [deployment.name for deployment in built_app.deployments] == ["Ingress"]
assert built_app.ingress_request_router_deployment is not None
def test_build_app_haproxy_allows_custom_router_on_non_ingress_deployment(monkeypatch):
"""The guard targets only the ingress, so a custom router on a downstream
deployment is honored under HAProxy."""
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", True)
@serve.deployment(
request_router_config=RequestRouterConfig(request_router_class=RoundRobinRouter)
)
class Downstream:
pass
@serve.deployment
class Ingress:
def __init__(self, child):
pass
built_app: BuiltApplication = build_app(
Ingress.bind(Downstream.bind()),
name="default",
make_deployment_handle=FakeDeploymentHandle.from_deployment,
)
assert {deployment.name for deployment in built_app.deployments} == {
"Ingress",
"Downstream",
}
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,662 @@
import asyncio
import sys
from typing import List
import pytest
from ray.serve._private.common import DeploymentID
from ray.serve.experimental.capacity_queue import (
CapacityQueue,
CapacityQueueStats,
ReplicaCapacityInfo,
)
def _get_raw_class():
"""Get the underlying class without the @ray.remote decorator."""
return CapacityQueue.__ray_actor_class__
def _create_queue(acquire_timeout_s=30.0, token_ttl_s=None):
"""Create a CapacityQueue instance for local testing (no Ray)."""
raw_class = _get_raw_class()
return raw_class(
acquire_timeout_s=acquire_timeout_s,
token_ttl_s=token_ttl_s,
_enable_long_poll=False,
)
class TestCapacityQueueStats:
"""Tests for CapacityQueueStats dataclass."""
def test_stats_fields(self):
stats = CapacityQueueStats(
queue_size=10,
num_waiters=2,
num_replicas=3,
total_capacity=15,
total_in_flight=5,
total_acquires=100,
total_releases=95,
total_timeouts=1,
max_waiters_seen=5,
)
assert stats.queue_size == 10
assert stats.num_waiters == 2
assert stats.num_replicas == 3
assert stats.total_capacity == 15
assert stats.total_in_flight == 5
assert stats.total_acquires == 100
assert stats.total_releases == 95
assert stats.total_timeouts == 1
assert stats.max_waiters_seen == 5
class TestReplicaCapacityInfo:
"""Tests for ReplicaCapacityInfo dataclass."""
def test_default_values(self):
info = ReplicaCapacityInfo(replica_id="replica1", max_capacity=5)
assert info.replica_id == "replica1"
assert info.max_capacity == 5
assert info.in_flight == 0
class TestCapacityQueueLocal:
"""Tests for CapacityQueue without Ray (local logic only)."""
def test_initialization(self):
queue = _create_queue(acquire_timeout_s=10.0)
assert queue._acquire_timeout_s == 10.0
assert queue.get_queue_length() == 0
assert len(queue._waiters) == 0
assert len(queue._replicas) == 0
def test_register_replica(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
assert queue.get_queue_length() == 5
assert "replica1" in queue._replicas
assert queue._replicas["replica1"].max_capacity == 5
assert queue._replicas["replica1"].in_flight == 0
def test_register_replica_duplicate(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
queue.register_replica("replica1", 10) # Should be ignored
assert queue.get_queue_length() == 5
assert queue._replicas["replica1"].max_capacity == 5
def test_register_multiple_replicas(self):
queue = _create_queue()
queue.register_replica("replica1", 3)
queue.register_replica("replica2", 5)
queue.register_replica("replica3", 2)
assert queue.get_queue_length() == 10
assert len(queue._replicas) == 3
def test_unregister_replica(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
queue.register_replica("replica2", 3)
queue.unregister_replica("replica1")
assert queue.get_queue_length() == 3
assert "replica1" not in queue._replicas
assert "replica2" in queue._replicas
def test_unregister_nonexistent_replica(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
queue.unregister_replica("nonexistent") # Should not raise
assert queue.get_queue_length() == 5
def test_release_to_unregistered_replica(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
initial_tokens = queue.get_queue_length()
queue.release("nonexistent")
assert queue.get_queue_length() == initial_tokens
def test_get_stats(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
queue.register_replica("replica2", 3)
stats = queue.get_stats()
assert stats.queue_size == 8
assert stats.num_replicas == 2
assert stats.total_capacity == 8
assert stats.total_in_flight == 0
def test_get_registered_replicas(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
queue.register_replica("replica2", 3)
replicas = queue.get_registered_replicas()
assert set(replicas) == {"replica1", "replica2"}
class TestCapacityQueueAsync:
"""Async tests for CapacityQueue."""
@pytest.mark.asyncio
async def test_acquire_immediate(self):
queue = _create_queue()
queue.register_replica("replica1", 5)
replica_id = await queue.acquire()
assert replica_id == "replica1"
assert queue._replicas["replica1"].in_flight == 1
assert queue.get_queue_length() == 4
@pytest.mark.asyncio
async def test_acquire_and_release(self):
queue = _create_queue()
queue.register_replica("replica1", 2)
r1 = await queue.acquire()
r2 = await queue.acquire()
assert queue.get_queue_length() == 0
assert queue._replicas["replica1"].in_flight == 2
queue.release(r1)
assert queue.get_queue_length() == 1
assert queue._replicas["replica1"].in_flight == 1
queue.release(r2)
assert queue.get_queue_length() == 2
assert queue._replicas["replica1"].in_flight == 0
@pytest.mark.asyncio
async def test_acquire_timeout(self):
queue = _create_queue(acquire_timeout_s=0.1)
# No replicas registered
replica_id = await queue.acquire()
assert replica_id is None
assert queue._total_timeouts == 1
@pytest.mark.asyncio
async def test_acquire_timeout_custom(self):
queue = _create_queue(acquire_timeout_s=10.0)
replica_id = await queue.acquire(timeout_s=0.1)
assert replica_id is None
@pytest.mark.asyncio
async def test_acquire_waits_for_release(self):
queue = _create_queue()
queue.register_replica("replica1", 1)
await queue.acquire()
assert queue.get_queue_length() == 0
acquire_task = asyncio.create_task(queue.acquire(timeout_s=5.0))
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 1
queue.release("replica1")
replica_id = await acquire_task
assert replica_id == "replica1"
assert queue.get_num_waiters() == 0
@pytest.mark.asyncio
async def test_acquire_waits_for_registration(self):
queue = _create_queue()
acquire_task = asyncio.create_task(queue.acquire(timeout_s=5.0))
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 1
queue.register_replica("replica1", 3)
replica_id = await acquire_task
assert replica_id == "replica1"
assert queue.get_num_waiters() == 0
@pytest.mark.asyncio
async def test_acquire_cancellation(self):
queue = _create_queue()
acquire_task = asyncio.create_task(queue.acquire(timeout_s=5.0))
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 1
acquire_task.cancel()
try:
await acquire_task
except asyncio.CancelledError:
pass
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 0
@pytest.mark.asyncio
async def test_least_loaded_distribution(self):
queue = _create_queue()
queue.register_replica("replica1", 3)
queue.register_replica("replica2", 3)
# Acquire 2 tokens - should distribute evenly
await queue.acquire()
await queue.acquire()
assert queue._replicas["replica1"].in_flight == 1
assert queue._replicas["replica2"].in_flight == 1
# Acquire 2 more - again evenly
await queue.acquire()
await queue.acquire()
assert queue._replicas["replica1"].in_flight == 2
assert queue._replicas["replica2"].in_flight == 2
@pytest.mark.asyncio
async def test_max_waiters_tracking(self):
queue = _create_queue()
tasks = [asyncio.create_task(queue.acquire(timeout_s=0.2)) for _ in range(5)]
await asyncio.sleep(0.05)
await asyncio.gather(*tasks)
stats = queue.get_stats()
assert stats.max_waiters_seen >= 5
class TestCapacityQueueStatsTracking:
"""Tests for statistics tracking during operations."""
@pytest.mark.asyncio
async def test_total_acquires_increments(self):
queue = _create_queue(acquire_timeout_s=0.1)
queue.register_replica("replica1", 2)
assert queue._total_acquires == 0
await queue.acquire()
assert queue._total_acquires == 1
await queue.acquire()
assert queue._total_acquires == 2
# Timeout also counts
await queue.acquire()
assert queue._total_acquires == 3
@pytest.mark.asyncio
async def test_total_releases_increments(self):
queue = _create_queue()
queue.register_replica("replica1", 3)
assert queue._total_releases == 0
r1 = await queue.acquire()
r2 = await queue.acquire()
queue.release(r1)
assert queue._total_releases == 1
queue.release(r2)
assert queue._total_releases == 2
@pytest.mark.asyncio
async def test_in_flight_tracking(self):
queue = _create_queue()
queue.register_replica("replica1", 3)
assert queue._replicas["replica1"].in_flight == 0
r1 = await queue.acquire()
assert queue._replicas["replica1"].in_flight == 1
r2 = await queue.acquire()
assert queue._replicas["replica1"].in_flight == 2
queue.release(r1)
assert queue._replicas["replica1"].in_flight == 1
queue.release(r2)
assert queue._replicas["replica1"].in_flight == 0
@pytest.mark.asyncio
async def test_stats_reflect_operations(self):
queue = _create_queue(acquire_timeout_s=0.1)
queue.register_replica("replica1", 2)
queue.register_replica("replica2", 1)
r1 = await queue.acquire()
await queue.acquire()
stats = queue.get_stats()
assert stats.total_capacity == 3
assert stats.total_in_flight == 2
assert stats.queue_size == 1
assert stats.total_acquires == 2
queue.release(r1)
stats = queue.get_stats()
assert stats.total_in_flight == 1
assert stats.queue_size == 2
assert stats.total_releases == 1
class TestCapacityQueueEdgeCases:
"""Tests for edge cases and error handling."""
def test_register_zero_capacity(self):
queue = _create_queue()
queue.register_replica("replica1", 0)
assert "replica1" in queue._replicas
assert queue._replicas["replica1"].max_capacity == 0
assert queue.get_queue_length() == 0
@pytest.mark.asyncio
async def test_unregister_while_in_flight(self):
queue = _create_queue()
queue.register_replica("replica1", 3)
r1 = await queue.acquire()
assert queue._replicas["replica1"].in_flight == 1
queue.unregister_replica("replica1")
assert "replica1" not in queue._replicas
assert queue.get_queue_length() == 0
# Release should be handled gracefully (discarded)
queue.release(r1)
assert queue.get_queue_length() == 0
@pytest.mark.asyncio
async def test_release_same_token_twice(self):
"""Releasing the same token twice doesn't inflate capacity."""
queue = _create_queue()
queue.register_replica("replica1", 1)
r1 = await queue.acquire()
assert queue.get_queue_length() == 0
queue.release(r1)
assert queue.get_queue_length() == 1
# Release again - in_flight clamped to 0
queue.release(r1)
assert queue.get_queue_length() == 1 # Correctly doesn't inflate
@pytest.mark.asyncio
async def test_fifo_waiter_ordering(self):
"""Waiters are fulfilled in FIFO order."""
queue = _create_queue()
completion_order: List[int] = []
async def waiter(waiter_id: int):
await queue.acquire(timeout_s=5.0)
completion_order.append(waiter_id)
tasks = [asyncio.create_task(waiter(i)) for i in range(3)]
await asyncio.sleep(0.05)
assert queue.get_num_waiters() == 3
queue.register_replica("replica1", 3)
await asyncio.gather(*tasks)
assert completion_order == [0, 1, 2]
@pytest.mark.asyncio
async def test_multiple_replicas_least_loaded(self):
"""Least-loaded replica is selected."""
queue = _create_queue()
queue.register_replica("replica1", 2)
queue.register_replica("replica2", 2)
r1 = await queue.acquire()
r2 = await queue.acquire()
# Should be different replicas (load balanced)
assert r1 != r2
# Both should have 1 in-flight
assert queue._replicas["replica1"].in_flight == 1
assert queue._replicas["replica2"].in_flight == 1
# Acquire two more
await queue.acquire()
await queue.acquire()
assert queue._replicas["replica1"].in_flight == 2
assert queue._replicas["replica2"].in_flight == 2
# Clean up
queue.release(r1)
queue.release(r2)
@pytest.mark.asyncio
async def test_waiter_timeout_cleanup(self):
"""Timed-out waiters are properly cleaned up."""
queue = _create_queue(acquire_timeout_s=0.1)
tasks = [asyncio.create_task(queue.acquire()) for _ in range(3)]
results = await asyncio.gather(*tasks)
assert all(r is None for r in results)
assert queue.get_num_waiters() == 0
assert queue._total_timeouts == 3
@pytest.mark.asyncio
async def test_cancel_before_token_acquired(self):
"""Cancelling a request while waiting for a token (tokens exhausted)
should clean up the waiter without affecting capacity."""
queue = _create_queue()
queue.register_replica("replica1", 1)
# Exhaust the single token.
r1 = await queue.acquire()
assert queue.get_queue_length() == 0
# Second acquire blocks — no capacity available.
waiting_task = asyncio.create_task(queue.acquire(timeout_s=5.0))
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 1
# Cancel before a token is granted.
waiting_task.cancel()
with pytest.raises(asyncio.CancelledError):
await waiting_task
await asyncio.sleep(0.01)
assert queue.get_num_waiters() == 0
# Capacity unchanged — still 0 available (1 in-flight).
assert queue.get_queue_length() == 0
assert queue._replicas["replica1"].in_flight == 1
# Release the held token — full capacity restored.
queue.release(r1)
assert queue.get_queue_length() == 1
assert queue._replicas["replica1"].in_flight == 0
@pytest.mark.asyncio
async def test_cancel_after_token_acquired(self):
"""Cancelling a request after a token was acquired should still allow
the caller to release the token, preserving capacity."""
queue = _create_queue()
queue.register_replica("replica1", 2)
# Acquire a token normally.
r1 = await queue.acquire()
assert r1 == "replica1"
assert queue._replicas["replica1"].in_flight == 1
assert queue.get_queue_length() == 1
# Simulate the caller being cancelled after acquiring — the token is
# held. The caller (router) is responsible for releasing it.
# Verify capacity is reduced while the token is held.
assert queue._replicas["replica1"].in_flight == 1
# Release the token (as the router's on_request_cancelled would do).
queue.release(r1)
assert queue._replicas["replica1"].in_flight == 0
assert queue.get_queue_length() == 2
class TestReplicaLifecycleOnDeploymentTargetUpdate:
"""Tests that the queue reflects replica changes from the controller.
The controller sends deployment target updates (via long poll) when replicas
are added or removed. The queue must unregister removed replicas and
register new ones so that tokens are only issued for live replicas.
"""
@staticmethod
def _make_target_info(replica_specs):
"""Build a DeploymentTargetInfo from a list of (unique_id, capacity) tuples."""
from ray.serve._private.common import (
DeploymentTargetInfo,
ReplicaID,
RunningReplicaInfo,
)
dep_id = DeploymentID(name="test_deployment", app_name="test_app")
replicas = []
for uid, cap in replica_specs:
replicas.append(
RunningReplicaInfo(
replica_id=ReplicaID(unique_id=uid, deployment_id=dep_id),
node_id="node1",
node_ip="127.0.0.1",
availability_zone=None,
actor_name=f"actor_{uid}",
max_ongoing_requests=cap,
is_cross_language=False,
)
)
return DeploymentTargetInfo(is_available=True, running_replicas=replicas)
def test_update_adds_new_replicas(self):
"""New replicas in the update are registered in the queue."""
queue = _create_queue()
target = self._make_target_info([("r1", 5)])
queue._update_deployment_targets(target)
assert set(queue.get_registered_replicas()) == {"r1"}
assert queue.get_queue_length() == 5
def test_update_removes_old_replicas(self):
"""Replicas absent from the update are unregistered."""
queue = _create_queue()
queue.register_replica("r1", 3)
queue.register_replica("r2", 2)
assert len(queue.get_registered_replicas()) == 2
# Update with only r1 — r2 should be removed
target = self._make_target_info([("r1", 3)])
queue._update_deployment_targets(target)
assert set(queue.get_registered_replicas()) == {"r1"}
assert queue.get_queue_length() == 3
def test_update_empty_removes_all(self):
"""An update with no replicas clears the queue."""
queue = _create_queue()
queue.register_replica("r1", 3)
target = self._make_target_info([])
queue._update_deployment_targets(target)
assert queue.get_registered_replicas() == []
assert queue.get_queue_length() == 0
def test_update_is_idempotent(self):
"""Calling update twice with the same replicas is a no-op."""
queue = _create_queue()
target = self._make_target_info([("r1", 5)])
queue._update_deployment_targets(target)
queue._update_deployment_targets(target)
assert set(queue.get_registered_replicas()) == {"r1"}
assert queue.get_queue_length() == 5
@pytest.mark.asyncio
async def test_scale_down_frees_dead_capacity(self):
"""After scale-down, tokens for removed replicas are no longer issued."""
queue = _create_queue()
queue.register_replica("r1", 1)
queue.register_replica("r2", 1)
# Saturate both replicas
t1 = await queue.acquire()
t2 = await queue.acquire()
assert queue.get_queue_length() == 0
# Scale down: remove r2
target = self._make_target_info([("r1", 1)])
queue._update_deployment_targets(target)
# Release both tokens — only r1's should restore capacity
queue.release(t1)
queue.release(t2) # r2 is gone, release is discarded
assert queue.get_queue_length() == 1
assert set(queue.get_registered_replicas()) == {"r1"}
# Next acquire must go to r1 (no phantom r2)
token = await queue.acquire(timeout_s=1.0)
assert token == "r1"
class TestTokenTTL:
"""Tests for the token TTL auto-reclaim feature."""
@pytest.mark.asyncio
async def test_expired_token_reclaimed(self):
"""Tokens exceeding the TTL are automatically reclaimed."""
queue = _create_queue(token_ttl_s=0.2)
queue.register_replica("replica1", 2)
# Acquire a token (never release it).
r = await queue.acquire()
assert r == "replica1"
assert queue._replicas["replica1"].in_flight == 1
assert queue.get_queue_length() == 1
# Wait for the TTL reaper to reclaim it.
await asyncio.sleep(0.5)
assert queue._replicas["replica1"].in_flight == 0
assert queue.get_queue_length() == 2
assert queue._total_ttl_reclaims == 1
@pytest.mark.asyncio
async def test_ttl_does_not_reclaim_released_tokens(self):
"""Properly released tokens are not double-reclaimed by the reaper."""
queue = _create_queue(token_ttl_s=0.2)
queue.register_replica("replica1", 2)
r = await queue.acquire()
queue.release(r)
assert queue._replicas["replica1"].in_flight == 0
# Wait past TTL — reaper should find nothing to reclaim.
await asyncio.sleep(0.5)
assert queue._replicas["replica1"].in_flight == 0
assert queue.get_queue_length() == 2
assert queue._total_ttl_reclaims == 0
@pytest.mark.asyncio
async def test_ttl_reclaim_wakes_waiters(self):
"""Reclaimed capacity from TTL should wake blocked waiters."""
queue = _create_queue(token_ttl_s=0.2)
queue.register_replica("replica1", 1)
# Exhaust capacity.
await queue.acquire()
assert queue.get_queue_length() == 0
# This waiter should be woken once the TTL reaper reclaims.
result = await asyncio.wait_for(queue.acquire(), timeout=2.0)
assert result == "replica1"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
+194
View File
@@ -0,0 +1,194 @@
import json
import os
import pathlib
import sys
from tempfile import NamedTemporaryFile
from typing import Dict, Optional
from unittest.mock import Mock, patch
import click
import pytest
import yaml
from click.testing import CliRunner
from ray.serve.schema import ServeApplicationSchema
from ray.serve.scripts import build, convert_args_to_dict, deploy
def test_convert_args_to_dict():
assert convert_args_to_dict(tuple()) == {}
with pytest.raises(
click.ClickException, match="Invalid application argument 'bad_arg'"
):
convert_args_to_dict(("bad_arg",))
with pytest.raises(
click.ClickException, match="Invalid application argument 'bad_arg='"
):
convert_args_to_dict(("bad_arg=",))
assert convert_args_to_dict(("key1=val1", "key2=val2", "key3=nested=val")) == {
"key1": "val1",
"key2": "val2",
"key3": "nested=val",
}
class FakeServeSubmissionClient:
def __init__(self):
self._deployed_config: Optional[Dict] = None
@property
def deployed_config(self) -> Optional[Dict]:
return self._deployed_config
def deploy_applications(self, config: Dict):
self._deployed_config = config
@pytest.fixture
def fake_serve_client() -> FakeServeSubmissionClient:
fake_client = FakeServeSubmissionClient()
with patch(
"ray.serve.scripts.ServeSubmissionClient",
new=Mock(return_value=fake_client),
):
yield fake_client
class TestDeploy:
def test_deploy_basic(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={},
runtime_env={},
).model_dump(exclude_unset=True)
]
def test_deploy_with_name(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app", "--name", "test-name"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
name="test-name",
args={},
runtime_env={},
).model_dump(exclude_unset=True)
]
def test_deploy_with_args(self, fake_serve_client):
runner = CliRunner()
result = runner.invoke(deploy, ["my_module:my_app", "arg1=val1", "arg2=val2"])
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={"arg1": "val1", "arg2": "val2"},
runtime_env={},
).model_dump(exclude_unset=True)
]
@pytest.mark.skipif(sys.platform == "win32", reason="Tempfile not working.")
@pytest.mark.parametrize(
"runtime_env",
[
{"env_vars": {"hi": "123"}},
{
"working_dir": "s3://some_bucket/pkg.zip",
"py_modules": ["s3://some_other_bucket/pkg.zip"],
},
],
)
@pytest.mark.parametrize("use_json", [False, True])
def test_deploy_with_runtime_env(
self, fake_serve_client, runtime_env: Dict, use_json: bool
):
runner = CliRunner()
with NamedTemporaryFile("w") as f:
if use_json:
runtime_env_args = ["--runtime-env-json", json.dumps(runtime_env)]
else:
yaml.dump(runtime_env, f, default_flow_style=False)
runtime_env_args = ["--runtime-env", f.name]
result = runner.invoke(deploy, ["my_module:my_app"] + runtime_env_args)
assert result.exit_code == 0, result.output
assert fake_serve_client.deployed_config["applications"] == [
ServeApplicationSchema(
import_path="my_module:my_app",
args={},
runtime_env=runtime_env,
).model_dump(exclude_unset=True)
]
class TestEnumSerialization:
"""Test that enum representer correctly serializes enums in YAML dumps."""
def test_build_command_with_enum_serialization(self):
"""Test that serve build correctly serializes AggregationFunction enum."""
runner = CliRunner()
with NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(
"from ray import serve\n"
"from ray.serve.config import AggregationFunction, AutoscalingConfig\n\n"
"@serve.deployment(\n"
" autoscaling_config=AutoscalingConfig(\n"
" min_replicas=1,\n"
" max_replicas=2,\n"
" aggregation_function=AggregationFunction.MEAN,\n"
" )\n"
")\n"
"def test_deployment():\n"
" return 'ok'\n\n"
"app = test_deployment.bind()\n"
)
temp_path = f.name
output_path = None
try:
import_path = f"{pathlib.Path(temp_path).stem}:app"
with NamedTemporaryFile(
mode="w", suffix=".yaml", delete=False
) as output_file:
output_path = output_file.name
result = runner.invoke(
build,
[
import_path,
"--app-dir",
str(pathlib.Path(temp_path).parent),
"--output-path",
output_path,
],
)
assert result.exit_code == 0, result.output
with open(output_path, "r") as f:
config = yaml.safe_load(f)
agg_func = config["applications"][0]["deployments"][0][
"autoscaling_config"
]["aggregation_function"]
assert agg_func == "mean"
assert isinstance(agg_func, str)
finally:
os.unlink(temp_path)
if output_path:
os.unlink(output_path)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
+257
View File
@@ -0,0 +1,257 @@
import pytest
from ray.serve._private.common import (
REPLICA_ID_FULL_ID_STR_PREFIX,
DeploymentID,
DeploymentStatus,
DeploymentStatusInfo,
DeploymentStatusInternalTrigger,
DeploymentStatusTrigger,
ReplicaID,
RunningReplicaInfo,
)
from ray.serve._private.constants import SERVE_DEPLOYMENT_ACTOR_PREFIX
from ray.serve._private.utils import get_deployment_actor_name, get_random_string
from ray.serve.generated.serve_pb2 import (
DeploymentStatusInfo as DeploymentStatusInfoProto,
)
def test_get_deployment_actor_name():
"""Test deterministic actor name for deployment-scoped actors."""
dep_id = DeploymentID(name="MyDeployment", app_name="my_app")
assert get_deployment_actor_name(dep_id, "prefix_tree", "v1") == (
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}my_app::MyDeployment::v1::prefix_tree"
)
dep_id_default_app = DeploymentID(name="Other") # app_name="default"
assert get_deployment_actor_name(dep_id_default_app, "x", "abc") == (
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}default::Other::abc::x"
)
def test_replica_id_formatting():
deployment = "DeploymentA"
unique_id = get_random_string()
app = "my_app"
replica_id = ReplicaID(
unique_id, deployment_id=DeploymentID(name=deployment, app_name=app)
)
assert (
replica_id.to_full_id_str()
== f"{REPLICA_ID_FULL_ID_STR_PREFIX}{app}#{deployment}#{unique_id}"
)
assert (
str(replica_id)
== f"Replica(id='{unique_id}', deployment='{deployment}', app='{app}')"
)
def test_replica_id_from_str():
unique_id = get_random_string()
# Test without app name.
full_id_str = f"{REPLICA_ID_FULL_ID_STR_PREFIX}DeploymentA#{unique_id}"
replica_id = ReplicaID.from_full_id_str(full_id_str)
assert replica_id.unique_id == unique_id
assert replica_id.deployment_id == DeploymentID(name="DeploymentA", app_name="")
# Test with app name.
full_id_str = f"{REPLICA_ID_FULL_ID_STR_PREFIX}App1#DeploymentA#{unique_id}"
replica_id = ReplicaID.from_full_id_str(full_id_str)
assert replica_id.unique_id == unique_id
assert replica_id.deployment_id == DeploymentID(name="DeploymentA", app_name="App1")
def test_invalid_id_from_str():
unique_id = get_random_string()
# Missing prefix.
full_id_str = f"DeploymentA#{unique_id}"
with pytest.raises(AssertionError):
ReplicaID.from_full_id_str(full_id_str)
# Too many delimiters.
full_id_str = f"{REPLICA_ID_FULL_ID_STR_PREFIX}DeploymentA###{unique_id}"
with pytest.raises(ValueError):
ReplicaID.from_full_id_str(full_id_str)
def test_is_replica_id():
unique_id = get_random_string()
assert not ReplicaID.is_full_id_str(f"DeploymentA##{unique_id}")
assert not ReplicaID.is_full_id_str(f"DeploymentA#{unique_id}")
assert ReplicaID.is_full_id_str(
f"{REPLICA_ID_FULL_ID_STR_PREFIX}DeploymentA#{unique_id}"
)
assert ReplicaID.is_full_id_str(
f"{REPLICA_ID_FULL_ID_STR_PREFIX}#App1#DeploymentA#{unique_id}"
)
class TestDeploymentStatusInfo:
def test_name_required(self):
with pytest.raises(TypeError):
DeploymentStatusInfo(
status=DeploymentStatus.HEALTHY,
status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
def test_deployment_status_required(self):
with pytest.raises(TypeError):
DeploymentStatusInfo(
name="test_name",
status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED,
)
@pytest.mark.parametrize(
"status,status_trigger",
list(zip(list(DeploymentStatus), list(DeploymentStatusTrigger))),
)
def test_proto(self, status, status_trigger):
deployment_status_info = DeploymentStatusInfo(
name="test_name",
status=status,
status_trigger=status_trigger,
message="context about status",
)
serialized_proto = deployment_status_info.to_proto().SerializeToString()
deserialized_proto = DeploymentStatusInfoProto.FromString(serialized_proto)
reconstructed_info = DeploymentStatusInfo.from_proto(deserialized_proto)
assert deployment_status_info == reconstructed_info
def test_handle_transition_deployment_actor_failed_when_already_deploy_failed(
self,
):
"""DEPLOYMENT_ACTOR_FAILED must be handled in DEPLOY_FAILED block.
When status is already DEPLOY_FAILED, repeated ticks call handle_transition
with DEPLOYMENT_ACTOR_FAILED. Without handling, the trigger falls through
and returns self (old message). With handling, returns updated copy.
"""
info = DeploymentStatusInfo(
name="test",
status=DeploymentStatus.DEPLOY_FAILED,
status_trigger=DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED,
message="original error message",
)
new_message = (
"The deployment failed to start deployment actors 2 times in a row."
)
result = info.handle_transition(
trigger=DeploymentStatusInternalTrigger.DEPLOYMENT_ACTOR_FAILED,
message=new_message,
)
assert result is not None
assert result.status == DeploymentStatus.DEPLOY_FAILED
assert result.status_trigger == DeploymentStatusTrigger.DEPLOYMENT_ACTOR_FAILED
assert result.message == new_message
def test_running_replica_info():
"""Test hash value of RunningReplicaInfo"""
replica_id = ReplicaID("asdf123", deployment_id=DeploymentID(name="my_deployment"))
actor_name = replica_id.to_full_id_str()
# Test that replicas with same attributes have same hash
replica1 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
)
replica2 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
)
# Test that cross-language setting affects hash
replica3 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=True,
)
assert replica1._hash == replica2._hash
assert replica3._hash != replica1._hash
# Test that backend_http_port affects hash so long-poll updates
# propagate when the backend HTTP port changes.
replica4 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
backend_http_port=8001,
)
replica5 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
backend_http_port=8002,
)
assert replica4._hash != replica1._hash
assert replica4._hash != replica5._hash
# Test that network endpoint changes affect hash so wrappers and
# long-poll consumers refresh when the replica moves or its gRPC
# port changes.
replica6 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip_a",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
port=9000,
)
replica7 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip_b",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
port=9000,
)
replica8 = RunningReplicaInfo(
replica_id=replica_id,
node_id="node_id",
node_ip="node_ip_a",
availability_zone="some-az",
actor_name=actor_name,
max_ongoing_requests=1,
is_cross_language=False,
port=9001,
)
assert replica6._hash != replica7._hash
assert replica6._hash != replica8._hash
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,618 @@
import asyncio
import sys
from collections import Counter
from typing import Optional
import pytest
from ray._common.utils import get_or_create_event_loop
from ray.exceptions import ActorDiedError, ActorUnavailableError
from ray.serve._private.common import (
DeploymentHandleSource,
RequestMetadata,
)
from ray.serve._private.request_router import PendingRequest
from ray.serve._private.test_utils import (
FAKE_REPLICA_DEFAULT_MAX_ONGOING_REQUESTS as DEFAULT_MAX_ONGOING_REQUESTS,
FAKE_REPLICA_DEPLOYMENT_ID as DEPLOYMENT_ID,
FakeRunningReplica,
MockTimer,
)
from ray.serve._private.utils import generate_request_id
from ray.serve.experimental.consistent_hash_router import (
DEFAULT_NUM_FALLBACK_REPLICAS,
DEFAULT_NUM_VIRTUAL_NODES,
ConsistentHashRouter,
_hash_bytes,
)
TIMER = MockTimer()
def _make_request(
session_id: str = "", request_id: Optional[str] = None
) -> PendingRequest:
internal_id = request_id if request_id is not None else generate_request_id()
return PendingRequest(
args=[],
kwargs={},
metadata=RequestMetadata(
request_id=generate_request_id(),
internal_request_id=internal_id,
session_id=session_id,
),
)
@pytest.fixture
def router(request) -> ConsistentHashRouter:
"""Construct a ConsistentHashRouter on its own loop so its
asyncio.Event attaches to the correct loop, then return it to
the active test loop."""
params = getattr(request, "param", {})
async def build(loop: asyncio.AbstractEventLoop) -> ConsistentHashRouter:
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="fake-actor-id",
self_actor_handle=None,
use_replica_queue_len_cache=False,
get_curr_time_s=TIMER.time,
)
r.initialize_state(**params)
r.initial_backoff_s = 0.001
r.backoff_multiplier = 1
r.max_backoff_s = 0.001
return r
s = asyncio.new_event_loop().run_until_complete(build(get_or_create_event_loop()))
TIMER.reset()
yield s
# Confirm routing loop exits cleanly when there's no residual work.
assert s.curr_num_routing_tasks == 0
assert s.num_pending_requests == 0
def test_hash_bytes():
assert _hash_bytes(b"hello") == _hash_bytes(b"hello")
assert _hash_bytes(b"sess_0000") != _hash_bytes(b"sess_0001")
h = _hash_bytes(b"arbitrary")
assert isinstance(h, int)
assert 0 <= h < (1 << 64)
@pytest.mark.parametrize(
"kwargs",
[
{"num_virtual_nodes": 0},
{"num_virtual_nodes": -5},
{"num_virtual_nodes": "many"},
{"num_fallback_replicas": -1},
],
)
def test_initialize_state_invalid_args(kwargs):
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="a",
self_actor_handle=None,
)
with pytest.raises(ValueError):
r.initialize_state(**kwargs)
def test_initialize_state_valid_args():
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="a",
self_actor_handle=None,
)
r.initialize_state(num_virtual_nodes=42, num_fallback_replicas=7)
assert r._num_virtual_nodes == 42
assert r._num_fallback_replicas == 7
def test_ring_defaults(router):
assert router._num_virtual_nodes == DEFAULT_NUM_VIRTUAL_NODES
assert router._num_fallback_replicas == DEFAULT_NUM_FALLBACK_REPLICAS
@pytest.mark.asyncio
@pytest.mark.parametrize("router", [{"num_fallback_replicas": 5}], indirect=True)
async def test_explicit_num_fallback_replicas_caps_gracefully(router):
"""When the operator explicitly requests more fallbacks than there
are replicas, the lookup must stop early gracefully."""
# 3 replicas with num_fallback_replicas=5 means we'd want a rank list
# of 6 but only 3 distinct replicas exist; the loop must cap at 3.
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
ranked = router._lookup_ranked_replicas("any_session")
assert len(ranked) == 3
assert len(set(ranked)) == 3
@pytest.mark.asyncio
async def test_default_num_fallback_replicas_caps_gracefully(
router,
):
"""Defaulted num_fallback_replicas must also stop early gracefully when
there are fewer replicas than 1 + DEFAULT_NUM_FALLBACK_REPLICAS."""
assert router._num_fallback_replicas == DEFAULT_NUM_FALLBACK_REPLICAS
replicas = [
FakeRunningReplica(f"r{i}") for i in range(DEFAULT_NUM_FALLBACK_REPLICAS)
]
router.update_replicas(replicas)
ranked = router._lookup_ranked_replicas("any_session")
assert len(ranked) == len(replicas)
assert len(set(ranked)) == len(replicas)
@pytest.mark.asyncio
async def test_choose_replicas_no_replicas_returns_empty(router):
result = await router.choose_replicas(
candidate_replicas=[], pending_request=_make_request("sess_x")
)
assert result == []
@pytest.mark.asyncio
async def test_choose_replicas_stickiness(router):
"""Repeated calls with the same session_id must map to the same primary replica."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(replicas)
sess = "user_123"
first = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request(sess)
)
for _ in range(10):
again = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request(sess)
)
assert again[0][0].replica_id == first[0][0].replica_id
@pytest.mark.asyncio
@pytest.mark.parametrize("router", [{"num_fallback_replicas": 0}], indirect=True)
async def test_choose_replicas_no_fallbacks(router):
"""K=0 -> a single rank containing only the primary."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("sess_x")
)
assert len(ranks) == 1
assert len(ranks[0]) == 1
@pytest.mark.asyncio
async def test_choose_replicas_fallback_caps_at_replica_count(router):
replicas = [FakeRunningReplica("ra"), FakeRunningReplica("rb")]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("sess_x")
)
assert len(ranks) == 2
flat = [rep.replica_id for rank in ranks for rep in rank]
assert len(flat) == len(set(flat)) == 2
@pytest.mark.asyncio
async def test_choose_replicas_missing_session_spreads_across_replicas(router):
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
primaries = set()
for _ in range(20):
ranks = await router.choose_replicas(
candidate_replicas=replicas,
pending_request=_make_request(session_id=""),
)
primaries.add(ranks[0][0].replica_id)
assert len(primaries) > 1
@pytest.mark.asyncio
async def test_lookup_wraps_around_past_max_vnode(router, monkeypatch):
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
max_vnode = max(router._ring_hashes)
monkeypatch.setattr(
"ray.serve.experimental.consistent_hash_router._hash_bytes",
lambda key: max_vnode + 1,
)
ranked = router._lookup_ranked_replicas("anything")
# Wrapping around sends us to index 0 so the primary is whichever replica
# owns the smallest vnode.
assert ranked[0] == router._ring_replicas[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("key_hash", [0, (1 << 64) - 1])
async def test_lookup_at_hash_space_boundaries(router, monkeypatch, key_hash):
"""Key hashing to exactly 0 or 2**64 - 1 must still produce a valid owner."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
monkeypatch.setattr(
"ray.serve.experimental.consistent_hash_router._hash_bytes",
lambda key: key_hash,
)
ranked = router._lookup_ranked_replicas("anything")
assert len(ranked) == 1 + DEFAULT_NUM_FALLBACK_REPLICAS
assert ranked[0] in {r.replica_id for r in replicas}
@pytest.mark.asyncio
async def test_balanced_distribution(router):
"""100 vnodes per replica should distribute random sessions close to uniform."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas)
counts: Counter = Counter()
for i in range(2000):
ranks = await router.choose_replicas(
candidate_replicas=replicas,
pending_request=_make_request(f"sess_{i}"),
)
counts[ranks[0][0].replica_id.unique_id] += 1
fair = 2000 / 5
for c in counts.values():
assert 0.5 * fair < c < 2 * fair
@pytest.mark.asyncio
async def test_scale_up_remaps_small_fraction(router):
"""Adding the Nth replica should remap ~1/N of sessions."""
initial = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(initial)
sessions = [f"sess_{i}" for i in range(1000)]
mapping_before = {}
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=initial, pending_request=_make_request(s)
)
mapping_before[s] = ranks[0][0].replica_id
# Scale from 4 to 5.
scaled = initial + [FakeRunningReplica("r_new")]
router.update_replicas(scaled)
moved = 0
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=scaled, pending_request=_make_request(s)
)
if ranks[0][0].replica_id != mapping_before[s]:
moved += 1
# Expected: 1/5 = 20%. Upper bound generous to avoid flake on
# random-cluster draws of the hash.
assert moved < 0.4 * len(
sessions
), f"Scale-up remapped {moved}/{len(sessions)} sessions; expected ~200"
assert moved > 0
@pytest.mark.asyncio
async def test_scale_down_remaps_only_owned_sessions(router):
"""Removing one replica out of R should remap roughly 1/R sessions.
Sessions that were not on the removed replica must stay put."""
initial = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(initial)
sessions = [f"sess_{i}" for i in range(1000)]
mapping_before = {}
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=initial, pending_request=_make_request(s)
)
mapping_before[s] = ranks[0][0].replica_id
# Drop the first replica from the set.
dropped_id = initial[0].replica_id
remaining = initial[1:]
router.update_replicas(remaining)
moved_owned = 0
moved_other = 0
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=remaining, pending_request=_make_request(s)
)
new = ranks[0][0].replica_id
prev = mapping_before[s]
if prev == dropped_id:
moved_owned += 1
elif new != prev:
moved_other += 1
# Every session that was on the dropped replica must move.
expected_owned = sum(1 for v in mapping_before.values() if v == dropped_id)
assert moved_owned == expected_owned
# Sessions not owned by the dropped replica stay put.
assert moved_other == 0
@pytest.mark.asyncio
async def test_fallback_when_primary_at_max_ongoing(router):
"""Primary saturated -> the outer retry loop must walk to fallback_1."""
loop = get_or_create_event_loop()
replicas = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
# Saturate the primary, leave fallbacks free.
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
else:
r.set_queue_len_response(0)
chosen = await loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
assert chosen.replica_id == fallback_id
@pytest.mark.asyncio
async def test_backoff_when_all_exhausted_then_retries_on_drain(router):
"""When every ranked replica is at max_ongoing_requests, the routing
task backs off and loops. When capacity opens up, it picks the newly
freed replica on the next iteration -- without us having to call
update_replicas."""
loop = get_or_create_event_loop()
# K+1 = 3 replicas so every one is in the ranked set.
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
for r in replicas:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
router.update_replicas(replicas)
task = loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
# Nothing should be ready: the task is stuck in the backoff loop
# because every replica reports itself as full.
done, _ = await asyncio.wait([task], timeout=0.02)
assert len(done) == 0
# Drain the primary. The retry loop's next probe sees the open slot
# and the task completes.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(0)
chosen = await task
assert chosen.replica_id == primary_id
@pytest.mark.asyncio
async def test_dead_primary_routes_to_fallback(router):
"""``ActorDiedError`` drops the primary from ``_replicas``; the
ring's stale reference is filtered out and the request lands on
the fallback instead of hanging."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
# Find the primary + fallback for "user_42" through the ring.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
# Primary's probe raises ActorDiedError; fallbacks respond normally.
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(queue_len=0, exception=ActorDiedError())
else:
r.set_queue_len_response(0)
chosen = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen.replica_id == fallback_id
# The dead replica should be gone from _replicas, so subsequent
# requests on this session never probe it again.
assert primary_id not in router._replicas
@pytest.mark.asyncio
async def test_unavailable_primary_falls_over_then_recovers(router):
"""``ActorUnavailableError`` is transient: request falls over to
the fallback, the replica stays in ``_replicas``, and sticky
traffic returns to the primary once it recovers."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
primary = next(r for r in replicas if r.replica_id == primary_id)
# Primary raises ActorUnavailableError; fallbacks accept.
primary.set_queue_len_response(
queue_len=0,
exception=ActorUnavailableError(
error_message="temporarily unavailable",
actor_id=b"a" * 16,
),
)
for r in replicas:
if r.replica_id != primary_id:
r.set_queue_len_response(0)
chosen = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen.replica_id == fallback_id
# Unlike the died case, the replica must still be in _replicas --
# ActorUnavailableError is transient.
assert primary_id in router._replicas
# Recover the primary. Next sticky request must return to it.
primary.set_queue_len_response(queue_len=0, exception=None)
chosen2 = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen2.replica_id == primary_id
@pytest.mark.asyncio
async def test_backoff_engaged_when_all_ranks_saturated(router):
router.initial_backoff_s = 999
router.backoff_multiplier = 1
router.max_backoff_s = 999
loop = get_or_create_event_loop()
# 1 + DEFAULT_NUM_FALLBACK_REPLICAS replicas -> the full ranked set is
# saturated, so _choose_replicas_with_backoff exhausts every yielded rank
# and falls through to the backoff branch.
replicas = [
FakeRunningReplica(f"r{i}") for i in range(1 + DEFAULT_NUM_FALLBACK_REPLICAS)
]
for r in replicas:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
router.update_replicas(replicas)
task = loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
done, _ = await asyncio.wait([task], timeout=0.1)
assert len(done) == 0
# The routing task must have entered the backoff branch.
assert router.num_routing_tasks_in_backoff >= 1
# Free up the primary. With a 999s backoff sleep already in flight, the
# task must remain pending.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(0)
done, _ = await asyncio.wait([task], timeout=0.1)
assert len(done) == 0, (
"routing task completed within the sleep window -- "
"exponential backoff is not being applied"
)
task.cancel()
try:
await task
except BaseException:
pass
router._routing_tasks.clear()
router._pending_requests_to_fulfill.clear()
router._pending_requests_to_route.clear()
@pytest.mark.asyncio
async def test_concurrent_same_session_does_not_orphan(router):
"""Concurrent same-session requests at the rank-list size must all
resolve, not livelock.
Regression: the routing task that pops a pending from the route deque
is the one responsible for fulfilling it. If the inner retry loop
breaks early under load (e.g., on the routing-task-shedding check after
a probe miss) without fulfilling, that pending used to become an
orphan: still in `_pending_requests_to_fulfill` / `_pending_requests_by_id`
but with no task actively routing it. Surviving routing tasks would
then loop with `request_metadata=None`, and because ConsistentHashRouter
uses strict-metadata-match fulfillment (no FIFO fallback like pow2),
they couldn't pick up the orphan, livelocking the router.
"""
loop = get_or_create_event_loop()
# 1 + DEFAULT_NUM_FALLBACK_REPLICAS replicas so the full rank list is
# exactly the same set across requests (no replica outside the ranks).
replicas = [
FakeRunningReplica(f"r{i}") for i in range(1 + DEFAULT_NUM_FALLBACK_REPLICAS)
]
for r in replicas:
r.set_queue_len_response(0)
router.update_replicas(replicas)
# Fire (1 + num_fallback_replicas + 2) concurrent same-session requests.
# That's strictly more than the rank-list size, exercising the path
# where the routing task that owns a popped pending might temporarily
# exceed the dynamically computed task target.
burst_size = 1 + DEFAULT_NUM_FALLBACK_REPLICAS + 2
tasks = [
loop.create_task(
router._choose_replica_for_request(_make_request("burst-session"))
)
for _ in range(burst_size)
]
done, pending = await asyncio.wait(tasks, timeout=2.0)
assert len(pending) == 0, (
f"{len(pending)}/{burst_size} concurrent same-session requests "
"did not resolve -- ConsistentHashRouter livelocked under load."
)
chosen_replicas = {t.result().replica_id for t in done}
# Affinity preserved: every burst request lands on the same primary.
assert len(chosen_replicas) == 1
@pytest.mark.asyncio
async def test_two_routers_route_same_session_to_same_replica(router):
"""Two independent routers with the same replica set must pick the same
replica for the same session_id."""
replicas_a = [FakeRunningReplica(f"r{i}") for i in range(5)]
replicas_b = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas_a)
other = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="other",
self_actor_handle=None,
)
other.initialize_state()
other.update_replicas(replicas_b)
for i in range(20):
sess = f"user_{i}"
ranks_a = await router.choose_replicas(
candidate_replicas=replicas_a,
pending_request=_make_request(sess),
)
ranks_b = await other.choose_replicas(
candidate_replicas=replicas_b,
pending_request=_make_request(sess),
)
assert ranks_a[0][0].replica_id == ranks_b[0][0].replica_id, (
f"Session {sess} diverged: {ranks_a[0][0].replica_id} vs "
f"{ranks_b[0][0].replica_id}"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,240 @@
import os
from unittest.mock import patch
import pytest
from ray.serve._private.constants_utils import (
_validate_name,
get_env_bool,
get_env_float,
get_env_float_non_negative,
get_env_float_positive,
get_env_int,
get_env_int_non_negative,
get_env_int_positive,
get_env_str,
parse_latency_buckets,
str_to_list,
)
class TestStrToList:
def test_str_to_list_basic(self):
assert str_to_list("a,b,c") == ["a", "b", "c"]
def test_str_to_list_with_whitespace(self):
assert str_to_list(" a , b , c ") == ["a", "b", "c"]
def test_str_to_list_empty_string(self):
assert str_to_list("") == []
def test_str_to_list_with_empty_entries(self):
assert str_to_list("a,,b,c,") == ["a", "b", "c"]
def test_str_to_list_only_whitespace(self):
assert str_to_list(" ") == []
def test_str_to_list_single_entry(self):
assert str_to_list("single") == ["single"]
def test_str_to_list_only_commas(self):
assert str_to_list(",,,,") == []
def test_str_to_list_whitespace_entries(self):
assert str_to_list("a, ,b") == ["a", "b"]
class TestParseLatencyBuckets:
def test_parse_latency_buckets(self):
# Test valid inputs with different formats
assert parse_latency_buckets("1,2,3", []) == [1.0, 2.0, 3.0]
assert parse_latency_buckets("1,2,3,4 ", []) == [1.0, 2.0, 3.0, 4.0]
assert parse_latency_buckets(" 1,2,3,4,5", []) == [1.0, 2.0, 3.0, 4.0, 5.0]
assert parse_latency_buckets(" 1, 2,3 ,4,5 ,6 ", []) == [
1.0,
2.0,
3.0,
4.0,
5.0,
6.0,
]
# Test decimal numbers
assert parse_latency_buckets("0.5,1.5,2.5", []) == [0.5, 1.5, 2.5]
def test_parse_latency_buckets_invalid(self):
# Test negative numbers
with pytest.raises(ValueError, match=".*must be positive.*"):
parse_latency_buckets("-1,1,2,3,4", [])
# Test non-ascending order
with pytest.raises(ValueError, match=".*be in strictly ascending order*"):
parse_latency_buckets("4,3,2,1", [])
# Test duplicate values
with pytest.raises(ValueError, match=".*be in strictly ascending order.*"):
parse_latency_buckets("1,2,2,3,4", [])
# Test invalid number format
with pytest.raises(ValueError, match=".*Invalid.*format.*"):
parse_latency_buckets("1,2,3,4,a", [])
# Test empty list
with pytest.raises(ValueError, match=".*could not convert.*"):
parse_latency_buckets(",,,", [])
# Test invalid separators
with pytest.raises(ValueError, match=".*could not convert.*"):
parse_latency_buckets("1;2;3;4", [])
@pytest.fixture
def mock_environ():
with patch.dict(os.environ, {}, clear=True) as mock_env:
yield mock_env
class TestEnvValueFunctions:
def test_get_env_int(self, mock_environ):
assert get_env_int("RAY_SERVE_TEST_VAR", 0) == 0
mock_environ["RAY_SERVE_TEST_VAR"] = "42"
assert get_env_int("RAY_SERVE_TEST_VAR", 0) == 42
mock_environ["RAY_SERVE_TEST_VAR"] = "-1"
assert get_env_int("RAY_SERVE_TEST_VAR", 0) == -1
mock_environ["RAY_SERVE_TEST_VAR"] = "0.1"
with pytest.raises(ValueError, match=".*`0.1` cannot be converted to `int`!*"):
get_env_int_positive("RAY_SERVE_TEST_VAR", 5)
mock_environ["RAY_SERVE_TEST_VAR"] = "abc"
with pytest.raises(ValueError, match=".*`abc` cannot be converted to `int`!*"):
get_env_int_positive("RAY_SERVE_TEST_VAR", 5)
with pytest.raises(ValueError, match=".*require prefix `RAY_SERVE_`*"):
get_env_int_positive("NO_PREFIX", 5)
def test_get_env_int_positive(self, mock_environ):
assert get_env_int_positive("RAY_SERVE_TEST_VAR", 1) == 1
mock_environ["RAY_SERVE_TEST_VAR"] = "42"
assert get_env_int_positive("RAY_SERVE_TEST_VAR", 1) == 42
mock_environ["RAY_SERVE_TEST_VAR"] = "-1"
with pytest.raises(ValueError, match=".*Expected positive `int`.*"):
get_env_int_positive("RAY_SERVE_TEST_VAR", 5)
def test_get_env_int_non_negative(self, mock_environ):
assert get_env_int_non_negative("RAY_SERVE_TEST_VAR", 0) == 0
assert get_env_int_non_negative("RAY_SERVE_TEST_VAR", 1) == 1
mock_environ["RAY_SERVE_TEST_VAR"] = "42"
assert get_env_int_non_negative("RAY_SERVE_TEST_VAR", 0) == 42
mock_environ["RAY_SERVE_TEST_VAR"] = "-1"
with pytest.raises(ValueError, match=".*Expected non negative `int`.*"):
get_env_int_non_negative("RAY_SERVE_TEST_VAR", 5)
with pytest.raises(ValueError, match=".*Expected non negative `int`.*"):
get_env_int_non_negative("RAY_SERVE_TEST_VAR_FROM_DEFAULT", -1)
def test_get_env_float(self, mock_environ):
assert get_env_float("RAY_SERVE_TEST_VAR", 0.0) == 0.0
mock_environ["RAY_SERVE_TEST_VAR"] = "3.14"
assert get_env_float("RAY_SERVE_TEST_VAR", 0.0) == 3.14
mock_environ["RAY_SERVE_TEST_VAR"] = "-2.5"
assert get_env_float("RAY_SERVE_TEST_VAR", 0.0) == -2.5
mock_environ["RAY_SERVE_TEST_VAR"] = "abc"
with pytest.raises(
ValueError, match=".*`abc` cannot be converted to `float`!*"
):
get_env_float("RAY_SERVE_TEST_VAR", 0.0)
def test_get_env_float_positive(self, mock_environ):
assert get_env_float_positive("RAY_SERVE_TEST_VAR", 1.5) == 1.5
assert get_env_float_positive("RAY_SERVE_TEST_VAR", None) is None
mock_environ["RAY_SERVE_TEST_VAR"] = "42.5"
assert get_env_float_positive("RAY_SERVE_TEST_VAR", 1.0) == 42.5
mock_environ["RAY_SERVE_TEST_VAR"] = "-1.2"
with pytest.raises(ValueError, match=".*Expected positive `float`.*"):
get_env_float_positive("RAY_SERVE_TEST_VAR", 5.0)
with pytest.raises(ValueError, match=".*Expected positive `float`.*"):
get_env_float_positive("RAY_SERVE_TEST_VAR_FROM_DEFAULT", 0.0)
with pytest.raises(ValueError, match=".*Expected positive `float`.*"):
get_env_float_positive("RAY_SERVE_TEST_VAR_FROM_DEFAULT", -1)
def test_get_env_float_non_negative(self, mock_environ):
assert get_env_float_non_negative("RAY_SERVE_TEST_VAR", 0.0) == 0.0
assert get_env_float_non_negative("RAY_SERVE_TEST_VAR", 1.5) == 1.5
mock_environ["RAY_SERVE_TEST_VAR"] = "42.5"
assert get_env_float_non_negative("RAY_SERVE_TEST_VAR", 0.0) == 42.5
mock_environ["RAY_SERVE_TEST_VAR"] = "-1.2"
with pytest.raises(ValueError, match=".*Expected non negative `float`.*"):
get_env_float_non_negative("RAY_SERVE_TEST_VAR", 5.0)
def test_get_env_str(self, mock_environ):
mock_environ["RAY_SERVE_TEST_STR"] = "hello"
assert get_env_str("RAY_SERVE_TEST_STR", "default") == "hello"
assert get_env_str("RAY_SERVE_NONEXISTENT_VAR", "default_str") == "default_str"
assert get_env_str("RAY_SERVE_NONEXISTENT_VAR", None) is None
def test_get_env_bool(self, mock_environ):
mock_environ["RAY_SERVE_TEST_BOOL_TRUE"] = "1"
assert get_env_bool("RAY_SERVE_TEST_BOOL_TRUE", "0") is True
# Test with any other value (False)
mock_environ["RAY_SERVE_TEST_BOOL_FALSE"] = "true"
assert get_env_bool("RAY_SERVE_TEST_BOOL_FALSE", "0") is False
mock_environ["RAY_SERVE_TEST_BOOL_FALSE2"] = "yes"
assert get_env_bool("RAY_SERVE_TEST_BOOL_FALSE2", "0") is False
# Test with default when environment variable not set
assert get_env_bool("RAY_SERVE_NONEXISTENT_VAR", "1") is True
assert get_env_bool("RAY_SERVE_NONEXISTENT_VAR", "0") is False
class TestValidation:
@pytest.mark.parametrize(
"name",
[
"RAY_SERVE_FOO",
"RAY_SERVE__DOUBLE_UNDERSCORE",
"RAY_SERVE_123",
"RAY_SERVE_VAR_NAME",
],
)
def test_validate_name_accepts_valid_prefix(self, name):
# Should not raise
assert _validate_name(name) is None
@pytest.mark.parametrize(
"name",
[
"",
"RAY_SERVE", # missing trailing underscore and name
"SERVE_VAR",
"ray_SERVE_BAR",
"RAY_service_VAR",
],
)
def test_validate_name_rejects_invalid_prefix(self, name):
with pytest.raises(ValueError, match=".*require prefix `RAY_SERVE_`*"):
_validate_name(name)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,720 @@
from copy import deepcopy
import pytest
from ray.serve._private.common import TargetCapacityDirection
from ray.serve._private.controller import (
applications_match,
calculate_target_capacity_direction,
)
from ray.serve._private.controller_health_metrics_tracker import (
_HEALTH_METRICS_HISTORY_SIZE,
ControllerHealthMetricsTracker,
)
from ray.serve.schema import (
ControllerHealthMetrics,
DurationStats,
HTTPOptionsSchema,
ServeApplicationSchema,
ServeDeploySchema,
)
def create_app_config(name: str) -> ServeApplicationSchema:
return ServeApplicationSchema(
name=name, import_path=f"fake.{name}", route_prefix=f"/{name}"
)
class TestApplicationsMatch:
def test_config_with_self(self):
config = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="app1",
import_path="fake.import",
route_prefix="/",
),
],
)
assert applications_match(config, config) is True
def test_configs_with_matching_app_names(self):
config1 = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="app1",
import_path="fake.import",
route_prefix="/app1",
),
ServeApplicationSchema(
name="app2",
import_path="fake.import2",
route_prefix="/app2",
),
ServeApplicationSchema(
name="app3",
import_path="fake.import3",
route_prefix="/app3",
),
],
)
# Configs contain apps with same name but different import paths.
config2 = deepcopy(config1)
config2.applications[0].import_path = "different_fake.import"
assert applications_match(config1, config2) is True
config2.applications[0].import_path = "extended.fake.import"
assert applications_match(config1, config2) is True
config2.applications[0].import_path = "fake:import"
assert applications_match(config1, config2) is True
# Configs contain apps with same name but different route_prefixes.
config2 = deepcopy(config1)
config2.applications[0].route_prefix += "_suffix"
assert applications_match(config1, config2) is True
# Configs contain apps with same name but different runtime_envs.
config2 = deepcopy(config1)
config2.applications[1].runtime_env = {"working_dir": "https://fake/uri"}
assert applications_match(config1, config2) is True
# Configs contain apps with same name but different target_capacities.
config2 = deepcopy(config1)
config2.target_capacity = 50
assert applications_match(config1, config2) is True
# Configs contain apps with same name but different http options.
config2 = deepcopy(config1)
config2.http_options = HTTPOptionsSchema(host="62.79.45.100")
assert applications_match(config1, config2) is True
def test_configs_with_different_app_names(self):
config1 = ServeDeploySchema(
applications=[
ServeApplicationSchema(
name="app1",
import_path="fake.import",
route_prefix="/app1",
),
ServeApplicationSchema(
name="app2",
import_path="fake.import2",
route_prefix="/app2",
),
ServeApplicationSchema(
name="app3",
import_path="fake.import3",
route_prefix="/app3",
),
],
)
# Configs contain apps with different names but same import paths.
config2 = deepcopy(config1)
config2.applications[0].name = "different_app1"
assert applications_match(config1, config2) is False
# Configs contain different number of apps.
config2 = deepcopy(config1)
config2.applications.pop()
assert applications_match(config1, config2) is False
class TestCalculateScaleDirection:
@pytest.mark.parametrize(
"curr_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN],
)
@pytest.mark.parametrize(
"new_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN],
)
def test_change_target_capacity_numeric(self, curr_direction, new_direction):
curr_target_capacity = 5
curr_config = ServeDeploySchema(
target_capacity=curr_target_capacity,
applications=[
create_app_config(name="app1"),
create_app_config(name="app2"),
],
)
if new_direction == TargetCapacityDirection.UP:
new_target_capacity = curr_target_capacity * 3.3
elif new_direction == TargetCapacityDirection.DOWN:
new_target_capacity = curr_target_capacity / 3.3
new_config = deepcopy(curr_config)
new_config.target_capacity = new_target_capacity
# The new direction must be returned, regardless of the current direction.
assert (
calculate_target_capacity_direction(
curr_config,
new_config,
curr_direction,
)
== new_direction
)
@pytest.mark.parametrize("target_capacity", [0, 50, 100, None])
@pytest.mark.parametrize(
"curr_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN, None],
)
def test_no_change_target_capacity(self, target_capacity, curr_direction):
"""When target_capacity doesn't change, return the current direction."""
if target_capacity is None:
# When target_capacity is None, the current direction must be None.
curr_direction = None
curr_config = ServeDeploySchema(
target_capacity=target_capacity,
applications=[
create_app_config(name="app1"),
create_app_config(name="app2"),
create_app_config(name="app3"),
],
)
assert (
calculate_target_capacity_direction(
curr_config,
curr_config,
curr_direction,
)
== curr_direction
)
@pytest.mark.parametrize("curr_target_capacity", [0, 50, 100, None])
@pytest.mark.parametrize(
"curr_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN, None],
)
def test_enter_null_target_capacity(self, curr_target_capacity, curr_direction):
"""When target capacity becomes null, scale up/down behavior must stop."""
if curr_target_capacity is None:
# When target_capacity is None, the current direction must be None.
curr_direction = None
curr_config = ServeDeploySchema(
target_capacity=curr_target_capacity,
applications=[
create_app_config(name="app1"),
create_app_config(name="app2"),
create_app_config(name="app3"),
],
)
new_config = deepcopy(curr_config)
new_config.target_capacity = None
assert (
calculate_target_capacity_direction(
curr_config,
new_config,
curr_direction,
)
is None
)
@pytest.mark.parametrize("new_target_capacity", [0, 50, 100])
def test_exit_null_target_capacity(self, new_target_capacity):
"""When target capacity goes null -> non-null, scale down must start."""
curr_config = ServeDeploySchema(
target_capacity=None,
applications=[create_app_config(name="app1")],
)
new_config = deepcopy(curr_config)
new_config.target_capacity = new_target_capacity
# When Serve is already running the applications at target_capacity
# None, and then a target_capacity is applied, the direction must
# become DOWN.
assert (
calculate_target_capacity_direction(
curr_config,
new_config,
None,
)
== TargetCapacityDirection.DOWN
)
def test_scale_up_first_config(self):
"""Check how Serve handles the first config that's applied."""
# Case 1: target_capacity is set. Serve should transition to scaling up.
new_config = ServeDeploySchema(
target_capacity=20,
applications=[create_app_config(name="app1")],
)
assert (
calculate_target_capacity_direction(
None,
new_config,
None,
)
== TargetCapacityDirection.UP
)
# Case 2: target_capacity is not set. Serve should not be scaling.
new_config = ServeDeploySchema(
target_capacity=None,
applications=[create_app_config(name="app1")],
)
assert (
calculate_target_capacity_direction(
None,
new_config,
None,
)
is None
)
@pytest.mark.parametrize("curr_target_capacity", [0, 50, 100, None])
@pytest.mark.parametrize(
"curr_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN, None],
)
def test_config_live_apps_mismatch(self, curr_target_capacity, curr_direction):
"""Apply a config with apps that don't match the live apps.
Serve should treat this like applying the first config. Its scaling
direction should not be based on the previous config's target_capacity.
"""
if curr_target_capacity is None:
# When target_capacity is None, the current direction must be None.
curr_direction = None
curr_config = ServeDeploySchema(
target_capacity=curr_target_capacity,
applications=[
create_app_config(name="app1"),
create_app_config(name="app2"),
create_app_config(name="app3"),
],
)
# Case 1: target_capacity is set. Serve should transition to scaling up.
new_config = ServeDeploySchema(
target_capacity=30,
applications=[
create_app_config(name="new_app1"),
create_app_config(name="app2"),
],
)
assert (
calculate_target_capacity_direction(
curr_config,
new_config,
curr_direction,
)
is TargetCapacityDirection.UP
)
# Case 2: target_capacity is not set. Serve should not be scaling.
new_config = ServeDeploySchema(
target_capacity=None,
applications=[
create_app_config(name="new_app1"),
create_app_config(name="app2"),
],
)
assert (
calculate_target_capacity_direction(
curr_config,
new_config,
curr_direction,
)
is None
)
class TestDurationStats:
"""Tests for DurationStats model."""
def test_empty_values(self):
"""Test statistics from empty list."""
stats = DurationStats.from_values([])
assert stats.mean == 0.0
assert stats.std == 0.0
assert stats.min == 0.0
assert stats.max == 0.0
def test_single_value(self):
"""Test statistics from single value."""
stats = DurationStats.from_values([5.0])
assert stats.mean == 5.0
assert stats.std == 0.0
assert stats.min == 5.0
assert stats.max == 5.0
def test_multiple_values(self):
"""Test statistics from multiple values."""
values = [1.0, 2.0, 3.0, 4.0, 5.0]
stats = DurationStats.from_values(values)
assert stats.mean == 3.0
assert stats.min == 1.0
assert stats.max == 5.0
# std for [1,2,3,4,5] with population std = sqrt(2)
assert abs(stats.std - 1.4142135623730951) < 0.0001
def test_dict_serialization(self):
"""Test that DurationStats serializes to dict."""
stats = DurationStats(mean=1.0, std=0.5, min=0.5, max=1.5)
result = stats.model_dump()
assert result == {"mean": 1.0, "std": 0.5, "min": 0.5, "max": 1.5}
class TestControllerHealthMetrics:
"""Tests for ControllerHealthMetrics dataclass."""
def test_default_values(self):
"""Test that all default values are initialized correctly."""
metrics = ControllerHealthMetrics()
assert metrics.timestamp == 0.0
assert metrics.controller_start_time == 0.0
assert metrics.uptime_s == 0.0
assert metrics.num_control_loops == 0
assert metrics.last_control_loop_time == 0.0
assert metrics.loop_duration_s is None
assert metrics.event_loop_delay_s == 0.0
assert metrics.num_asyncio_tasks == 0
assert metrics.process_memory_mb == 0.0
def test_model_dump(self):
"""Test serialization to dictionary."""
loop_stats = DurationStats(mean=0.3, std=0.1, min=0.1, max=0.5)
metrics = ControllerHealthMetrics(
timestamp=1000.0,
controller_start_time=900.0,
uptime_s=100.0,
num_control_loops=50,
loop_duration_s=loop_stats,
)
result = metrics.model_dump()
assert isinstance(result, dict)
assert result["timestamp"] == 1000.0
assert result["controller_start_time"] == 900.0
assert result["uptime_s"] == 100.0
assert result["num_control_loops"] == 50
assert result["loop_duration_s"]["mean"] == 0.3
def test_all_fields_in_model_dump(self):
"""Ensure model_dump() includes all fields."""
metrics = ControllerHealthMetrics()
result = metrics.model_dump()
expected_keys = [
"timestamp",
"controller_start_time",
"uptime_s",
"num_control_loops",
"last_control_loop_time",
"loop_duration_s",
"loops_per_second",
"last_sleep_duration_s",
"expected_sleep_duration_s",
"event_loop_delay_s",
"num_asyncio_tasks",
"deployment_state_update_duration_s",
"application_state_update_duration_s",
"proxy_state_update_duration_s",
"node_update_duration_s",
"handle_metrics_delay_ms",
"replica_metrics_delay_ms",
"process_memory_mb",
]
for key in expected_keys:
assert key in result, f"Missing key: {key}"
class TestCollectHealthMetrics:
"""Tests for the health metrics collection logic."""
def test_loop_statistics_computation(self):
"""Test that loop statistics are computed correctly from tracker data."""
tracker = ControllerHealthMetricsTracker()
# Record some loop durations
durations = [0.1, 0.2, 0.3, 0.4, 0.5]
for d in durations:
tracker.record_loop_duration(d)
# Verify tracker state
assert len(tracker.loop_durations) == 5
assert tracker.loop_durations[-1] == 0.5
# Collect metrics and verify DurationStats
metrics = tracker.collect_metrics()
assert metrics.loop_duration_s is not None
assert metrics.loop_duration_s.mean == 0.3
assert metrics.loop_duration_s.min == 0.1
assert metrics.loop_duration_s.max == 0.5
assert metrics.loop_duration_s.std > 0
def test_metrics_delay_statistics(self):
"""Test that metrics delay statistics are computed correctly."""
tracker = ControllerHealthMetricsTracker()
# Record handle metrics delays
handle_delays = [10.0, 20.0, 30.0, 40.0, 50.0]
for d in handle_delays:
tracker.record_handle_metrics_delay(d)
# Record replica metrics delays
replica_delays = [5.0, 15.0, 25.0, 35.0, 45.0]
for d in replica_delays:
tracker.record_replica_metrics_delay(d)
# Collect metrics and verify DurationStats
metrics = tracker.collect_metrics()
assert metrics.handle_metrics_delay_ms is not None
assert metrics.handle_metrics_delay_ms.mean == 30.0
assert metrics.handle_metrics_delay_ms.min == 10.0
assert metrics.handle_metrics_delay_ms.max == 50.0
assert metrics.replica_metrics_delay_ms is not None
assert metrics.replica_metrics_delay_ms.mean == 25.0
assert metrics.replica_metrics_delay_ms.min == 5.0
assert metrics.replica_metrics_delay_ms.max == 45.0
def test_empty_metrics_delays(self):
"""Test handling of empty metrics delay lists."""
tracker = ControllerHealthMetricsTracker()
# When no delays recorded, DurationStats should have zero values
metrics = tracker.collect_metrics()
assert metrics.handle_metrics_delay_ms is not None
assert metrics.handle_metrics_delay_ms.mean == 0.0
assert metrics.handle_metrics_delay_ms.max == 0.0
assert metrics.replica_metrics_delay_ms is not None
assert metrics.replica_metrics_delay_ms.mean == 0.0
assert metrics.replica_metrics_delay_ms.max == 0.0
def test_event_loop_delay_calculation(self):
"""Test event loop delay is calculated correctly."""
from ray.serve._private.constants import CONTROL_LOOP_INTERVAL_S
tracker = ControllerHealthMetricsTracker()
# Case 1: Sleep took longer than expected (overloaded)
tracker.last_sleep_duration_s = CONTROL_LOOP_INTERVAL_S + 0.5
delay = max(0.0, tracker.last_sleep_duration_s - CONTROL_LOOP_INTERVAL_S)
assert delay == 0.5
# Case 2: Sleep took expected time (healthy)
tracker.last_sleep_duration_s = CONTROL_LOOP_INTERVAL_S
delay = max(0.0, tracker.last_sleep_duration_s - CONTROL_LOOP_INTERVAL_S)
assert delay == 0.0
# Case 3: Sleep was shorter (shouldn't happen, but handle it)
tracker.last_sleep_duration_s = CONTROL_LOOP_INTERVAL_S - 0.1
delay = max(0.0, tracker.last_sleep_duration_s - CONTROL_LOOP_INTERVAL_S)
assert delay == 0.0
def test_loops_per_second_calculation(self):
"""Test loops per second calculation."""
import time
tracker = ControllerHealthMetricsTracker()
tracker.controller_start_time = time.time() - 10.0 # Started 10 seconds ago
tracker.num_control_loops = 5
now = time.time()
uptime = now - tracker.controller_start_time
loops_per_second = tracker.num_control_loops / uptime if uptime > 0 else 0.0
# Should be approximately 0.5 loops per second
assert 0.4 < loops_per_second < 0.6
def test_last_control_loop_time_propagated(self):
"""Test that last_control_loop_time on the tracker is propagated through
collect_metrics."""
tracker = ControllerHealthMetricsTracker()
# Default: tracker has not recorded a control loop yet.
metrics = tracker.collect_metrics()
assert metrics.last_control_loop_time == 0.0
# Set a specific timestamp and verify it is reflected in collected metrics.
tracker.last_control_loop_time = 12345.6789
metrics = tracker.collect_metrics()
assert metrics.last_control_loop_time == 12345.6789
# Update it again and verify the latest value is returned.
tracker.last_control_loop_time = 99999.0
metrics = tracker.collect_metrics()
assert metrics.last_control_loop_time == 99999.0
def test_component_update_durations_tracked(self):
"""Test that component update durations are tracked with DurationStats."""
tracker = ControllerHealthMetricsTracker()
# Record some component update durations
dsm_durations = [0.1, 0.2, 0.3, 0.4, 0.5]
asm_durations = [0.2, 0.3, 0.4, 0.5, 0.6]
proxy_durations = [0.3, 0.4, 0.5, 0.6, 0.7]
node_durations = [0.05, 0.06, 0.07, 0.08, 0.09]
for d in dsm_durations:
tracker.record_dsm_update_duration(d)
for d in asm_durations:
tracker.record_asm_update_duration(d)
for d in proxy_durations:
tracker.record_proxy_update_duration(d)
for d in node_durations:
tracker.record_node_update_duration(d)
# Collect metrics and verify DurationStats
metrics = tracker.collect_metrics()
assert metrics.deployment_state_update_duration_s is not None
assert metrics.deployment_state_update_duration_s.mean == 0.3
assert metrics.deployment_state_update_duration_s.min == 0.1
assert metrics.deployment_state_update_duration_s.max == 0.5
assert metrics.application_state_update_duration_s is not None
assert metrics.application_state_update_duration_s.mean == 0.4
assert metrics.application_state_update_duration_s.min == 0.2
assert metrics.application_state_update_duration_s.max == 0.6
assert metrics.proxy_state_update_duration_s is not None
assert metrics.proxy_state_update_duration_s.mean == 0.5
assert metrics.proxy_state_update_duration_s.min == 0.3
assert metrics.proxy_state_update_duration_s.max == 0.7
assert metrics.node_update_duration_s is not None
assert abs(metrics.node_update_duration_s.mean - 0.07) < 0.0001
assert metrics.node_update_duration_s.min == 0.05
assert metrics.node_update_duration_s.max == 0.09
class TestControllerHealthMetricsTracker:
"""Tests for ControllerHealthMetricsTracker."""
def test_record_loop_duration(self):
"""Test recording loop durations."""
tracker = ControllerHealthMetricsTracker()
tracker.record_loop_duration(0.5)
assert len(tracker.loop_durations) == 1
assert tracker.loop_durations[-1] == 0.5
tracker.record_loop_duration(0.3)
assert len(tracker.loop_durations) == 2
assert tracker.loop_durations[-1] == 0.3
def test_rolling_window_size(self):
"""Test that rolling window doesn't exceed max size."""
tracker = ControllerHealthMetricsTracker()
# Record more than the max history size
for i in range(_HEALTH_METRICS_HISTORY_SIZE + 50):
tracker.record_loop_duration(float(i))
assert len(tracker.loop_durations) == _HEALTH_METRICS_HISTORY_SIZE
# The oldest values should have been dropped
assert tracker.loop_durations[0] == 50.0
def test_record_handle_metrics_delay(self):
"""Test recording handle metrics delays."""
tracker = ControllerHealthMetricsTracker()
tracker.record_handle_metrics_delay(100.0)
assert len(tracker.handle_metrics_delays) == 1
assert tracker.handle_metrics_delays[-1] == 100.0
def test_record_replica_metrics_delay(self):
"""Test recording replica metrics delays."""
tracker = ControllerHealthMetricsTracker()
tracker.record_replica_metrics_delay(50.0)
assert len(tracker.replica_metrics_delays) == 1
assert tracker.replica_metrics_delays[-1] == 50.0
def test_multiple_delay_records(self):
"""Test recording multiple metrics delays."""
tracker = ControllerHealthMetricsTracker()
for i in range(10):
tracker.record_handle_metrics_delay(float(i * 10))
tracker.record_replica_metrics_delay(float(i * 5))
assert len(tracker.handle_metrics_delays) == 10
assert len(tracker.replica_metrics_delays) == 10
assert tracker.handle_metrics_delays[-1] == 90.0
assert tracker.replica_metrics_delays[-1] == 45.0
def test_record_dsm_update_duration(self):
"""Test recording deployment state manager update durations."""
tracker = ControllerHealthMetricsTracker()
tracker.record_dsm_update_duration(0.1)
assert len(tracker.dsm_update_durations) == 1
assert tracker.dsm_update_durations[-1] == 0.1
tracker.record_dsm_update_duration(0.2)
assert len(tracker.dsm_update_durations) == 2
assert tracker.dsm_update_durations[-1] == 0.2
def test_record_asm_update_duration(self):
"""Test recording application state manager update durations."""
tracker = ControllerHealthMetricsTracker()
tracker.record_asm_update_duration(0.15)
assert len(tracker.asm_update_durations) == 1
assert tracker.asm_update_durations[-1] == 0.15
def test_record_proxy_update_duration(self):
"""Test recording proxy state update durations."""
tracker = ControllerHealthMetricsTracker()
tracker.record_proxy_update_duration(0.25)
assert len(tracker.proxy_update_durations) == 1
assert tracker.proxy_update_durations[-1] == 0.25
def test_record_node_update_duration(self):
"""Test recording node update durations."""
tracker = ControllerHealthMetricsTracker()
tracker.record_node_update_duration(0.05)
assert len(tracker.node_update_durations) == 1
assert tracker.node_update_durations[-1] == 0.05
def test_component_duration_rolling_window(self):
"""Test that component duration rolling windows respect max size."""
tracker = ControllerHealthMetricsTracker()
# Record more than the max history size
for i in range(_HEALTH_METRICS_HISTORY_SIZE + 50):
tracker.record_dsm_update_duration(float(i))
assert len(tracker.dsm_update_durations) == _HEALTH_METRICS_HISTORY_SIZE
# The oldest values should have been dropped
assert tracker.dsm_update_durations[0] == 50.0
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
import pytest
from ray.serve._private.common import (
DeploymentID,
ReplicaID,
RequestProtocol,
RunningReplicaInfo,
)
from ray.serve._private.long_poll import LongPollNamespace
from ray.serve.schema import (
Target,
TargetGroup,
)
from ray.serve.tests.unit.test_controller_direct_ingress import (
FakeApplicationStateManager,
FakeDeploymentStateManager,
FakeDirectIngressController,
FakeKVStore,
FakeLongPollHost,
FakeProxyStateManager,
)
# Test Controller that overrides methods and dependencies for HAProxy testing
class FakeHAProxyController(FakeDirectIngressController):
def __init__(
self,
kv_store,
long_poll_host,
application_state_manager,
deployment_state_manager,
proxy_state_manager,
):
super().__init__(
kv_store=kv_store,
long_poll_host=long_poll_host,
application_state_manager=application_state_manager,
deployment_state_manager=deployment_state_manager,
proxy_state_manager=proxy_state_manager,
)
self._ha_proxy_enabled = True
@pytest.fixture
def haproxy_controller():
kv_store = FakeKVStore()
long_poll_host = FakeLongPollHost()
app_state_manager = FakeApplicationStateManager({}, {}, {})
deployment_state_manager = FakeDeploymentStateManager({})
proxy_state_manager = FakeProxyStateManager()
controller = FakeHAProxyController(
kv_store=kv_store,
long_poll_host=long_poll_host,
application_state_manager=app_state_manager,
deployment_state_manager=deployment_state_manager,
proxy_state_manager=proxy_state_manager,
)
yield controller
@pytest.mark.parametrize("from_proxy_manager", [True, False])
@pytest.mark.parametrize("ha_proxy_enabled", [True, False])
def test_get_target_groups_haproxy(
haproxy_controller: FakeHAProxyController,
from_proxy_manager: bool,
ha_proxy_enabled: bool,
):
"""Tests get_target_groups returns the appropriate target groups based on the
ha_proxy_enabled and from_proxy_manager parameters."""
haproxy_controller._ha_proxy_enabled = ha_proxy_enabled
# Setup test data with running applications
app_statuses = {"app1": {}}
route_prefixes = {"app1": "/app1"}
ingress_deployments = {"app1": "app1_ingress"}
deployment_id1 = DeploymentID(name="app1_ingress", app_name="app1")
# Create replica info
replica_id1 = ReplicaID(unique_id="replica1", deployment_id=deployment_id1)
replica_info1 = RunningReplicaInfo(
replica_id=replica_id1,
node_id="node1",
node_ip="10.0.0.1",
availability_zone="az1",
actor_name="replica1",
max_ongoing_requests=100,
)
running_replica_infos = {deployment_id1: [replica_info1]}
# Setup test application state manager
haproxy_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
haproxy_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
# Setup proxy state manager
haproxy_controller.proxy_state_manager.add_proxy_details(
"proxy_node1", "10.0.1.1", "proxy1"
)
haproxy_controller.proxy_state_manager.add_proxy_details(
"proxy_node2", "10.0.1.2", "proxy2"
)
# Allocate ports for replicas using controller's methods
http_port1 = haproxy_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.HTTP
)
grpc_port1 = haproxy_controller.allocate_replica_port(
"node1", replica_id1.unique_id, RequestProtocol.GRPC
)
target_groups = haproxy_controller.get_target_groups(
from_proxy_manager=from_proxy_manager
)
# Create expected target groups
if ha_proxy_enabled and not from_proxy_manager:
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/",
app_name="",
targets=[
Target(ip="10.0.1.1", port=8000, instance_id="", name="proxy1"),
Target(ip="10.0.1.2", port=8000, instance_id="", name="proxy2"),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/",
app_name="",
targets=[
Target(ip="10.0.1.1", port=9000, instance_id="", name="proxy1"),
Target(ip="10.0.1.2", port=9000, instance_id="", name="proxy2"),
],
),
]
else:
expected_target_groups = [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[
Target(
ip="10.0.0.1", port=http_port1, instance_id="", name="replica1"
),
],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[
Target(
ip="10.0.0.1", port=grpc_port1, instance_id="", name="replica1"
),
],
),
]
# Sort both lists to ensure consistent comparison
target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
expected_target_groups.sort(key=lambda g: (g.protocol, g.route_prefix))
assert target_groups == expected_target_groups
def test_get_target_groups_app_with_no_running_replicas(
haproxy_controller: FakeHAProxyController,
):
"""Tests get_target_groups returns the appropriate target groups when an app
has no running replicas."""
# Setup test data with running applications
app_statuses = {"app1": {}}
route_prefixes = {"app1": "/app1"}
ingress_deployments = {"app1": "app1_ingress"}
deployment_id1 = DeploymentID(name="app1_ingress", app_name="app1")
running_replica_infos = {deployment_id1: []}
# Setup test application state manager
haproxy_controller.application_state_manager = FakeApplicationStateManager(
app_statuses=app_statuses,
route_prefixes=route_prefixes,
ingress_deployments=ingress_deployments,
)
# Setup test deployment state manager
haproxy_controller.deployment_state_manager = FakeDeploymentStateManager(
running_replica_infos=running_replica_infos,
)
target_groups = haproxy_controller.get_target_groups(
from_proxy_manager=True,
)
assert target_groups == [
TargetGroup(
protocol=RequestProtocol.HTTP,
route_prefix="/app1",
app_name="app1",
targets=[],
),
TargetGroup(
protocol=RequestProtocol.GRPC,
route_prefix="/app1",
app_name="app1",
targets=[],
),
]
def test_broadcast_fallback_targets(
haproxy_controller: FakeHAProxyController,
):
"""Tests get_fallback_proxy_targets returns the appropriate fallback proxy targets."""
lph = haproxy_controller.long_poll_host
assert LongPollNamespace.FALLBACK_TARGETS not in lph.notified_changes
# Ensure there's no broadcast
haproxy_controller.broadcast_fallback_targets_if_changed()
assert LongPollNamespace.FALLBACK_TARGETS not in lph.notified_changes
# Add fallback proxy target
haproxy_controller.proxy_state_manager.add_fallback_proxy_target(
node_ip="10.0.0.1",
port=8500,
node_instance_id="instance1",
actor_name="proxy1",
protocol=RequestProtocol.HTTP,
)
# Ensure there is a broadcast
haproxy_controller.broadcast_fallback_targets_if_changed()
assert LongPollNamespace.FALLBACK_TARGETS in lph.notified_changes
assert lph.notified_changes[LongPollNamespace.FALLBACK_TARGETS] == {
RequestProtocol.HTTP: Target(
ip="10.0.0.1", port=8500, instance_id="instance1", name="proxy1"
),
}
# Change the fallback proxy target
haproxy_controller.proxy_state_manager.add_fallback_proxy_target(
node_ip="10.0.0.2",
port=8500,
node_instance_id="instance1",
actor_name="proxy1",
protocol=RequestProtocol.HTTP,
)
# Ensure there is a broadcast
haproxy_controller.broadcast_fallback_targets_if_changed()
assert LongPollNamespace.FALLBACK_TARGETS in lph.notified_changes
assert lph.notified_changes[LongPollNamespace.FALLBACK_TARGETS] == {
RequestProtocol.HTTP: Target(
ip="10.0.0.2", port=8500, instance_id="instance1", name="proxy1"
),
}
# Add fallback proxy target
haproxy_controller.proxy_state_manager.add_fallback_proxy_target(
node_ip="10.0.0.1",
port=9500,
node_instance_id="instance1",
actor_name="proxy1",
protocol=RequestProtocol.GRPC,
)
# Ensure there's another broadcast
haproxy_controller.broadcast_fallback_targets_if_changed()
assert LongPollNamespace.FALLBACK_TARGETS in lph.notified_changes
assert lph.notified_changes[LongPollNamespace.FALLBACK_TARGETS] == {
RequestProtocol.HTTP: Target(
ip="10.0.0.2", port=8500, instance_id="instance1", name="proxy1"
),
RequestProtocol.GRPC: Target(
ip="10.0.0.1", port=9500, instance_id="instance1", name="proxy1"
),
}
if __name__ == "__main__":
pytest.main()
@@ -0,0 +1,261 @@
import itertools
import random
import sys
from typing import Dict, List
import pytest
from ray import serve
from ray.serve._private.config import DeploymentConfig
def get_random_dict_combos(d: Dict, n: int) -> List[Dict]:
"""Gets n random combinations of dictionary d.
Args:
d: The source dictionary to draw combinations from.
n: The maximum number of combinations to return.
Returns:
List of dictionary combinations of lengths from 0 to len(d). List
contains n random combinations of d's elements.
"""
# Shuffle dictionary without modifying original dictionary
d = dict(random.sample(list(d.items()), len(d)))
combos = []
# Sample random combos of random size
subset_sizes = list(range(len(d) + 1))
random.shuffle(subset_sizes)
for subset_size in subset_sizes:
subset_combo_iterator = map(
dict, itertools.combinations(d.items(), subset_size)
)
if len(combos) < n:
subset_combos = list(
itertools.islice(subset_combo_iterator, n - len(combos))
)
combos.extend(subset_combos)
else:
break
return combos
class TestGetDictCombos:
def test_empty(self):
assert get_random_dict_combos({}, 1) == [{}]
def test_basic(self):
d = {"a": 1, "b": 2, "c": 3}
combos = get_random_dict_combos(d, 8)
# Sort combos for comparison (sort by length, break ties by value sum)
combos.sort(key=lambda d: len(d) * 100 + sum(d.values()))
assert combos == [
# Dictionaries of length 0
{},
# Dictionaries of length 1
*({"a": 1}, {"b": 2}, {"c": 3}),
# Dictionaries of length 2
*({"a": 1, "b": 2}, {"a": 1, "c": 3}, {"b": 2, "c": 3}),
# Dictionaries of length 3
{"a": 1, "b": 2, "c": 3},
]
def test_len(self):
d = {i: i + 1 for i in range(50)}
assert len(get_random_dict_combos(d, 1000)) == 1000
def test_randomness(self):
d = {i: i + 1 for i in range(1000)}
combo1 = get_random_dict_combos(d, 1000)[0]
combo2 = get_random_dict_combos(d, 1000)[0]
assert combo1 != combo2
class TestDeploymentOptions:
# Deployment options mapped to sample input
deployment_options = {
"name": "test",
"num_replicas": 1,
"ray_actor_options": {},
"user_config": {},
"max_ongoing_requests": 10,
"autoscaling_config": None,
"graceful_shutdown_wait_loop_s": 10,
"graceful_shutdown_timeout_s": 10,
"health_check_period_s": 10,
"health_check_timeout_s": 10,
}
deployment_option_combos = get_random_dict_combos(deployment_options, 1000)
@pytest.mark.parametrize("options", deployment_option_combos)
def test_user_configured_option_names(self, options: Dict):
"""Check that user_configured_option_names tracks the correct options.
Args:
options: Maps deployment option strings (e.g. "name",
"num_replicas", etc.) to sample inputs. Pairs come from
TestDeploymentOptions.deployment_options.
"""
@serve.deployment(**options)
def f():
pass
assert f._deployment_config.user_configured_option_names == set(options.keys())
@pytest.mark.parametrize("options", deployment_option_combos)
def test_user_configured_option_names_serialized(self, options: Dict):
"""Check user_configured_option_names after serialization.
Args:
options: Maps deployment option strings (e.g. "name",
"num_replicas", etc.) to sample inputs. Pairs come from
TestDeploymentOptions.deployment_options.
"""
# init_kwargs requires independent serialization, so we omit it.
if "init_kwargs" in options:
del options["init_kwargs"]
@serve.deployment(**options)
def f():
pass
serialized_config = f._deployment_config.to_proto_bytes()
deserialized_config = DeploymentConfig.from_proto_bytes(serialized_config)
assert deserialized_config.user_configured_option_names == set(options.keys())
@pytest.mark.parametrize(
"option",
[
"num_replicas",
"autoscaling_config",
"user_config",
],
)
def test_nullable_options(self, option: str):
"""Check that nullable options can be set to None."""
deployment_options = {option: None}
# One of "num_replicas" or "autoscaling_config" must be provided.
if option == "num_replicas":
deployment_options["autoscaling_config"] = {
"min_replicas": 1,
"max_replicas": 5,
"target_ongoing_requests": 5,
}
elif option == "autoscaling_config":
deployment_options["num_replicas"] = 5
# Deployment should be created without error.
@serve.deployment(**deployment_options)
def f():
pass
@pytest.mark.parametrize("options", deployment_option_combos)
def test_options(self, options):
"""Check that updating options also updates user_configured_options_names."""
@serve.deployment
def f():
pass
f = f.options(**options)
assert f._deployment_config.user_configured_option_names == set(options.keys())
def test_deployment_decorator_version_removed(self):
with pytest.raises(
ValueError,
match=r"`version` in `@serve\.deployment` has been removed",
):
@serve.deployment(version="abcd")
def f():
pass
def test_deployment_options_version_removed(self):
@serve.deployment
def f():
pass
with pytest.raises(
ValueError,
match=r"`version` in `Deployment\.options\(\)` has been removed",
):
f.options(version="abcd")
def test_deployment_route_prefix_removed(self):
@serve.deployment
def f():
pass
assert not hasattr(f, "route_prefix")
with pytest.raises(AttributeError):
_ = f.route_prefix
with pytest.raises(TypeError, match="route_prefix"):
@serve.deployment(route_prefix="/prefix")
def g():
pass
with pytest.raises(TypeError, match="route_prefix"):
f.options(route_prefix="/prefix")
def test_eager_placement_group_validation(self):
"""Check that placement groups are validated early.
Placement group bundles should be validated when the deployment is
defined, not when it's deployed.
"""
with pytest.raises(ValueError):
# PG bundle with empty resources is invalid.
@serve.deployment(
placement_group_bundles=[{"CPU": 0, "GPU": 0}],
ray_actor_options={"num_cpus": 0, "num_gpus": 0},
)
def f():
pass
def test_deployment_url_removed(self):
@serve.deployment
def f():
pass
assert not hasattr(f, "url")
with pytest.raises(AttributeError):
_ = f.url
def test_placement_group_strategy_without_bundles(self):
"""Check that specifying strategy requires also specifying bundles."""
with pytest.raises(ValueError):
# PG strategy without bundles is invalid.
@serve.deployment(placement_group_strategy="PACK")
def f():
pass
# PG strategy with bundles is valid.
@serve.deployment(
placement_group_strategy="PACK",
placement_group_bundles=[{"CPU": 10}],
)
def g():
pass
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,89 @@
from typing import Optional
from unittest.mock import patch
import pytest
from ray.serve._private.common import TargetCapacityDirection
from ray.serve._private.config import DeploymentConfig, ReplicaConfig
from ray.serve._private.deployment_info import DeploymentInfo
@patch("ray.serve._private.deployment_info.ray.get_runtime_context")
@pytest.mark.parametrize("target_capacity", [0, 25.809, 73, 100.0, None])
@pytest.mark.parametrize(
"target_capacity_direction",
[TargetCapacityDirection.UP, TargetCapacityDirection.DOWN, None],
)
def test_deployment_info_serialization(
mock_runtime_context,
target_capacity: Optional[float],
target_capacity_direction: Optional[TargetCapacityDirection],
):
"""Checks that deploymet infos can be serialized without losing data."""
# Mock out the runtime_context call, so Ray doesn't start.
class MockRuntimeContext:
def get_job_id(self) -> str:
return ""
mock_runtime_context.return_value = MockRuntimeContext()
def compare_replica_configs(config1: ReplicaConfig, config2: ReplicaConfig):
"""Check whether two replica configs contain the same data."""
assert config1.serialized_deployment_def == config2.serialized_deployment_def
assert config1.serialized_init_args == config2.serialized_init_args
assert config1.serialized_init_kwargs == config2.serialized_init_kwargs
assert config1.ray_actor_options == config2.ray_actor_options
assert config1.placement_group_bundles == config2.placement_group_bundles
assert config1.placement_group_strategy == config2.placement_group_strategy
assert config1.max_replicas_per_node == config2.max_replicas_per_node
def compare_deployment_info(info1: DeploymentInfo, info2: DeploymentInfo):
"""Checks that two deployment infos contain the same data.
Does not compare job_ids because those are mocked out to avoid starting
Ray
"""
assert info1.version == info2.version
assert info1.deployment_config == info2.deployment_config
compare_replica_configs(info1.replica_config, info2.replica_config)
assert info1.start_time_ms == info2.start_time_ms
assert info1.target_capacity == info2.target_capacity
assert info1.target_capacity_direction == info2.target_capacity_direction
deployment_info_args = dict(
version="123",
deployment_config=DeploymentConfig(num_replicas=1),
replica_config=ReplicaConfig.create(lambda x: x),
start_time_ms=0,
deployer_job_id="",
)
# Check that serialization works correctly when the DeploymentInfo is
# initialized with the target_capacity info directly.
info = DeploymentInfo(
target_capacity=target_capacity,
target_capacity_direction=target_capacity_direction,
**deployment_info_args
)
serialized_info = info.to_proto()
reconstructed_info = DeploymentInfo.from_proto(serialized_info)
compare_deployment_info(reconstructed_info, info)
# Check that serialization works correctly when the DeploymentInfo's
# target_capacity has been set using set_target_capacity().
info = DeploymentInfo(**deployment_info_args)
info.set_target_capacity(
new_target_capacity=target_capacity,
new_target_capacity_direction=target_capacity_direction,
)
serialized_info = info.to_proto()
reconstructed_info = DeploymentInfo.from_proto(serialized_info)
compare_deployment_info(reconstructed_info, info)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,412 @@
import pytest
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.deployment_state import DeploymentVersion
def test_validation():
# Code version must be a string.
with pytest.raises(TypeError):
DeploymentVersion(123, DeploymentConfig(), {})
def test_other_type_equality():
v = DeploymentVersion("1", DeploymentConfig(), {})
assert v is not None
assert v != "1"
assert v != None # noqa: E711
def test_code_version():
v1 = DeploymentVersion("1", DeploymentConfig(), {})
v2 = DeploymentVersion("1", DeploymentConfig(), {})
v3 = DeploymentVersion("2", DeploymentConfig(), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_deployment_config_basic():
v1 = DeploymentVersion("1", DeploymentConfig(user_config="1"), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config="1"), {})
v3 = DeploymentVersion("1", DeploymentConfig(user_config="2"), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_hashable():
v1 = DeploymentVersion("1", DeploymentConfig(user_config=("1", "2")), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config=("1", "2")), {})
v3 = DeploymentVersion("1", DeploymentConfig(user_config=("1", "3")), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_list():
v1 = DeploymentVersion("1", DeploymentConfig(user_config=["1", "2"]), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config=["1", "2"]), {})
v3 = DeploymentVersion("1", DeploymentConfig(user_config=["1", "3"]), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_dict_keys():
v1 = DeploymentVersion("1", DeploymentConfig(user_config={"1": "1"}), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config={"1": "1"}), {})
v3 = DeploymentVersion("1", DeploymentConfig(user_config={"2": "1"}), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_dict_vals():
v1 = DeploymentVersion("1", DeploymentConfig(user_config={"1": "1"}), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config={"1": "1"}), {})
v3 = DeploymentVersion("1", DeploymentConfig(user_config={"1": "2"}), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_nested():
v1 = DeploymentVersion(
"1", DeploymentConfig(user_config=[{"1": "2"}, {"1": "2"}]), {}
)
v2 = DeploymentVersion(
"1", DeploymentConfig(user_config=[{"1": "2"}, {"1": "2"}]), {}
)
v3 = DeploymentVersion(
"1", DeploymentConfig(user_config=[{"1": "2"}, {"1": "3"}]), {}
)
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_user_config_nested_in_hashable():
v1 = DeploymentVersion(
"1", DeploymentConfig(user_config=([{"1": "2"}, {"1": "2"}])), {}
)
v2 = DeploymentVersion(
"1", DeploymentConfig(user_config=([{"1": "2"}, {"1": "2"}])), {}
)
v3 = DeploymentVersion(
"1", DeploymentConfig(user_config=([{"1": "2"}, {"1": "3"}])), {}
)
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_num_replicas():
v1 = DeploymentVersion("1", DeploymentConfig(num_replicas=1), {})
v2 = DeploymentVersion("1", DeploymentConfig(num_replicas=2), {})
assert v1 == v2
assert hash(v1) == hash(v2)
def test_autoscaling_config():
v1 = DeploymentVersion(
"1",
DeploymentConfig(
autoscaling_config={"max_replicas": 2, "metrics_interval_s": 10}
),
{},
)
v2 = DeploymentVersion(
"1",
DeploymentConfig(
autoscaling_config={"max_replicas": 5, "metrics_interval_s": 10}
),
{},
)
v3 = DeploymentVersion(
"1",
DeploymentConfig(
autoscaling_config={"max_replicas": 2, "metrics_interval_s": 3}
),
{},
)
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_max_ongoing_requests():
v1 = DeploymentVersion("1", DeploymentConfig(max_ongoing_requests=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(max_ongoing_requests=5), {})
v3 = DeploymentVersion("1", DeploymentConfig(max_ongoing_requests=10), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert not v1.requires_actor_reconfigure(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
assert v1.requires_actor_reconfigure(v3)
def test_health_check_period_s():
v1 = DeploymentVersion("1", DeploymentConfig(health_check_period_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(health_check_period_s=5), {})
v3 = DeploymentVersion("1", DeploymentConfig(health_check_period_s=10), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_health_check_timeout_s():
v1 = DeploymentVersion("1", DeploymentConfig(health_check_timeout_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(health_check_timeout_s=5), {})
v3 = DeploymentVersion("1", DeploymentConfig(health_check_timeout_s=10), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_graceful_shutdown_timeout_s():
v1 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=5), {})
v3 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=10), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_graceful_shutdown_wait_loop_s():
v1 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_wait_loop_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_wait_loop_s=5), {})
v3 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_wait_loop_s=10), {})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_ray_actor_options():
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v3 = DeploymentVersion("1", DeploymentConfig(), {"num_gpus": 0.1})
assert v1 == v2
assert hash(v1) == hash(v2)
assert v1 != v3
assert hash(v1) != hash(v3)
def test_max_replicas_per_node():
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
max_replicas_per_node=1,
)
v3 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
max_replicas_per_node=1,
)
v4 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
max_replicas_per_node=2,
)
assert v1 != v2
assert hash(v1) != hash(v2)
assert v1.requires_actor_restart(v2)
assert v2 == v3
assert hash(v2) == hash(v3)
assert not v2.requires_actor_restart(v3)
assert v3 != v4
assert hash(v3) != hash(v4)
assert v3.requires_actor_restart(v4)
def test_placement_group_options():
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.1}],
)
v3 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.1}],
)
v4 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"GPU": 0.1}],
)
assert v1 != v2
assert hash(v1) != hash(v2)
assert v1.requires_actor_restart(v2)
assert v2 == v3
assert hash(v2) == hash(v3)
assert not v2.requires_actor_restart(v3)
assert v3 != v4
assert hash(v3) != hash(v4)
assert v3.requires_actor_restart(v4)
v5 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.1}],
placement_group_strategy="PACK",
)
v6 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.1}],
placement_group_strategy="PACK",
)
v7 = DeploymentVersion(
"1",
DeploymentConfig(),
{"num_cpus": 0.1},
placement_group_bundles=[{"CPU": 0.1}],
placement_group_strategy="SPREAD",
)
assert v5 == v6
assert hash(v5) == hash(v6)
assert not v5.requires_actor_restart(v6)
assert v6 != v7
assert hash(v6) != hash(v7)
assert v6.requires_actor_restart(v7)
def test_requires_actor_restart():
# Code version different
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion("2", DeploymentConfig(), {"num_cpus": 0.1})
assert v1.requires_actor_restart(v2)
# Runtime env different
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.2})
assert v1.requires_actor_restart(v2)
# Placement group bundles different
v1 = DeploymentVersion(
"1", DeploymentConfig(), {}, placement_group_bundles=[{"CPU": 0.1}]
)
v2 = DeploymentVersion(
"1", DeploymentConfig(), {}, placement_group_bundles=[{"CPU": 0.2}]
)
assert v1.requires_actor_restart(v2)
# Placement group strategy different
v1 = DeploymentVersion("1", DeploymentConfig(), {}, placement_group_strategy="PACK")
v2 = DeploymentVersion(
"1", DeploymentConfig(), {}, placement_group_strategy="SPREAD"
)
assert v1.requires_actor_restart(v2)
# Both code version and runtime env different
v1 = DeploymentVersion("1", DeploymentConfig(), {"num_cpus": 0.1})
v2 = DeploymentVersion("2", DeploymentConfig(), {"num_cpus": 0.2})
assert v1.requires_actor_restart(v2)
# Num replicas is different
v1 = DeploymentVersion("1", DeploymentConfig(num_replicas=1), {})
v2 = DeploymentVersion("1", DeploymentConfig(num_replicas=2), {})
assert not v1.requires_actor_restart(v2)
# Graceful shutdown timeout is different
v1 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=10), {})
assert not v1.requires_actor_restart(v2)
def test_requires_actor_reconfigure():
# Replicas need the updated user config to call the user-defined
# reconfigure method
v1 = DeploymentVersion("1", DeploymentConfig(user_config=1), {})
v2 = DeploymentVersion("1", DeploymentConfig(user_config=2), {})
assert v1.requires_actor_reconfigure(v2)
# Graceful shutdown loop requires actor reconfigure, since the
# replica needs the updated value to correctly execute graceful
# shutdown.
v1 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_wait_loop_s=1), {})
v2 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_wait_loop_s=2), {})
assert v1.requires_actor_reconfigure(v2)
# Graceful shutdown timeout shouldn't require actor reconfigure, as
# it's only used by the controller to decide when to force-kill a
# replica
v1 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(graceful_shutdown_timeout_s=10), {})
assert not v1.requires_actor_reconfigure(v2)
# Num replicas shouldn't require actor reconfigure, as it's only
# by the controller to decide when to start or stop replicas.
v1 = DeploymentVersion("1", DeploymentConfig(num_replicas=1), {})
v2 = DeploymentVersion("1", DeploymentConfig(num_replicas=2), {})
assert not v1.requires_actor_reconfigure(v2)
def test_requires_long_poll_broadcast():
# If max concurrent queries is updated, it needs to be broadcasted
# to all routers.
v1 = DeploymentVersion("1", DeploymentConfig(max_ongoing_requests=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(max_ongoing_requests=10), {})
assert v1.requires_long_poll_broadcast(v2)
# Something random like health check timeout doesn't require updating
# any info on routers.
v1 = DeploymentVersion("1", DeploymentConfig(health_check_timeout_s=5), {})
v2 = DeploymentVersion("1", DeploymentConfig(health_check_timeout_s=10), {})
assert not v1.requires_long_poll_broadcast(v2)
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,66 @@
import sys
from typing import Any, Tuple
from unittest.mock import Mock, patch
import pytest
from ray.serve._private.endpoint_state import EndpointState
class MockKVStore:
def __init__(self):
self.store = dict()
def put(self, key: str, val: Any) -> bool:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
self.store[key] = val
return True
def get(self, key: str) -> Any:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
return self.store.get(key, None)
def delete(self, key: str) -> bool:
if not isinstance(key, str):
raise TypeError("key must be a string, got: {}.".format(type(key)))
if key in self.store:
del self.store[key]
return True
return False
@pytest.fixture
def mock_endpoint_state() -> Tuple[EndpointState, Mock]:
with patch("ray.serve._private.long_poll.LongPollHost") as mock_long_poll:
endpoint_state = EndpointState(
kv_store=MockKVStore(),
long_poll_host=mock_long_poll,
)
yield endpoint_state
def test_is_ready_for_shutdown(mock_endpoint_state):
"""Test `is_ready_for_shutdown()` returns the correct state.
Before shutting down endpoint `is_ready_for_shutdown()` should return False.
After shutting down endpoint `is_ready_for_shutdown()` should return True.
"""
# Setup endpoint state with checkpoint
endpoint_state = mock_endpoint_state
endpoint_state._checkpoint()
# Before shutdown is called, `is_ready_for_shutdown()` should return False
assert not endpoint_state.is_ready_for_shutdown()
endpoint_state.shutdown()
# After shutdown is called, `is_ready_for_shutdown()` should return True
assert endpoint_state.is_ready_for_shutdown()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,374 @@
"""Unit tests for extract_route_patterns function."""
import pytest
from fastapi import APIRouter, FastAPI
from starlette.applications import Starlette
from starlette.routing import Mount, Route
from ray.serve._private.thirdparty.get_asgi_route_name import (
extract_route_patterns,
)
def has_path(patterns, path):
"""Helper to check if a path exists in patterns list."""
return any(pattern.path == path for pattern in patterns)
def get_methods_for_path(patterns, path):
"""Helper to get methods for a specific path."""
for pattern in patterns:
if pattern.path == path:
return pattern.methods
return None
def test_extract_route_patterns_fastapi_simple():
"""Test extracting route patterns from a simple FastAPI app."""
app = FastAPI()
@app.get("/")
def root():
return {"message": "root"}
@app.get("/users/{user_id}")
def get_user(user_id: str):
return {"user_id": user_id}
@app.post("/items/{item_id}")
def create_item(item_id: str):
return {"item_id": item_id}
patterns = extract_route_patterns(app)
# FastAPI automatically adds some default routes
assert has_path(patterns, "/")
assert has_path(patterns, "/users/{user_id}")
assert has_path(patterns, "/items/{item_id}")
# FastAPI adds OpenAPI routes
assert has_path(patterns, "/openapi.json")
assert has_path(patterns, "/docs")
def test_extract_route_patterns_nested_paths():
"""Test extracting nested parameterized routes."""
app = FastAPI()
@app.get("/api/v1/users/{user_id}/posts/{post_id}")
def get_post(user_id: str, post_id: str):
return {"user_id": user_id, "post_id": post_id}
@app.get("/api/v1/users/{user_id}/settings")
def get_settings(user_id: str):
return {"user_id": user_id}
patterns = extract_route_patterns(app)
assert has_path(patterns, "/api/v1/users/{user_id}/posts/{post_id}")
assert has_path(patterns, "/api/v1/users/{user_id}/settings")
def test_extract_route_patterns_with_mounts():
"""Test extracting route patterns from apps with mounted sub-apps."""
# Create a sub-app
sub_app = Starlette(
routes=[
Route("/health", lambda request: None),
Route("/status", lambda request: None),
]
)
# Create main app with mounted sub-app
app = Starlette(
routes=[
Route("/", lambda request: None),
Mount("/admin", app=sub_app),
]
)
patterns = extract_route_patterns(app)
assert has_path(patterns, "/")
assert has_path(patterns, "/admin/health")
assert has_path(patterns, "/admin/status")
def test_extract_route_patterns_included_router():
"""Routes registered via `include_router` must be extracted.
On FastAPI >= 0.137 these routes are nested under an `_IncludedRouter` node
instead of being flattened into `app.routes` (see #64475).
"""
app = FastAPI()
@app.get("/direct")
def direct():
return {}
# Router with its own prefix, included without an include-time prefix.
router = APIRouter(prefix="/prefix")
@router.get("/routed/{user_id}")
def routed():
return {}
@router.websocket("/ws")
async def ws():
pass
# Router included with an include-time prefix.
other = APIRouter()
@other.post("/create")
def create():
return {}
app.include_router(router)
app.include_router(other, prefix="/other")
patterns = extract_route_patterns(app)
assert has_path(patterns, "/direct")
assert has_path(patterns, "/prefix/routed/{user_id}")
assert get_methods_for_path(patterns, "/prefix/routed/{user_id}") == ["GET"]
assert has_path(patterns, "/other/create")
assert get_methods_for_path(patterns, "/other/create") == ["POST"]
# WebSocket routes have no method restrictions.
assert has_path(patterns, "/prefix/ws")
assert get_methods_for_path(patterns, "/prefix/ws") is None
def test_extract_route_patterns_nested_mounts():
"""Test extracting patterns from deeply nested mounts."""
# Innermost app
inner_app = Starlette(
routes=[
Route("/details", lambda request: None),
]
)
# Middle app
middle_app = Starlette(
routes=[
Route("/list", lambda request: None),
Mount("/item", app=inner_app),
]
)
# Main app
app = Starlette(
routes=[
Route("/", lambda request: None),
Mount("/api/v1", app=middle_app),
]
)
patterns = extract_route_patterns(app)
assert has_path(patterns, "/")
assert has_path(patterns, "/api/v1/list")
assert has_path(patterns, "/api/v1/item/details")
def test_extract_route_patterns_with_root_path():
"""Test extracting patterns from apps with root_path set."""
app = FastAPI(root_path="/v1")
@app.get("/")
def root():
return {}
@app.get("/users")
def get_users():
return []
@app.get("/items/{item_id}")
def get_item(item_id: str):
return {"item_id": item_id}
patterns = extract_route_patterns(app)
# Root path should be prepended to all routes
assert has_path(patterns, "/v1/") # Root route
assert has_path(patterns, "/v1/users")
assert has_path(patterns, "/v1/items/{item_id}")
def test_extract_route_patterns_empty_app():
"""Test extracting patterns from an app with no user-defined routes."""
app = FastAPI()
# Don't define any routes
patterns = extract_route_patterns(app)
# Should still have FastAPI defaults
assert has_path(patterns, "/openapi.json")
assert has_path(patterns, "/docs")
# May or may not have "/" depending on FastAPI version
def test_extract_route_patterns_starlette():
"""Test extracting patterns from a pure Starlette app."""
async def homepage(request):
return None
async def user_detail(request):
return None
app = Starlette(
routes=[
Route("/", homepage),
Route("/users/{user_id}", user_detail),
]
)
patterns = extract_route_patterns(app)
assert has_path(patterns, "/")
assert has_path(patterns, "/users/{user_id}")
# Starlette shouldn't have OpenAPI routes
assert not has_path(patterns, "/openapi.json")
def test_extract_route_patterns_multiple_methods_same_path():
"""Test that methods are grouped when multiple methods use same path."""
app = FastAPI()
@app.get("/items/{item_id}")
def get_item(item_id: str):
return {"item_id": item_id}
@app.put("/items/{item_id}")
def update_item(item_id: str):
return {"item_id": item_id}
@app.delete("/items/{item_id}")
def delete_item(item_id: str):
return {"item_id": item_id}
patterns = extract_route_patterns(app)
# Path should appear only once with all methods grouped
path_count = sum(1 for pattern in patterns if pattern.path == "/items/{item_id}")
assert path_count == 1
# Check that all methods are present
methods = get_methods_for_path(patterns, "/items/{item_id}")
assert methods is not None
assert "GET" in methods
assert "PUT" in methods
assert "DELETE" in methods
def test_extract_route_patterns_invalid_app():
"""Test that invalid apps return empty list gracefully."""
class FakeApp:
"""An app without routes attribute."""
pass
fake_app = FakeApp()
# Should return empty list without raising exception
patterns = extract_route_patterns(fake_app)
assert patterns == []
def test_extract_route_patterns_mount_without_routes():
"""Test handling mounts that don't have sub-routes."""
from starlette.responses import PlainTextResponse
async def custom_mount(scope, receive, send):
response = PlainTextResponse("Custom mount")
await response(scope, receive, send)
app = Starlette(
routes=[
Route("/", lambda request: None),
Mount("/custom", app=custom_mount),
]
)
patterns = extract_route_patterns(app)
assert has_path(patterns, "/")
assert has_path(patterns, "/custom")
# Custom mount has no method restrictions
assert get_methods_for_path(patterns, "/custom") is None
def test_extract_route_patterns_sorted_output():
"""Test that output is sorted by path."""
app = FastAPI()
@app.get("/zebra")
def zebra():
return {}
@app.get("/apple")
def apple():
return {}
@app.get("/banana")
def banana():
return {}
patterns = extract_route_patterns(app)
# Extract just the paths
paths = [pattern.path for pattern in patterns]
# Find the user-defined routes
user_routes = [p for p in paths if p in ["/zebra", "/apple", "/banana"]]
# Should be sorted
assert user_routes == ["/apple", "/banana", "/zebra"]
def test_extract_route_patterns_special_characters():
"""Test routes with special regex characters."""
app = FastAPI()
@app.get("/users/{user_id:path}")
def get_user_path(user_id: str):
return {"user_id": user_id}
@app.get("/items/{item_id:int}")
def get_item_int(item_id: int):
return {"item_id": item_id}
patterns = extract_route_patterns(app)
# Extract just the paths
paths = [pattern.path for pattern in patterns]
# FastAPI converts these to standard patterns
assert any("user_id" in p for p in paths)
assert any("item_id" in p for p in paths)
def test_extract_route_patterns_websocket_routes():
"""Test that WebSocket routes are also extracted."""
app = FastAPI()
@app.get("/http")
def http_route():
return {}
@app.websocket("/ws")
async def websocket_route(websocket):
await websocket.accept()
await websocket.close()
patterns = extract_route_patterns(app)
assert has_path(patterns, "/http")
assert has_path(patterns, "/ws")
# WebSocket route should have no method restrictions
assert get_methods_for_path(patterns, "/ws") is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,286 @@
import sys
import pytest
from fastapi import APIRouter, FastAPI
from ray.serve._private.thirdparty.get_asgi_route_name import get_asgi_route_name
def test_single_path():
app = FastAPI()
@app.get("/")
def root():
pass
# Match.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) == "/"
)
# Mismatched subpath.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/subpath"})
is None
)
def test_methods():
app = FastAPI()
@app.get("/")
def root_get():
pass
@app.post("/")
def root_post():
pass
# Match GET.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) == "/"
)
# Match POST.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) == "/"
)
# Missing PUT.
assert (
get_asgi_route_name(app, {"type": "http", "method": "PUT", "path": "/"}) is None
)
def test_subpath():
app = FastAPI()
@app.get("/subpath")
def subpath():
pass
# Match.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/subpath"})
== "/subpath"
)
# Missing subpath.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) is None
)
def test_wildcard():
app = FastAPI()
@app.get("/{user_id}")
def dynamic_subpath():
pass
# Match.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/abc123"})
== "/{user_id}"
)
# Missing subpath.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) is None
)
def test_mounted_app():
app = FastAPI()
@app.get("/")
def root():
pass
mounted_app = FastAPI()
@mounted_app.get("/")
def mounted_root():
pass
@mounted_app.get("/subpath")
def mounted_subpath():
pass
@mounted_app.get("/subpath/{user_id}")
def mounted_dynamic_subpath():
pass
app.mount("/mounted", mounted_app)
# Match base app root.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/"}) == "/"
)
# Match mounted app root.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/mounted"})
== "/mounted"
)
# Match mounted app subpath.
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/mounted/subpath"}
)
== "/mounted/subpath"
)
# Match mounted app dynamic subpath.
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/mounted/subpath/abc123"}
)
== "/mounted/subpath/{user_id}"
)
# Missing mounted app route.
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/mounted/some-other-path"}
)
is None
)
def test_included_router():
"""Routes registered via `include_router` must resolve their route name.
On FastAPI >= 0.137 these routes are nested under an `_IncludedRouter` node
instead of being flattened into `app.routes` (see #64475).
"""
app = FastAPI()
@app.get("/direct")
def direct():
pass
# Router with its own prefix, included without an include-time prefix.
router = APIRouter(prefix="/prefix")
@router.get("/routed/{user_id}")
def routed():
pass
@router.websocket("/ws")
async def ws():
pass
# Router included with an include-time prefix.
other = APIRouter()
@other.post("/create")
def create():
pass
app.include_router(router)
app.include_router(other, prefix="/other")
# Directly decorated route still resolves.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/direct"})
== "/direct"
)
# Route from an included router (with dynamic segment).
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/prefix/routed/abc123"}
)
== "/prefix/routed/{user_id}"
)
# WebSocket route from an included router.
assert (
get_asgi_route_name(app, {"type": "websocket", "path": "/prefix/ws"})
== "/prefix/ws"
)
# Route from a router included with an include-time prefix.
assert (
get_asgi_route_name(
app, {"type": "http", "method": "POST", "path": "/other/create"}
)
== "/other/create"
)
# Unknown path still returns None.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/nope"})
is None
)
def test_root_path():
app = FastAPI(root_path="/some/root")
@app.get("/subpath")
def subpath():
pass
assert (
get_asgi_route_name(
app,
{
"type": "http",
"method": "GET",
"path": "/subpath",
"root_path": "/some/root",
},
)
== "/some/root/subpath"
)
@pytest.mark.parametrize("redirect_slashes", [False, True])
def test_redirect_slashes(redirect_slashes: bool):
app = FastAPI(redirect_slashes=redirect_slashes)
@app.get("/subpath")
def subpath():
pass
# Should always match.
assert (
get_asgi_route_name(app, {"type": "http", "method": "GET", "path": "/subpath"})
== "/subpath"
)
# Should match depending on redirect_slashes behavior.
if redirect_slashes:
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/subpath/"}
)
== "/subpath/"
)
else:
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/subpath/"}
)
is None
)
@app.get("/other/{user_id}")
def dynamic_subpath():
pass
# Should always match.
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/other/abc123"}
)
== "/other/{user_id}"
)
# Should match depending on redirect_slashes behavior.
if redirect_slashes:
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/other/abc123/"}
)
== "/other/{user_id}/"
)
else:
assert (
get_asgi_route_name(
app, {"type": "http", "method": "GET", "path": "/other/abc123/"}
)
is None
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,391 @@
import asyncio
import sys
import threading
import pytest
from ray import ActorID, cloudpickle
from ray._common.test_utils import wait_for_condition
from ray.serve._private.common import RequestMetadata
from ray.serve._private.replica_result import gRPCReplicaResult
from ray.serve.generated import serve_pb2
class FakegRPCUnaryCall:
def __init__(self, item, is_error: bool = False):
self._loop = asyncio.get_running_loop()
self._item = item
self._is_error = is_error
def __await__(self):
if asyncio.get_running_loop() != self._loop:
raise RuntimeError("Tried to fetch from a different loop!")
yield
return serve_pb2.ASGIResponse(
serialized_message=cloudpickle.dumps(self._item), is_error=self._is_error
)
def add_done_callback(self, cb):
pass
class FakegRPCStreamCall:
def __init__(self, items, *, event: threading.Event = None):
self._loop = asyncio.get_running_loop()
self._items = items
self._event = event
def is_empty(self) -> bool:
assert len(self._items) == 0
return True
def __aiter__(self):
return self
async def __anext__(self):
if asyncio.get_running_loop() != self._loop:
raise RuntimeError("Tried to fetch from a different loop!")
if not self._items:
raise StopAsyncIteration
if self._event:
await self._loop.run_in_executor(None, self._event.wait)
item, is_error = self._items.pop(0)
return serve_pb2.ASGIResponse(
serialized_message=cloudpickle.dumps(item),
is_error=is_error,
)
def add_done_callback(self, cb):
pass
@pytest.fixture
def create_asyncio_event_loop_in_thread():
async_loop = asyncio.new_event_loop()
thread = threading.Thread(daemon=True, target=async_loop.run_forever)
thread.start()
event = threading.Event()
yield async_loop, event
# Unblock event in case it's blocking shutdown
event.set()
@pytest.mark.asyncio
class TestSameLoop:
def make_fake_call(self, is_streaming: bool, *, data=None, error=None):
if is_streaming:
fake_call = FakegRPCStreamCall(data)
else:
if error:
fake_call = FakegRPCUnaryCall(error, is_error=True)
else:
fake_call = FakegRPCUnaryCall(data, is_error=False)
return gRPCReplicaResult(
fake_call,
metadata=RequestMetadata(
request_id="",
internal_request_id="",
is_streaming=False,
_on_separate_loop=False,
),
actor_id=ActorID(b"2" * 16),
loop=asyncio.get_running_loop(),
)
async def test_unary(self):
replica_result = self.make_fake_call(is_streaming=False, data="hello")
assert await replica_result.get_async() == "hello"
async def test_streaming(self):
replica_result = self.make_fake_call(
is_streaming=True, data=[(1, False), (2, False), (3, False), (4, False)]
)
assert [r async for r in replica_result] == [1, 2, 3, 4]
async def test_unary_with_gen(self):
replica_result = self.make_fake_call(is_streaming=True, data=[("hello", False)])
assert await replica_result.get_async() == "hello"
async def test_unary_error(self):
"""Test error is raised correctly."""
replica_result = self.make_fake_call(
is_streaming=False, error=RuntimeError("oh no!")
)
with pytest.raises(RuntimeError, match="oh no!"):
await replica_result.get_async()
async def test_streaming_error(self):
"""Test error is raised correctly."""
replica_result = self.make_fake_call(
is_streaming=True, data=[(RuntimeError("oh no!"), True)]
)
with pytest.raises(RuntimeError, match="oh no!"):
await replica_result.__anext__()
class TestSeparateLoop:
async def make_fake_unary_request(self, data, loop: asyncio.AbstractEventLoop):
fake_call = FakegRPCUnaryCall(data)
replica_result = gRPCReplicaResult(
fake_call,
metadata=RequestMetadata(
request_id="",
internal_request_id="",
is_streaming=False,
_on_separate_loop=True,
),
actor_id=ActorID(b"2" * 16),
loop=loop,
)
return replica_result
async def make_fake_streaming_request(
self,
data,
loop: asyncio.AbstractEventLoop,
on_separate_loop: bool,
*,
is_streaming: bool = True,
event: threading.Event = None,
error=None,
):
if error:
fake_call = FakegRPCStreamCall([(error, True)], event=event)
else:
fake_call = FakegRPCStreamCall([(d, False) for d in data], event=event)
return gRPCReplicaResult(
fake_call,
metadata=RequestMetadata(
request_id="",
internal_request_id="",
is_streaming=is_streaming,
_on_separate_loop=on_separate_loop,
),
actor_id=ActorID(b"2" * 16),
loop=loop,
)
def test_unary_sync(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_unary_request("hello", loop), loop=loop
)
replica_result = fut.result()
assert replica_result.get(None) == "hello"
@pytest.mark.asyncio
async def test_unary_async(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_unary_request("hello", loop), loop=loop
)
replica_result = fut.result()
assert await replica_result.get_async() == "hello"
def test_streaming_sync(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
# Instantiate gRPCReplicaResult with FakegRPCStreamCall. This needs
# to be run on the "other loop"
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request([1, 2, 3, 4], loop, on_separate_loop=True),
loop=loop,
)
replica_result = fut.result()
# The async generator should be consumed even if we don't fetch
# the items explicitly through the ReplicaResult object
wait_for_condition(replica_result._call.is_empty, retry_interval_ms=10)
# Finally, check results given by gRPCReplicaResult fetched from
# the queue are correct
assert list(replica_result) == [1, 2, 3, 4]
@pytest.mark.asyncio
async def test_streaming_async(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request([1, 2, 3, 4], loop, on_separate_loop=True),
loop=loop,
)
replica_result = fut.result()
# Check async generator is consumed on its own
wait_for_condition(replica_result._call.is_empty, retry_interval_ms=10)
assert [r async for r in replica_result] == [1, 2, 3, 4]
@pytest.mark.asyncio
async def test_streaming_blocked(self, create_asyncio_event_loop_in_thread):
"""Use threading event to block async generator, check everything works"""
loop, event = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
[1, 2, 3, 4], loop, on_separate_loop=True, event=event
),
loop=loop,
)
replica_result = fut.result()
async def fetch():
return [r async for r in replica_result]
t = asyncio.create_task(fetch())
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(t), 0.01)
event.set()
assert await t == [1, 2, 3, 4]
def test_unary_with_gen_sync(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
["hello"], loop, on_separate_loop=True, is_streaming=False
),
loop=loop,
)
replica_result = fut.result()
# Check async generator is consumed on its own
wait_for_condition(replica_result._call.is_empty, retry_interval_ms=10)
assert replica_result.get(None) == "hello"
@pytest.mark.asyncio
async def test_unary_with_gen_async(self, create_asyncio_event_loop_in_thread):
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
["hello"], loop, on_separate_loop=True, is_streaming=False
),
loop=loop,
)
replica_result = fut.result()
# Check async generator is consumed on its own
wait_for_condition(replica_result._call.is_empty, retry_interval_ms=10)
assert await replica_result.get_async() == "hello"
@pytest.mark.asyncio
async def test_unary_with_gen_blocked(self, create_asyncio_event_loop_in_thread):
"""Use threading event to block async generator, check everything works"""
loop, event = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
["hello"], loop, on_separate_loop=True, event=event
),
loop=loop,
)
replica_result = fut.result()
t = asyncio.create_task(replica_result.get_async())
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(asyncio.shield(t), 0.01)
event.set()
assert await t == "hello"
def test_unary_with_timeout(self, create_asyncio_event_loop_in_thread):
"""Test get() with timeout."""
loop, event = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
["hello"], loop, on_separate_loop=True, event=event
),
loop=loop,
)
replica_result = fut.result()
with pytest.raises(TimeoutError):
replica_result.get(timeout_s=0.01)
event.set()
assert replica_result.get(timeout_s=0.01) == "hello"
def test_unary_error_sync(self, create_asyncio_event_loop_in_thread):
"""Test error is raised correctly."""
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
None, loop, on_separate_loop=True, error=RuntimeError("oh no!")
),
loop=loop,
)
replica_result = fut.result()
with pytest.raises(RuntimeError, match="oh no!"):
replica_result.get(None)
@pytest.mark.asyncio
async def test_unary_error_async(self, create_asyncio_event_loop_in_thread):
"""Test error is raised correctly."""
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
None, loop, on_separate_loop=True, error=RuntimeError("oh no!")
),
loop=loop,
)
replica_result = fut.result()
with pytest.raises(RuntimeError, match="oh no!"):
await replica_result.get_async()
def test_streaming_error_sync(self, create_asyncio_event_loop_in_thread):
"""Test error is raised correctly."""
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
None, loop, on_separate_loop=True, error=RuntimeError("oh no!")
),
loop=loop,
)
replica_result = fut.result()
with pytest.raises(RuntimeError, match="oh no!"):
replica_result.__next__()
@pytest.mark.asyncio
async def test_streaming_error_async(self, create_asyncio_event_loop_in_thread):
"""Test error is raised correctly."""
loop, _ = create_asyncio_event_loop_in_thread
fut = asyncio.run_coroutine_threadsafe(
self.make_fake_streaming_request(
None, loop, on_separate_loop=True, error=RuntimeError("oh no!")
),
loop=loop,
)
replica_result = fut.result()
with pytest.raises(RuntimeError, match="oh no!"):
await replica_result.__anext__()
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,224 @@
import pickle
from typing import Callable
from unittest.mock import Mock
import grpc
import pytest
from google.protobuf.any_pb2 import Any as AnyProto
from ray import cloudpickle
from ray.serve._private.default_impl import add_grpc_address
from ray.serve._private.grpc_util import (
GRPC_MAX_STATUS_DETAILS_LENGTH,
_truncate_message,
get_grpc_response_status,
gRPCGenericServer,
)
from ray.serve._private.proxy_request_response import gRPCStreamingType
from ray.serve._private.test_utils import FakeGrpcContext
from ray.serve.exceptions import BackPressureError, gRPCStatusError
from ray.serve.grpc_util import RayServegRPCContext
class FakeGrpcServer:
def __init__(self):
self.address = None
def add_insecure_port(self, address):
self.address = address
def fake_service_handler_factory(
service_method: str, streaming_type: gRPCStreamingType
) -> Callable:
def foo() -> bytes:
return f"{streaming_type.value} call from {service_method}".encode()
return foo
def test_grpc_server():
"""Test `gRPCGenericServer` did the correct overrides.
When a add_servicer_to_server function is called on an instance of `gRPCGenericServer`,
it correctly overrides `response_serializer` to None, and `unary_unary`,
`unary_stream`, `stream_unary`, and `stream_stream` to be generated from the
factory function.
"""
service_name = "ray.serve.ServeAPIService"
method_name = "ServeRoutes"
def add_test_servicer_to_server(servicer, server):
rpc_method_handlers = {
method_name: grpc.unary_unary_rpc_method_handler(
servicer.ServeRoutes,
request_deserializer=AnyProto.FromString,
response_serializer=AnyProto.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
service_name, rpc_method_handlers
)
server.add_generic_rpc_handlers((generic_handler,))
grpc_server = gRPCGenericServer(fake_service_handler_factory)
dummy_servicer = Mock()
# Ensure `generic_rpc_handlers` is not populated before calling
# the add_servicer_to_server function.
assert grpc_server.generic_rpc_handlers == []
add_test_servicer_to_server(dummy_servicer, grpc_server)
# `generic_rpc_handlers` should be populated after add_servicer_to_server is called.
assert len(grpc_server.generic_rpc_handlers) == 1
# The populated rpc handler should have the correct service name.
rpc_handler = grpc_server.generic_rpc_handlers[0][0]
assert rpc_handler.service_name() == service_name
# The populated method handlers should have the correct response_serializer,
# unary_unary, unary_stream, stream_unary, and stream_stream.
service_method = f"/{service_name}/{method_name}"
method_handlers = rpc_handler._method_handlers.get(service_method)
assert method_handlers.response_serializer is None
assert (
method_handlers.unary_unary()
== f"unary_unary call from {service_method}".encode()
)
assert (
method_handlers.unary_stream()
== f"unary_stream call from {service_method}".encode()
)
assert (
method_handlers.stream_unary()
== f"stream_unary call from {service_method}".encode()
)
assert (
method_handlers.stream_stream()
== f"stream_stream call from {service_method}".encode()
)
def test_ray_serve_grpc_context_serializable():
"""RayServegRPCContext should be serializable."""
context = RayServegRPCContext(FakeGrpcContext())
pickled_context = pickle.dumps(context)
deserialized_context = pickle.loads(pickled_context)
assert deserialized_context.__dict__ == context.__dict__
cloudpickled_context = cloudpickle.dumps(context)
deserialized_context = pickle.loads(cloudpickled_context)
assert deserialized_context.__dict__ == context.__dict__
def test_add_grpc_address():
"""Test `add_grpc_address` adds the address to the gRPC server."""
fake_grpc_server = FakeGrpcServer()
grpc_address = "fake_address:50051"
assert fake_grpc_server.address is None
add_grpc_address(fake_grpc_server, grpc_address)
assert fake_grpc_server.address == grpc_address
def test_get_grpc_response_status_backpressure_error():
"""Test that BackPressureError returns RESOURCE_EXHAUSTED status."""
backpressure_error = BackPressureError(
num_queued_requests=10, max_queued_requests=5
)
status = get_grpc_response_status(
exc=backpressure_error, request_timeout_s=30.0, request_id="test_request_123"
)
assert status.code == grpc.StatusCode.RESOURCE_EXHAUSTED
assert status.is_error is True
assert status.message == backpressure_error.message
def test_get_grpc_response_status_grpc_status_error():
"""Test that gRPCStatusError preserves user-set status code."""
original_error = RuntimeError("test error")
user_status_code = grpc.StatusCode.INVALID_ARGUMENT
user_details = "Invalid argument provided"
grpc_status_error = gRPCStatusError(
original_exception=original_error,
code=user_status_code,
details=user_details,
)
status = get_grpc_response_status(
exc=grpc_status_error, request_timeout_s=30.0, request_id="test_request_123"
)
assert status.code == user_status_code
assert status.is_error is True
assert status.message == user_details
def test_get_grpc_response_status_grpc_status_error_no_details():
"""Test that gRPCStatusError without details uses original exception message."""
original_error = RuntimeError("original error message")
user_status_code = grpc.StatusCode.RESOURCE_EXHAUSTED
grpc_status_error = gRPCStatusError(
original_exception=original_error,
code=user_status_code,
details=None,
)
status = get_grpc_response_status(
exc=grpc_status_error, request_timeout_s=30.0, request_id="test_request_123"
)
assert status.code == user_status_code
assert status.is_error is True
assert "original error message" in status.message
def test_truncate_message_short():
"""Test that short messages are not truncated."""
short_message = "short error message"
result = _truncate_message(short_message)
assert result == short_message
def test_truncate_message_long():
"""Test that long messages are truncated."""
# Create a message longer than the max length
long_message = "a" * (GRPC_MAX_STATUS_DETAILS_LENGTH + 1000)
result = _truncate_message(long_message)
assert len(result) <= GRPC_MAX_STATUS_DETAILS_LENGTH
assert result.endswith("... [truncated]")
def test_truncate_message_at_boundary():
"""Test truncation at the exact boundary."""
# Create a message exactly at the limit
exact_message = "a" * GRPC_MAX_STATUS_DETAILS_LENGTH
result = _truncate_message(exact_message)
assert result == exact_message
assert len(result) == GRPC_MAX_STATUS_DETAILS_LENGTH
def test_get_grpc_response_status_truncates_long_message():
"""Test that long error messages are truncated in INTERNAL errors."""
long_message = "a" * (GRPC_MAX_STATUS_DETAILS_LENGTH + 1000)
long_error = RuntimeError(long_message)
status = get_grpc_response_status(
exc=long_error, request_timeout_s=30.0, request_id="test_request_123"
)
assert status.code == grpc.StatusCode.INTERNAL
assert status.is_error is True
assert len(status.message) <= GRPC_MAX_STATUS_DETAILS_LENGTH
assert status.message.endswith("... [truncated]")
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,102 @@
import sys
import pytest
from ray.serve._private.common import DeploymentHandleSource
from ray.serve._private.handle_options import DynamicHandleOptions, InitHandleOptions
from ray.serve._private.utils import DEFAULT
def test_dynamic_handle_options():
default_options = DynamicHandleOptions()
assert default_options.method_name == "__call__"
assert default_options.multiplexed_model_id == ""
assert default_options.session_id == ""
assert default_options.stream is False
# Test setting method name.
only_set_method = default_options.copy_and_update(method_name="hi")
assert only_set_method.method_name == "hi"
assert only_set_method.multiplexed_model_id == ""
assert only_set_method.session_id == ""
assert only_set_method.stream is False
# Existing options should be unmodified.
assert default_options.method_name == "__call__"
assert default_options.multiplexed_model_id == ""
assert default_options.session_id == ""
assert default_options.stream is False
# Test setting model ID.
only_set_model_id = default_options.copy_and_update(multiplexed_model_id="hi")
assert only_set_model_id.method_name == "__call__"
assert only_set_model_id.multiplexed_model_id == "hi"
assert only_set_model_id.session_id == ""
assert only_set_model_id.stream is False
# Existing options should be unmodified.
assert default_options.method_name == "__call__"
assert default_options.multiplexed_model_id == ""
assert default_options.session_id == ""
assert default_options.stream is False
# Test setting stream.
only_set_stream = default_options.copy_and_update(stream=True)
assert only_set_stream.method_name == "__call__"
assert only_set_stream.multiplexed_model_id == ""
assert only_set_stream.session_id == ""
assert only_set_stream.stream is True
# Existing options should be unmodified.
assert default_options.method_name == "__call__"
assert default_options.multiplexed_model_id == ""
assert default_options.session_id == ""
assert default_options.stream is False
# Test setting session ID.
only_set_session_id = default_options.copy_and_update(session_id="sess_abc")
assert only_set_session_id.method_name == "__call__"
assert only_set_session_id.multiplexed_model_id == ""
assert only_set_session_id.session_id == "sess_abc"
assert only_set_session_id.stream is False
# Existing options should be unmodified.
assert default_options.method_name == "__call__"
assert default_options.multiplexed_model_id == ""
assert default_options.session_id == ""
assert default_options.stream is False
# Test setting multiple.
set_multiple = default_options.copy_and_update(method_name="hi", stream=True)
assert set_multiple.method_name == "hi"
assert set_multiple.multiplexed_model_id == ""
assert set_multiple.session_id == ""
assert set_multiple.stream is True
def test_init_handle_options():
default_options = InitHandleOptions.create()
assert default_options._prefer_local_routing is False
assert default_options._source == DeploymentHandleSource.UNKNOWN
default1 = InitHandleOptions.create(_prefer_local_routing=DEFAULT.VALUE)
assert default1._prefer_local_routing is False
assert default1._source == DeploymentHandleSource.UNKNOWN
default2 = InitHandleOptions.create(_source=DEFAULT.VALUE)
assert default2._prefer_local_routing is False
assert default2._source == DeploymentHandleSource.UNKNOWN
prefer_local = InitHandleOptions.create(
_prefer_local_routing=True, _source=DEFAULT.VALUE
)
assert prefer_local._prefer_local_routing is True
assert prefer_local._source == DeploymentHandleSource.UNKNOWN
proxy_options = InitHandleOptions.create(_source=DeploymentHandleSource.PROXY)
assert proxy_options._prefer_local_routing is False
assert proxy_options._source == DeploymentHandleSource.PROXY
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,103 @@
"""Unit tests for get_haproxy_binary() resolution logic.
get_haproxy_binary() resolves an HAProxy binary path with this priority:
1. Explicit RAY_SERVE_HAPROXY_BINARY_PATH override, validated and returned.
2. Bundled binary from the ``ray-haproxy`` PyPI package.
3. System ``haproxy`` on PATH.
4. FileNotFoundError with an actionable message.
"""
import stat
from unittest.mock import MagicMock, patch
import pytest
from ray.serve._private.haproxy import get_haproxy_binary
# Patch targets: module-level constants imported at the top of haproxy.py.
HAPROXY_MODULE = "ray.serve._private.haproxy"
BINARY_PATH_PATCH = f"{HAPROXY_MODULE}.RAY_SERVE_HAPROXY_BINARY_PATH"
WHICH_PATCH = f"{HAPROXY_MODULE}.shutil.which"
def _make_executable_binary(tmp_path):
"""Create an empty executable file under tmp_path and return its path."""
binary = tmp_path / "haproxy"
binary.write_bytes(b"")
binary.chmod(binary.stat().st_mode | stat.S_IXUSR)
return binary
def test_explicit_path_takes_precedence_over_bundled(tmp_path):
"""An explicit RAY_SERVE_HAPROXY_BINARY_PATH wins over the bundled
ray-haproxy package, so an operator's custom binary is not silently
overridden."""
binary = _make_executable_binary(tmp_path)
mock_module = MagicMock()
mock_module.get_haproxy_binary.return_value = "/bundled/haproxy"
with patch.dict("sys.modules", {"ray_haproxy": mock_module}), patch(
BINARY_PATH_PATCH, str(binary)
):
assert get_haproxy_binary() == str(binary)
mock_module.get_haproxy_binary.assert_not_called()
def test_explicit_path_validates_executable(tmp_path):
"""An explicit RAY_SERVE_HAPROXY_BINARY_PATH is validated before use: it must
exist and be executable, otherwise resolution raises."""
binary = _make_executable_binary(tmp_path)
with patch(BINARY_PATH_PATCH, str(binary)):
assert get_haproxy_binary() == str(binary)
# Same file without the execute bit is rejected.
binary.chmod(0o644)
with patch(BINARY_PATH_PATCH, str(binary)):
with pytest.raises(FileNotFoundError, match="HAPROXY_BINARY_PATH"):
get_haproxy_binary()
# A nonexistent path is rejected.
with patch(BINARY_PATH_PATCH, str(tmp_path / "no_such_file")):
with pytest.raises(FileNotFoundError, match="HAPROXY_BINARY_PATH"):
get_haproxy_binary()
@patch(BINARY_PATH_PATCH, "")
def test_bundled_package_used_when_no_explicit_path():
"""With no explicit path set, the bundled ray-haproxy binary is used."""
mock_module = MagicMock()
mock_module.get_haproxy_binary.return_value = "/bundled/haproxy"
with patch.dict("sys.modules", {"ray_haproxy": mock_module}):
assert get_haproxy_binary() == "/bundled/haproxy"
@patch(WHICH_PATCH, return_value="/usr/sbin/haproxy")
@patch(BINARY_PATH_PATCH, "")
def test_pip_package_oserror_falls_through(_mock_which):
"""If ray-haproxy is installed but its binary is broken (e.g. missing file,
bad permissions), the function should fall through to the system haproxy
rather than crashing the proxy actor. We catch OSError (not just
FileNotFoundError) so PermissionError is also handled."""
mock_module = MagicMock()
mock_module.get_haproxy_binary.side_effect = OSError("bad permissions")
with patch.dict("sys.modules", {"ray_haproxy": mock_module}):
assert get_haproxy_binary() == "/usr/sbin/haproxy"
@patch(WHICH_PATCH, return_value=None)
@patch(BINARY_PATH_PATCH, "")
def test_nothing_available_raises_with_instructions(_mock_which):
"""When no binary is available from any source, the error message must
tell the user how to fix it (install ray[serve], set the env var, or
put haproxy on PATH)."""
with patch.dict("sys.modules", {"ray_haproxy": None}):
with pytest.raises(FileNotFoundError, match=r"ray\[serve\]"):
get_haproxy_binary()
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,195 @@
"""Unit tests for build_grpc_healthcheck_request_hex().
The function returns a hex string passed verbatim to HAProxy's
`tcp-check send-binary`. It must encode a complete, valid HTTP/2 client
message stream for a unary gRPC `Healthz` call:
1. H2 connection preface
2. empty SETTINGS frame
3. HEADERS frame (HPACK, END_HEADERS only) carrying :method, :scheme,
:path, :authority, te, content-type
4. DATA frame carrying a 5-byte empty gRPC message and END_STREAM
This test verifies all four pieces by parsing the bytes back out.
"""
from typing import Tuple
import pytest
from ray.serve._private.haproxy import build_grpc_healthcheck_request_hex
H2_PREFACE = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
# HTTP/2 frame type codes (RFC 7540 §6).
FRAME_DATA = 0x0
FRAME_HEADERS = 0x1
FRAME_SETTINGS = 0x4
# HTTP/2 frame flags relevant to this test.
FLAG_END_STREAM = 0x1
FLAG_END_HEADERS = 0x4
def _parse_frame(buf: bytes, offset: int) -> Tuple[int, int, int, bytes, int]:
"""Parse a single HTTP/2 frame starting at offset.
Returns (frame_type, flags, stream_id, payload, next_offset).
"""
length = int.from_bytes(buf[offset : offset + 3], "big")
frame_type = buf[offset + 3]
flags = buf[offset + 4]
stream_id = int.from_bytes(buf[offset + 5 : offset + 9], "big") & 0x7FFFFFFF
payload = buf[offset + 9 : offset + 9 + length]
assert len(payload) == length, "frame truncated"
return frame_type, flags, stream_id, payload, offset + 9 + length
def _parse_hpack_literals(block: bytes) -> dict:
"""Parse an HPACK block built by build_grpc_healthcheck_request_hex.
The producer only emits 0x00-prefixed literal-without-indexing entries
with new names, so we don't need a real HPACK decoder. Every entry is:
0x00 <name_len> <name bytes> <value_len> <value bytes>
All lengths are < 127 (single byte). Returns {name: value}.
"""
out: dict = {}
i = 0
while i < len(block):
assert (
block[i] == 0x00
), f"expected literal-no-index marker at {i}, got {block[i]:#x}"
i += 1
name_len = block[i]
assert name_len < 128, "unexpected huffman / long-length encoding"
i += 1
name = block[i : i + name_len].decode()
i += name_len
value_len = block[i]
assert value_len < 128
i += 1
value = block[i : i + value_len].decode()
i += value_len
out[name] = value
return out
@pytest.fixture
def healthz_bytes() -> bytes:
hex_str = build_grpc_healthcheck_request_hex(
"/ray.serve.RayServeAPIService/Healthz"
)
# Sanity: result is a hex string, decodes cleanly.
return bytes.fromhex(hex_str)
def test_starts_with_h2_preface(healthz_bytes: bytes):
"""Every HTTP/2 client connection must start with the 24-byte preface."""
assert healthz_bytes.startswith(H2_PREFACE)
def test_emits_empty_settings_then_headers_then_data(healthz_bytes: bytes):
"""Frame sequence must be SETTINGS (empty) → HEADERS → DATA."""
offset = len(H2_PREFACE)
ftype, flags, stream_id, payload, offset = _parse_frame(healthz_bytes, offset)
assert ftype == FRAME_SETTINGS
assert payload == b"", "client SETTINGS must be empty (no settings sent)"
assert stream_id == 0
ftype, flags, _, headers_payload, offset = _parse_frame(healthz_bytes, offset)
assert ftype == FRAME_HEADERS
ftype, flags, _, data_payload, offset = _parse_frame(healthz_bytes, offset)
assert ftype == FRAME_DATA
# No trailing bytes after the DATA frame.
assert offset == len(
healthz_bytes
), f"unexpected trailing bytes: {healthz_bytes[offset:]!r}"
def test_headers_frame_carries_required_grpc_pseudo_headers(healthz_bytes: bytes):
"""The Python gRPC server rejects requests missing :authority with a
Trailers-Only error before the handler runs. Verify every required
pseudo-header (and te/content-type) is present.
"""
offset = len(H2_PREFACE)
_, _, _, _, offset = _parse_frame(healthz_bytes, offset) # skip SETTINGS
ftype, flags, stream_id, payload, _ = _parse_frame(healthz_bytes, offset)
assert ftype == FRAME_HEADERS
# HEADERS frame must end the headers block but NOT the stream, since the
# gRPC message is still coming in the following DATA frame.
assert flags & FLAG_END_HEADERS, "END_HEADERS must be set"
assert not (
flags & FLAG_END_STREAM
), "END_STREAM on HEADERS would suppress the DATA frame"
assert stream_id == 1, "client stream ids start at 1"
headers = _parse_hpack_literals(payload)
assert headers[":method"] == "POST"
assert headers[":scheme"] == "http"
assert headers[":path"] == "/ray.serve.RayServeAPIService/Healthz"
# `:authority` is mandatory; without it, the server returns Trailers-Only
# "Missing :authority header" and the check never matches.
assert headers[":authority"], ":authority must be present"
assert headers["content-type"] == "application/grpc"
assert headers["te"] == "trailers"
def test_data_frame_is_empty_grpc_message_with_end_stream(healthz_bytes: bytes):
"""The DATA frame must carry exactly the 5-byte empty gRPC frame
(uncompressed flag + 4-byte length=0) so the server's framing layer
deserializes an empty HealthzRequest and runs the handler. END_STREAM
must be on this frame, not on HEADERS.
"""
offset = len(H2_PREFACE)
_, _, _, _, offset = _parse_frame(healthz_bytes, offset) # SETTINGS
_, _, _, _, offset = _parse_frame(healthz_bytes, offset) # HEADERS
ftype, flags, stream_id, payload, _ = _parse_frame(healthz_bytes, offset)
assert ftype == FRAME_DATA
assert stream_id == 1
assert flags & FLAG_END_STREAM, "DATA frame must close the request stream"
assert (
payload == b"\x00\x00\x00\x00\x00"
), f"expected empty gRPC frame, got {payload.hex()}"
@pytest.mark.parametrize(
"path",
[
"/ray.serve.RayServeAPIService/Healthz",
"/grpc.health.v1.Health/Check",
"/a/b",
],
)
def test_path_is_threaded_through_to_pseudo_header(path: str):
"""The :path pseudo-header must reflect the argument verbatim — the
template renders one healthcheck per backend, all with the same
health_check_path, and rendering the wrong path would silently route
the check away from the handler."""
raw = bytes.fromhex(build_grpc_healthcheck_request_hex(path))
offset = len(H2_PREFACE)
_, _, _, _, offset = _parse_frame(raw, offset) # SETTINGS
_, _, _, headers_payload, _ = _parse_frame(raw, offset)
headers = _parse_hpack_literals(headers_payload)
assert headers[":path"] == path
def test_output_is_lowercase_hex_with_no_separators():
"""`tcp-check send-binary` accepts a hex string with no separators;
HAProxy will reject the config if we emit anything else. Guard against
accidental formatting changes (uppercase, spaces, 0x-prefixes)."""
out = build_grpc_healthcheck_request_hex("/x")
assert out == out.lower(), "hex must be lowercase"
assert all(
c in "0123456789abcdef" for c in out
), f"non-hex character in output: {out!r}"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,164 @@
"""Unit tests for HAProxyApi reload takeover verification and -sf signaling.
Regression coverage for the orphaned-worker incident: during a reload the
admin socket answers through its previous owner, so a socket-only readiness
check declares a dead spawn ready and strands the worker it was meant to
stop, which keeps serving its stale config indefinitely.
"""
import asyncio
import os
import sys
from typing import Optional
import pytest
from ray.serve._private.haproxy import HAProxyApi, HAProxyConfig
class FakeProc:
"""Minimal stand-in for asyncio.subprocess.Process."""
def __init__(
self,
pid: int,
returncode: Optional[int] = None,
stdout_path: str = "",
stderr_path: str = "",
):
self.pid = pid
self.returncode = returncode
self._stdout_path = stdout_path
self._stderr_path = stderr_path
@pytest.fixture
def api(tmp_path) -> HAProxyApi:
cfg = HAProxyConfig(
socket_path=str(tmp_path / "admin.sock"),
server_state_base=str(tmp_path),
server_state_file=str(tmp_path / "server-state"),
enable_hap_optimization=False,
)
return HAProxyApi(cfg=cfg, config_file_path=str(tmp_path / "haproxy.cfg"))
def _make_stream_files(tmp_path, stderr_text: str = ""):
stdout = tmp_path / "spawn.stdout.log"
stderr = tmp_path / "spawn.stderr.log"
stdout.write_text("")
stderr.write_text(stderr_text)
return str(stdout), str(stderr)
def _answer_show_info(api: HAProxyApi, response: str) -> None:
async def fake_send(cmd: str) -> str:
assert cmd == "show info"
return response
api._send_socket_command = fake_send
class TestWaitForHapAvailability:
def test_rejects_answer_from_previous_socket_owner(self, api, tmp_path):
"""Readiness requires the answering pid to be the spawn itself, not
whichever previous worker still owns the socket path."""
stdout, stderr = _make_stream_files(tmp_path, "fd transfer failed")
# The socket answers, but from the OLD worker (pid 111).
_answer_show_info(api, "Name: HAProxy\nPid: 111\nUptime: 1d\n")
proc = FakeProc(pid=222, stdout_path=stdout, stderr_path=stderr)
with pytest.raises(RuntimeError, match="did not take over") as exc_info:
asyncio.run(api._wait_for_hap_availability(proc, timeout_s=1))
# The spawn's stderr is surfaced at failure time.
assert "fd transfer failed" in str(exc_info.value)
def test_passes_when_answering_pid_matches(self, api, tmp_path):
stdout, stderr = _make_stream_files(tmp_path)
_answer_show_info(api, "Name: HAProxy\nPid: 222\nUptime: 0d\n")
proc = FakeProc(pid=222, stdout_path=stdout, stderr_path=stderr)
asyncio.run(api._wait_for_hap_availability(proc, timeout_s=1))
def test_crashed_spawn_raises_with_stderr(self, api, tmp_path):
stdout, stderr = _make_stream_files(tmp_path, "cannot bind socket")
_answer_show_info(api, "Name: HAProxy\nPid: 111\n")
proc = FakeProc(pid=222, returncode=1, stdout_path=stdout, stderr_path=stderr)
with pytest.raises(RuntimeError, match="crashed during startup"):
asyncio.run(api._wait_for_hap_availability(proc, timeout_s=1))
class TestGetRunningPid:
@pytest.mark.parametrize(
"response,expected",
[
("Name: HAProxy\nVersion: 2.8\nPid: 4242\nUptime: 0d\n", 4242),
("Name: HAProxy\nPid: not-a-pid\n", None),
("Name: HAProxy\nVersion: 2.8\n", None),
],
)
def test_parses_show_info(self, api, response, expected):
_answer_show_info(api, response)
assert asyncio.run(api._get_running_pid()) == expected
def test_returns_none_when_socket_unavailable(self, api):
async def fake_send(cmd: str) -> str:
raise RuntimeError("socket does not exist")
api._send_socket_command = fake_send
assert asyncio.run(api._get_running_pid()) is None
class TestIsOurHaproxy:
"""`_is_our_haproxy` keeps a recycled `-sf` signal from hitting an unrelated
process: only pids whose /proc cmdline still carries our config path pass."""
@pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="/proc is Linux-only"
)
def test_true_when_cmdline_matches(self, api):
# Point config_file_path at a real token in this process's cmdline.
with open(f"/proc/{os.getpid()}/cmdline", "rb") as f:
argv = [t for t in f.read().split(b"\0") if t]
api.config_file_path = argv[0].decode()
assert api._is_our_haproxy(os.getpid()) is True
@pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="/proc is Linux-only"
)
def test_false_when_cmdline_does_not_match(self, api):
api.config_file_path = "/tmp/not-in-any-cmdline/haproxy.cfg"
assert api._is_our_haproxy(os.getpid()) is False
def test_false_for_nonexistent_pid(self, api):
# Missing /proc entry (or any non-Linux platform) -> OSError -> False.
api.config_file_path = "/tmp/whatever/haproxy.cfg"
assert api._is_our_haproxy(2_000_000_000) is False
class TestCountHaproxyProcesses:
"""`count_haproxy_processes` scans /proc for processes whose cmdline carries
our config path, so the count spans live, draining, and leaked workers."""
@pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="/proc is Linux-only"
)
def test_counts_matching_processes(self, api):
# Point config_file_path at a real token in this process's cmdline so at
# least this process is counted.
with open(f"/proc/{os.getpid()}/cmdline", "rb") as f:
argv = [t for t in f.read().split(b"\0") if t]
api.config_file_path = argv[0].decode()
assert api.count_haproxy_processes() >= 1
@pytest.mark.skipif(
not sys.platform.startswith("linux"), reason="/proc is Linux-only"
)
def test_returns_zero_when_no_match(self, api):
api.config_file_path = "/tmp/not-in-any-cmdline/haproxy.cfg"
assert api.count_haproxy_processes() == 0
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,358 @@
import asyncio
import pickle
import sys
from typing import Generator, Tuple
from unittest.mock import MagicMock, patch
import pytest
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from ray._common.utils import get_or_create_event_loop
from ray.serve import HTTPOptions
from ray.serve._private.http_util import (
ASGIReceiveProxy,
MessageQueue,
configure_http_middlewares,
configure_http_options_with_defaults,
)
@pytest.mark.asyncio
async def test_message_queue_nowait():
queue = MessageQueue()
# Check that wait_for_message hangs until a message is sent.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.wait_for_message(), 0.001)
assert len(list(queue.get_messages_nowait())) == 0
await queue({"type": "http.response.start"})
await queue.wait_for_message()
assert len(list(queue.get_messages_nowait())) == 1
# Check that messages are cleared after being consumed.
assert len(list(queue.get_messages_nowait())) == 0
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.wait_for_message(), 0.001)
# Check that consecutive messages are returned in order.
await queue({"type": "http.response.start", "idx": 0})
await queue({"type": "http.response.start", "idx": 1})
await queue.wait_for_message()
messages = list(queue.get_messages_nowait())
assert len(messages) == 2
assert messages[0]["idx"] == 0
assert messages[1]["idx"] == 1
assert len(list(queue.get_messages_nowait())) == 0
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.wait_for_message(), 0.001)
# Check that a concurrent waiter is notified when a message is available.
loop = asyncio.get_running_loop()
waiting_task = loop.create_task(queue.wait_for_message())
for _ in range(1000):
assert not waiting_task.done()
await queue({"type": "http.response.start"})
await waiting_task
assert len(list(queue.get_messages_nowait())) == 1
# Check that once the queue is closed, new messages should be rejected and
# ongoing and subsequent calls to wait for messages should return immediately.
waiting_task = loop.create_task(queue.wait_for_message())
queue.close()
await waiting_task # Ongoing call should return.
for _ in range(100):
with pytest.raises(RuntimeError):
await queue({"hello": "world"})
await queue.wait_for_message()
assert queue.get_messages_nowait() == []
@pytest.mark.asyncio
async def test_message_queue_wait():
queue = MessageQueue()
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.get_one_message(), 0.001)
queue.put_nowait("A")
assert await queue.get_one_message() == "A"
# Check that messages are cleared after being consumed.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.get_one_message(), 0.001)
# Check that consecutive messages are returned in order.
queue.put_nowait("B")
queue.put_nowait("C")
assert await queue.get_one_message() == "B"
assert await queue.get_one_message() == "C"
# Check that messages are cleared after being consumed.
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.get_one_message(), 0.001)
# Check that a concurrent waiter is notified when a message is available.
loop = asyncio.get_running_loop()
fetch_task = loop.create_task(queue.get_one_message())
for _ in range(1000):
assert not fetch_task.done()
queue.put_nowait("D")
assert await fetch_task == "D"
@pytest.mark.asyncio
async def test_message_queue_wait_closed():
queue = MessageQueue()
queue.put_nowait("A")
assert await queue.get_one_message() == "A"
# Check that once the queue is closed, ongoing and subsequent calls
# to get_one_message should raise an exception
loop = asyncio.get_running_loop()
fetch_task = loop.create_task(queue.get_one_message())
queue.close()
with pytest.raises(StopAsyncIteration):
await fetch_task
for _ in range(10):
with pytest.raises(StopAsyncIteration):
await queue.get_one_message()
@pytest.mark.asyncio
async def test_message_queue_wait_error():
queue = MessageQueue()
queue.put_nowait("A")
assert await queue.get_one_message() == "A"
# Check setting an error
loop = asyncio.get_running_loop()
fetch_task = loop.create_task(queue.get_one_message())
queue.set_error(TypeError("uh oh! something went wrong."))
with pytest.raises(TypeError, match="uh oh! something went wrong"):
await fetch_task
for _ in range(10):
with pytest.raises(TypeError, match="uh oh! something went wrong"):
await queue.get_one_message()
@pytest.fixture
@pytest.mark.asyncio
def setup_receive_proxy(
request,
) -> Generator[Tuple[ASGIReceiveProxy, MessageQueue], None, None]:
# Param can be 'http' (default) or 'websocket' (ASGI scope type).
type = getattr(request, "param", "http")
queue = MessageQueue()
async def receive_asgi_messages(request_id: str) -> bytes:
await queue.wait_for_message()
messages = queue.get_messages_nowait()
for message in messages:
if isinstance(message, Exception):
raise message
return pickle.dumps(messages)
loop = get_or_create_event_loop()
asgi_receive_proxy = ASGIReceiveProxy({"type": type}, "", receive_asgi_messages)
receiver_task = loop.create_task(asgi_receive_proxy.fetch_until_disconnect())
try:
yield asgi_receive_proxy, queue
except Exception:
receiver_task.cancel()
@pytest.mark.asyncio
class TestASGIReceiveProxy:
async def test_basic(
self, setup_receive_proxy: Tuple[ASGIReceiveProxy, MessageQueue]
):
asgi_receive_proxy, queue = setup_receive_proxy
queue.put_nowait({"type": "foo"})
queue.put_nowait({"type": "bar"})
assert await asgi_receive_proxy() == {"type": "foo"}
assert await asgi_receive_proxy() == {"type": "bar"}
assert asgi_receive_proxy._queue.empty()
# Once disconnect is received, it should be returned repeatedly.
queue.put_nowait({"type": "http.disconnect"})
for _ in range(100):
assert await asgi_receive_proxy() == {"type": "http.disconnect"}
# Subsequent messages should be ignored.
queue.put_nowait({"type": "baz"})
assert await asgi_receive_proxy() == {"type": "http.disconnect"}
async def test_raises_exception(
self, setup_receive_proxy: Tuple[ASGIReceiveProxy, MessageQueue]
):
asgi_receive_proxy, queue = setup_receive_proxy
queue.put_nowait({"type": "foo"})
queue.put_nowait({"type": "bar"})
assert await asgi_receive_proxy() == {"type": "foo"}
assert await asgi_receive_proxy() == {"type": "bar"}
queue.put_nowait(RuntimeError("oopsies"))
with pytest.raises(RuntimeError, match="oopsies"):
await asgi_receive_proxy()
@pytest.mark.parametrize(
"setup_receive_proxy",
["http", "websocket"],
indirect=True,
)
async def test_return_disconnect_on_key_error(
self, setup_receive_proxy: Tuple[ASGIReceiveProxy, MessageQueue]
):
"""If the proxy is no longer handling a given request, it raises a KeyError.
In these cases, the ASGI receive proxy should return a disconnect message.
See https://github.com/ray-project/ray/pull/44647 for details.
"""
asgi_receive_proxy, queue = setup_receive_proxy
queue.put_nowait({"type": "foo"})
queue.put_nowait({"type": "bar"})
assert await asgi_receive_proxy() == {"type": "foo"}
assert await asgi_receive_proxy() == {"type": "bar"}
queue.put_nowait(KeyError("not found"))
for _ in range(100):
if asgi_receive_proxy._type == "http":
assert await asgi_receive_proxy() == {"type": "http.disconnect"}
else:
assert await asgi_receive_proxy() == {
"type": "websocket.disconnect",
"code": 1005,
}
async def test_receive_asgi_messages_raises(self):
async def receive_asgi_messages(request_id: str) -> bytes:
raise RuntimeError("maybe actor crashed")
loop = get_or_create_event_loop()
asgi_receive_proxy = ASGIReceiveProxy(
{"type": "http"}, "", receive_asgi_messages
)
receiver_task = loop.create_task(asgi_receive_proxy.fetch_until_disconnect())
try:
with pytest.raises(RuntimeError, match="maybe actor crashed"):
await asgi_receive_proxy()
finally:
receiver_task.cancel()
class MockMiddleware:
"""Mock middleware class for testing."""
def __init__(self, name):
self.name = name
def __eq__(self, other):
return isinstance(other, MockMiddleware) and self.name == other.name
def __repr__(self):
return f"MockMiddleware({self.name})"
@pytest.fixture
def base_http_options():
"""Provides basic HTTPOptions for testing."""
return HTTPOptions(
host="0.0.0.0",
port=8000,
request_timeout_s=30.0,
keep_alive_timeout_s=5.0,
middlewares=[],
)
class TestConfigureHttpOptionsWithDefaults:
"""Test suite for configure_http_options_with_defaults function."""
def test_basic_configuration(self, base_http_options):
"""Test basic configuration preserves settings."""
result = configure_http_options_with_defaults(base_http_options)
# Request timeout should be preserved
assert result.request_timeout_s == 30.0
# Keep alive timeout should be preserved (no env override)
assert result.keep_alive_timeout_s == 5.0
# Should initialize middlewares list
assert result.middlewares == []
# Original should not be modified
assert base_http_options.request_timeout_s == 30.0
@patch("ray.serve._private.http_util.call_function_from_import_path")
@patch(
"ray.serve._private.http_util.RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH",
"my.module.callback",
)
def test_callback_middleware_injection(self, mock_call_function, base_http_options):
"""Test that the callback middleware is injected correctly."""
# Arrange: Create a valid middleware by wrapping it with Starlette's Middleware class
class CustomMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
response = await call_next(request) # Simply pass the request through
return response
# Mock the app argument
mock_app = MagicMock()
wrapped_middleware = Middleware(CustomMiddleware, app=mock_app)
mock_call_function.return_value = [
wrapped_middleware
] # Return list of wrapped middleware
# Act
result = configure_http_middlewares(base_http_options)
# Assert
mock_call_function.assert_called_once_with(
"my.module.callback"
) # Verify callback execution
assert len(result.middlewares) == 1 # Ensure one middleware was injected
assert isinstance(result.middlewares[0], Middleware)
def test_callback_middleware_disabled(self, base_http_options):
"""Test that callback middleware is not loaded when disabled."""
with patch(
"ray.serve._private.http_util.RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH",
"",
):
result = configure_http_options_with_defaults(base_http_options)
# Assert that no callback middleware is added
assert result.middlewares == []
def test_deep_copy_behavior(self, base_http_options):
"""Test that an original HTTPOptions object is not modified."""
original_timeout = base_http_options.request_timeout_s
result = configure_http_options_with_defaults(base_http_options)
# Original should remain unchanged
assert base_http_options.request_timeout_s == original_timeout
# Result should be a different object
assert result is not base_http_options
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,37 @@
import sys
import pytest
# skip the test if vllm is installed
HAS_VLLM = False
try:
import vllm # noqa: F401
HAS_VLLM = True
except ImportError:
pass
@pytest.mark.skipif(not HAS_VLLM, reason="vllm is not installed")
def test_serve_llm_import_does_not_error():
# expected ImportError because of missing
# dependencies without ray[llm] dependencies
with pytest.raises(ImportError):
import ray.serve.llm # noqa: F401
with pytest.raises(ImportError):
from ray.serve.llm import (
LLMConfig, # noqa: F401
)
with pytest.raises(ImportError):
from ray.serve.llm.deployment import (
LLMServer, # noqa: F401
)
with pytest.raises(ImportError):
from ray.serve.llm import (
build_llm_deployment, # noqa: F401
build_openai_app, # noqa: F401
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", __file__]))
@@ -0,0 +1,246 @@
import logging
import os
import re
import sys
import pytest
from ray import serve
from ray.serve._private.constants import SERVE_LOGGER_NAME
from ray.serve.exceptions import RayServeException
from ray.serve.handle import DeploymentHandle
def test_basic_composition():
@serve.deployment
class Inner:
def __init__(self, my_name: str):
self._my_name = my_name
def __call__(self):
return self._my_name
@serve.deployment
class Outer:
def __init__(self, my_name: str, inner_handle: DeploymentHandle):
assert isinstance(inner_handle, DeploymentHandle)
self._my_name = my_name
self._inner_handle = inner_handle
async def __call__(self, name: str):
inner_name = await self._inner_handle.remote()
return f"Hello {name} from {self._my_name} and {inner_name}!"
h = serve.run(Outer.bind("Theodore", Inner.bind("Kevin")), _local_testing_mode=True)
assert isinstance(h, DeploymentHandle)
assert h.remote("Edith").result() == "Hello Edith from Theodore and Kevin!"
@pytest.mark.parametrize("deployment", ["Inner", "Outer"])
def test_exception_raised_in_constructor(deployment: str):
@serve.deployment
class Inner:
def __init__(self, should_raise: bool):
if should_raise:
raise RuntimeError("Exception in Inner constructor.")
@serve.deployment
class Outer:
def __init__(self, h: DeploymentHandle, should_raise: bool):
if should_raise:
raise RuntimeError("Exception in Outer constructor.")
with pytest.raises(RuntimeError, match=f"Exception in {deployment} constructor."):
serve.run(
Outer.bind(Inner.bind(deployment == "Inner"), deployment == "Outer"),
_local_testing_mode=True,
)
def test_to_object_ref_error_message():
def _get_error_match(by_reference: bool) -> str:
if by_reference:
return (
"Converting DeploymentResponses to ObjectRefs "
"is not supported in local testing mode."
)
else:
return re.escape(
"Converting by-value DeploymentResponses to ObjectRefs is not supported. "
"Use handle.options(_by_reference=True) to enable it."
)
@serve.deployment
class Inner:
pass
@serve.deployment
class Outer:
def __init__(self, h: DeploymentHandle):
self._h = h
async def __call__(self):
match = _get_error_match(self._h.handle_options._by_reference)
with pytest.raises(
RuntimeError,
match=match,
):
await self._h.remote()._to_object_ref()
h = serve.run(Outer.bind(Inner.bind()), _local_testing_mode=True)
match = _get_error_match(h.handle_options._by_reference)
with pytest.raises(
RuntimeError,
match=match,
):
h.remote()._to_object_ref_sync()
# Test the inner handle case (this would raise if it failed).
h.remote().result()
def test_dictionary_logging_config_with_local_mode():
"""Test that the logging config can be passed as a dictionary.
See: https://github.com/ray-project/ray/issues/50052
"""
@serve.deployment
class MyApp:
def __call__(self):
logger = logging.getLogger(SERVE_LOGGER_NAME)
return logger.level
app = MyApp.bind()
logging_config = {"log_level": "WARNING"}
# This should not raise exception.
h = serve.run(app, logging_config=logging_config, _local_testing_mode=True)
# The logger should be setup with WARNING level.
assert h.remote().result() == logging.WARNING
def test_ingress_request_router_requires_haproxy(monkeypatch):
@serve.deployment
class LLMServer:
pass
@serve.deployment
class IngressRequestRouter:
pass
monkeypatch.setattr("ray.serve._private.build_app.RAY_SERVE_ENABLE_HA_PROXY", False)
with pytest.raises(
RayServeException,
match="Ray controller's environment",
):
llm_server = LLMServer.bind()
app = llm_server._with_ingress_request_router(
IngressRequestRouter.bind(llm_deployment=llm_server)
)
serve.run(
app,
_local_testing_mode=True,
)
def test_deploy_multiple_apps_batched() -> None:
@serve.deployment
class A:
def __call__(self):
return "a"
@serve.deployment
class B:
def __call__(self):
return "b"
a, b = serve.run_many(
[
serve.RunTarget(A.bind(), name="a", route_prefix="/a"),
serve.RunTarget(B.bind(), name="b", route_prefix="/b"),
],
_local_testing_mode=True,
)
assert a.remote().result() == "a"
assert b.remote().result() == "b"
def test_redeploy_multiple_apps_batched() -> None:
@serve.deployment
class A:
def __call__(self):
return "a", os.getpid()
@serve.deployment
class V1:
def __call__(self):
return "version 1", os.getpid()
@serve.deployment
class V2:
def __call__(self):
return "version 2", os.getpid()
a_handle, v1_handle = serve.run_many(
[
serve.RunTarget(A.bind(), name="a", route_prefix="/a"),
serve.RunTarget(V1.bind(), name="v", route_prefix="/v"),
],
_local_testing_mode=True,
)
a1, pida1 = a_handle.remote().result()
assert a1 == "a"
v1, pid1 = v1_handle.remote().result()
assert v1 == "version 1"
(v2_handle,) = serve.run_many(
[
serve.RunTarget(V2.bind(), name="v", route_prefix="/v"),
],
_local_testing_mode=True,
)
v2, pid2 = v2_handle.remote().result()
assert v2 == "version 2"
assert pid1 == pid2 # because local testing mode, so it's all in-process
# Redeploying "v" should not have affected "a"
a2, pida2 = a_handle.remote().result()
assert a1 == a2
assert pida1 == pida2
def test_reconfigure():
@serve.deployment
class Hello:
def __init__(self):
self.user_config = {"name": "Bob"}
async def reconfigure(self, user_config) -> None:
print("Reconfiguring...")
self.user_config = user_config
def __call__(self):
name = self.user_config["name"]
return f"Hi {name}!"
h = serve.run(
Hello.options(user_config={"name": "Charles"}).bind(), _local_testing_mode=True
)
assert isinstance(h, DeploymentHandle)
assert h.remote().result() == "Hi Charles!"
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,356 @@
import sys
from typing import Set
import pytest
from ray._common.test_utils import wait_for_condition
from ray.serve._private.common import RequestProtocol
from ray.serve._private.node_port_manager import NoAvailablePortError, NodePortManager
@pytest.fixture
def port_range_constants():
"""Fixture to set up port range constants for testing."""
return {
"RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT": 8000,
"RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT": 8005,
"RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT": 9000,
"RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT": 9005,
}
@pytest.fixture(autouse=True)
def setup_port_constants(monkeypatch, port_range_constants):
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT",
port_range_constants["RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT"],
)
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT",
port_range_constants["RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT"],
)
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT",
port_range_constants["RAY_SERVE_DIRECT_INGRESS_MIN_GRPC_PORT"],
)
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT",
port_range_constants["RAY_SERVE_DIRECT_INGRESS_MAX_GRPC_PORT"],
)
# Disable quarantine so existing tests keep their immediate-reuse semantics.
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
0.0,
)
yield
# _node_managers is class-level and persists across tests; reset for isolation.
NodePortManager._node_managers.clear()
def test_http_port_allocation_and_release(port_range_constants):
manager = NodePortManager.get_node_manager("node-1")
port = manager.allocate_port("replica-1", RequestProtocol.HTTP)
assert (
port_range_constants["RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT"]
<= port
< port_range_constants["RAY_SERVE_DIRECT_INGRESS_MAX_HTTP_PORT"]
)
# Port should be reusable after release
manager.release_port("replica-1", port, RequestProtocol.HTTP)
port2 = manager.allocate_port("replica-2", RequestProtocol.HTTP)
assert port2 == port
def test_grpc_port_allocation_and_blocking():
manager = NodePortManager.get_node_manager("node-2")
port = manager.allocate_port("replica-a", RequestProtocol.GRPC)
manager.release_port("replica-a", port, RequestProtocol.GRPC, block_port=True)
# New allocation should skip blocked port
allocated_ports = set()
for i in range(4):
new_port = manager.allocate_port(f"replica-b{i}", RequestProtocol.GRPC)
assert new_port != port
allocated_ports.add(new_port)
assert len(allocated_ports) == 4
def test_reuse_existing_port_if_already_allocated():
manager = NodePortManager.get_node_manager("node-3")
port1 = manager.allocate_port("replica-x", RequestProtocol.HTTP)
port2 = manager.allocate_port("replica-x", RequestProtocol.HTTP)
assert port1 == port2
def test_cleanup_releases_ports():
manager = NodePortManager.get_node_manager("node-4")
allocated = set()
# Allocate all HTTP ports
for i in range(5):
port = manager.allocate_port(f"replica-{i}", RequestProtocol.HTTP)
allocated.add(port)
# Only keep some replicas active
active_replica_ids: Set[str] = {"replica-0", "replica-1"}
NodePortManager.prune({"node-4": active_replica_ids})
# We should be able to reallocate some of the cleaned-up ports
new_port = manager.allocate_port("replica-new", RequestProtocol.HTTP)
assert new_port in allocated - {
manager.get_port("replica-0", RequestProtocol.HTTP),
manager.get_port("replica-1", RequestProtocol.HTTP),
}
def test_node_manager_cleanup():
NodePortManager.get_node_manager("node-5")
assert "node-5" in NodePortManager._node_managers
NodePortManager.prune(node_id_to_alive_replica_ids={})
assert "node-5" not in NodePortManager._node_managers
def test_port_exhaustion():
"""Test behavior when all ports are exhausted."""
manager = NodePortManager.get_node_manager("node-6")
# Allocate all HTTP ports
allocated_ports = set()
for i in range(5): # 5 ports available (8000-8004)
port = manager.allocate_port(f"replica-{i}", RequestProtocol.HTTP)
allocated_ports.add(port)
# Try to allocate one more port
with pytest.raises(NoAvailablePortError, match="No available"):
manager.allocate_port("replica-extra", RequestProtocol.HTTP)
def test_invalid_port_release():
"""Test error handling when releasing invalid ports."""
manager = NodePortManager.get_node_manager("node-7")
# Allocate a port
port = manager.allocate_port("replica-1", RequestProtocol.HTTP)
# Try to release with wrong port number
with pytest.raises(AssertionError, match="port mismatch"):
manager.release_port("replica-1", port + 1, RequestProtocol.HTTP)
def test_concurrent_port_allocation():
"""Test concurrent port allocation from multiple coroutines."""
manager = NodePortManager.get_node_manager("node-8")
def allocate_port(replica_id: str):
return manager.allocate_port(replica_id, RequestProtocol.HTTP)
# Allocate ports concurrently
ports = [allocate_port(f"replica-{i}") for i in range(5)]
# Verify all ports are unique and in range
assert len(set(ports)) == len(ports)
assert all(8000 <= port < 8005 for port in ports)
def test_mixed_protocol_port_allocation():
"""Test allocating both HTTP and gRPC ports for the same replica."""
manager = NodePortManager.get_node_manager("node-9")
http_port = manager.allocate_port("replica-1", RequestProtocol.HTTP)
grpc_port = manager.allocate_port("replica-1", RequestProtocol.GRPC)
assert 8000 <= http_port < 8005
assert 9000 <= grpc_port < 9005
assert http_port != grpc_port
def test_port_blocking_persistence():
"""Test that blocked ports remain blocked across multiple allocations."""
manager = NodePortManager.get_node_manager("node-10")
# Block a port
port = manager.allocate_port("replica-1", RequestProtocol.HTTP)
manager.release_port("replica-1", port, RequestProtocol.HTTP, block_port=True)
# Try to allocate ports multiple times
for _ in range(3):
new_port = manager.allocate_port("replica-2", RequestProtocol.HTTP)
assert new_port != port
manager.release_port("replica-2", new_port, RequestProtocol.HTTP)
def test_check_replica_port_allocated():
"""Test the check_replica_port_allocated method."""
manager = NodePortManager.get_node_manager("node-11")
# Test case where port is allocated
port = manager.allocate_port("replica-1", RequestProtocol.HTTP)
assert manager.get_port("replica-1", RequestProtocol.HTTP) == port
# Test case where port is not allocated
with pytest.raises(
ValueError,
match="HTTP port not allocated for replica non-existent on node node-11",
):
manager.get_port("non-existent", RequestProtocol.HTTP)
# Clean up
manager.release_port("replica-1", port, RequestProtocol.HTTP)
def test_quarantine_holds_released_port(monkeypatch, port_range_constants):
"""A released port is held in quarantine and not immediately reusable."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
60.0,
)
manager = NodePortManager.get_node_manager("node-quarantine-1")
first = manager.allocate_port("replica-q1", RequestProtocol.HTTP)
manager.release_port("replica-q1", first, RequestProtocol.HTTP)
next_port = manager.allocate_port("replica-q2", RequestProtocol.HTTP)
assert next_port != first
assert first not in manager._http_allocator._available_ports
assert first in manager._http_allocator._quarantined_ports
def test_quarantine_expiry_returns_port_to_pool(monkeypatch, port_range_constants):
"""Once a quarantined port's timer expires, it becomes available again."""
import time
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
0.05,
)
manager = NodePortManager.get_node_manager("node-quarantine-2")
first = manager.allocate_port("replica-q1", RequestProtocol.HTTP)
manager.release_port("replica-q1", first, RequestProtocol.HTTP)
assert first in manager._http_allocator._quarantined_ports
time.sleep(0.1)
# Drain explicitly so we don't rely on the allocate-side drain firing.
manager._http_allocator._drain_expired_quarantine()
assert first not in manager._http_allocator._quarantined_ports
assert first in manager._http_allocator._available_ports
def test_quarantine_skipped_for_block_port(monkeypatch, port_range_constants):
"""block_port=True bypasses quarantine."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
60.0,
)
manager = NodePortManager.get_node_manager("node-quarantine-3")
port = manager.allocate_port("replica-q", RequestProtocol.HTTP)
manager.release_port("replica-q", port, RequestProtocol.HTTP, block_port=True)
assert port in manager._http_allocator._blocked_ports
assert port not in manager._http_allocator._quarantined_ports
def test_quarantine_disabled_when_zero(monkeypatch, port_range_constants):
"""Quarantine at 0 preserves immediate-reuse behavior."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
0.0,
)
manager = NodePortManager.get_node_manager("node-quarantine-4")
port = manager.allocate_port("replica-q1", RequestProtocol.HTTP)
manager.release_port("replica-q1", port, RequestProtocol.HTTP)
next_port = manager.allocate_port("replica-q2", RequestProtocol.HTTP)
assert next_port == port
def test_allocate_skips_recovered_port(port_range_constants):
"""update_port_if_missing recovers a port but leaves it in the heap;
a subsequent allocate must not hand the same port to another replica."""
manager = NodePortManager.get_node_manager("node-recover")
# Recover a replica at the smallest port in the range — heappop would
# otherwise return it first.
recovered = port_range_constants["RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT"]
manager._http_allocator.update_port_if_missing("replica-r", recovered)
next_port = manager.allocate_port("replica-new", RequestProtocol.HTTP)
assert next_port != recovered
def test_allocate_skips_quarantined_recovered_port(monkeypatch, port_range_constants):
"""A port recovered via update_port_if_missing stays in the heap; once
released into quarantine it must not be handed out again until the
quarantine expires, even though it's still in _available_ports."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
60.0,
)
manager = NodePortManager.get_node_manager("node-recover-quarantine")
alloc = manager._http_allocator
# Recover at the smallest port so heappop would otherwise return it first.
recovered = port_range_constants["RAY_SERVE_DIRECT_INGRESS_MIN_HTTP_PORT"]
alloc.update_port_if_missing("replica-r", recovered)
# Release it: goes into quarantine but is still in the heap (recovery
# never popped it).
manager.release_port("replica-r", recovered, RequestProtocol.HTTP)
assert recovered in alloc._quarantined_ports
assert recovered in alloc._available_ports # the invariant-violating state
# allocate() must not hand back the still-quarantined port.
next_port = manager.allocate_port("replica-new", RequestProtocol.HTTP)
assert next_port != recovered
assert recovered in alloc._quarantined_ports # still held
def test_prune_keeps_manager_while_port_quarantined(monkeypatch, port_range_constants):
"""A node losing its last replica must NOT have its manager (and quarantine
state) dropped while a freed port is still quarantined, and the quarantined
port must not be handed to the next replica scheduled there."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
60.0,
)
node_id = "node-prune-quarantine"
manager = NodePortManager.get_node_manager(node_id)
port = manager.allocate_port("replica-old", RequestProtocol.HTTP)
manager.release_port("replica-old", port, RequestProtocol.HTTP)
assert port in manager._http_allocator._quarantined_ports
# Node now has zero alive replicas; the manager must survive the prune.
NodePortManager.prune(node_id_to_alive_replica_ids={})
assert node_id in NodePortManager._node_managers
assert manager.has_pending_quarantine()
# The next replica on this node must not reuse the quarantined port.
new_port = manager.allocate_port("replica-new", RequestProtocol.HTTP)
assert new_port != port
def test_prune_drops_empty_manager_after_quarantine(monkeypatch, port_range_constants):
"""Once the quarantine drains, an empty node's manager is reclaimed."""
monkeypatch.setattr(
"ray.serve._private.node_port_manager.RAY_SERVE_PORT_QUARANTINE_S",
0.05,
)
node_id = "node-prune-quarantine-expire"
manager = NodePortManager.get_node_manager(node_id)
port = manager.allocate_port("replica-old", RequestProtocol.HTTP)
manager.release_port("replica-old", port, RequestProtocol.HTTP)
NodePortManager.prune(node_id_to_alive_replica_ids={})
assert node_id in NodePortManager._node_managers # still quarantined -> kept
def _manager_reclaimed():
# Drives prune each poll; True once the expired quarantine lets it go.
NodePortManager.prune(node_id_to_alive_replica_ids={})
return node_id not in NodePortManager._node_managers
wait_for_condition(_manager_reclaimed, timeout=5)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
import pickle
from unittest.mock import MagicMock
import pytest
import ray.serve._private.logging_utils as logging_utils_mod
from ray.serve._private.common import gRPCRequest
from ray.serve._private.logging_utils import access_log_msg, format_client_address
from ray.serve._private.proxy_request_response import (
ASGIProxyRequest,
ProxyRequest,
gRPCProxyRequest,
)
from ray.serve._private.test_utils import FakeGrpcContext
from ray.serve.generated import serve_pb2
class TestASGIProxyRequest:
def create_asgi_proxy_request(self, scope: dict) -> ASGIProxyRequest:
receive = MagicMock()
send = MagicMock()
return ASGIProxyRequest(scope=scope, receive=receive, send=send)
def test_request_type(self):
"""Test calling request_type on an instance of ASGIProxyRequest.
When the request_type is not passed into the scope, it returns empty string.
When the request_type is passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.request_type == ""
request_type = "fake-request_type"
proxy_request = self.create_asgi_proxy_request(scope={"type": request_type})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.request_type == request_type
def test_client(self):
"""Test calling client on an instance of ASGIProxyRequest.
When the client is not passed into the scope, it returns empty string.
When the request_type is passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.client == ""
client = "fake-client"
proxy_request = self.create_asgi_proxy_request(scope={"client": client})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.client == client
def test_method(self):
"""Test calling method on an instance of ASGIProxyRequest.
When the method is not passed into the scope, it returns "WEBSOCKET". When
the method is passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.method == "WS"
method = "fake-method"
proxy_request = self.create_asgi_proxy_request(scope={"method": method})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.method == method.upper()
def test_root_path(self):
"""Test calling root_path on an instance of ASGIProxyRequest.
When the root_path is not passed into the scope, it returns empty string.
When calling set_root_path, it correctly sets the root_path. When the
root_path is passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.root_path == ""
root_path = "fake-root_path"
proxy_request.set_root_path(root_path)
assert proxy_request.root_path == root_path
proxy_request = self.create_asgi_proxy_request(scope={"root_path": root_path})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.root_path == root_path
def test_path(self):
"""Test calling path on an instance of ASGIProxyRequest.
When the path is not passed into the scope, it returns empty string.
When calling set_path, it correctly sets the path. When the
path is passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.path == ""
path = "fake-path"
proxy_request.set_path(path)
assert proxy_request.path == path
proxy_request = self.create_asgi_proxy_request(scope={"path": path})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.path == path
def test_headers(self):
"""Test calling headers on an instance of ASGIProxyRequest.
When the headers are not passed into the scope, it returns empty list.
When the headers are passed into the scope, it returns the correct value.
"""
proxy_request = self.create_asgi_proxy_request(scope={})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.headers == []
headers = [(b"fake-header-key", b"fake-header-value")]
proxy_request = self.create_asgi_proxy_request(scope={"headers": headers})
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.headers == headers
def test_is_route_request(self):
"""Test calling is_route_request on an instance of ASGIProxyRequest.
When the is_route_request is called with `/-/routes`, it returns true.
When the is_route_request is called with other path, it returns false.
"""
scope = {"path": "/-/routes"}
proxy_request = self.create_asgi_proxy_request(scope=scope)
assert proxy_request.is_route_request is True
scope = {"path": "/foo"}
proxy_request = self.create_asgi_proxy_request(scope=scope)
assert proxy_request.is_route_request is False
def test_is_health_request(self):
"""Test calling is_health_request on an instance of ASGIProxyRequest.
When the is_health_request is called with `/-/healthz`, it returns true.
When the is_health_request is called with other path, it returns false.
"""
scope = {"path": "/-/healthz"}
proxy_request = self.create_asgi_proxy_request(scope=scope)
assert proxy_request.is_health_request is True
scope = {"path": "/foo"}
proxy_request = self.create_asgi_proxy_request(scope=scope)
assert proxy_request.is_health_request is False
class TestgRPCProxyRequest:
def test_calling_list_applications_method(self):
"""Test initialize gRPCProxyRequest with list applications service method.
When the gRPCProxyRequest is initialized with list application service method,
calling is_route_request should return true and calling is_health_request
should return false.
"""
context = FakeGrpcContext()
request_proto = serve_pb2.ListApplicationsRequest()
service_method = "/ray.serve.RayServeAPIService/ListApplications"
proxy_request = gRPCProxyRequest(
request_proto=request_proto,
context=context,
service_method=service_method,
stream=False,
)
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.is_route_request is True
assert proxy_request.is_health_request is False
def test_calling_healthz_method(self):
"""Test initialize gRPCProxyRequest with healthz service method.
When the gRPCProxyRequest is initialized with healthz service method, calling
is_route_request should return false and calling is_health_request
should return true.
"""
context = FakeGrpcContext()
request_proto = serve_pb2.HealthzRequest()
service_method = "/ray.serve.RayServeAPIService/Healthz"
proxy_request = gRPCProxyRequest(
request_proto=request_proto,
context=context,
service_method=service_method,
stream=False,
)
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.is_route_request is False
assert proxy_request.is_health_request is True
def test_calling_user_defined_method(self):
"""Test initialize gRPCProxyRequest with user defined service method.
When the gRPCProxyRequest is initialized with user defined service method,
all attributes should be setup accordingly. Calling both is_route_request
and is_health_request should return false. `send_request_id()` should
also work accordingly to be able to send the into back to the client.
`request_object()` generates a gRPCRequest object with the correct attributes.
"""
request_proto = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar")
application = "fake-application"
request_id = "fake-request_id"
multiplexed_model_id = "fake-multiplexed_model_id"
session_id = "fake-session_id"
metadata = (
("foo", "bar"),
("application", application),
("request_id", request_id),
("multiplexed_model_id", multiplexed_model_id),
("session_id", session_id),
)
context = MagicMock()
context.invocation_metadata.return_value = metadata
method_name = "Method1"
service_method = f"/custom.defined.Service/{method_name}"
proxy_request = gRPCProxyRequest(
request_proto=request_proto,
context=context,
service_method=service_method,
stream=MagicMock(),
)
assert isinstance(proxy_request, ProxyRequest)
assert proxy_request.route_path == application
assert proxy_request.method_name == method_name
assert proxy_request.app_name == application
assert proxy_request.request_id == request_id
assert proxy_request.multiplexed_model_id == multiplexed_model_id
assert proxy_request.session_id == session_id
assert proxy_request.is_route_request is False
assert proxy_request.is_health_request is False
proxy_request.send_request_id(request_id=request_id)
assert proxy_request.ray_serve_grpc_context.trailing_metadata() == [
("request_id", request_id)
]
serialized_arg = proxy_request.serialized_replica_arg()
assert isinstance(serialized_arg, bytes)
request_object = pickle.loads(serialized_arg)
assert isinstance(request_object, gRPCRequest)
assert request_object.user_request_proto == request_proto
class TestGRPCProxyRequestClient:
"""Tests for gRPCProxyRequest.client property."""
def _make_request(self, peer_value):
context = MagicMock()
context.peer.return_value = peer_value
context.invocation_metadata.return_value = ()
return gRPCProxyRequest(
request_proto=MagicMock(),
context=context,
service_method="/ray.serve.RayServeAPIService/Healthz",
stream=False,
)
def test_ipv4_peer(self):
req = self._make_request("ipv4:127.0.0.1:54321")
assert req.client == "127.0.0.1:54321"
def test_ipv6_peer(self):
# gRPC URL-encodes brackets in IPv6 peer addresses
req = self._make_request("ipv6:%5B::1%5D:54321")
assert req.client == "[::1]:54321"
def test_none_peer(self):
req = self._make_request(None)
assert req.client == ""
def test_empty_peer(self):
req = self._make_request("")
assert req.client == ""
class TestFormatClientAddress:
"""Tests for format_client_address in proxy.py."""
def test_tuple(self):
assert format_client_address(("10.0.0.1", 54321)) == "10.0.0.1:54321"
def test_list(self):
assert format_client_address(["10.0.0.1", 54321]) == "10.0.0.1:54321"
def test_string(self):
assert format_client_address("10.0.0.1:54321") == "10.0.0.1:54321"
def test_empty_string(self):
assert format_client_address("") == ""
def test_none(self):
assert format_client_address(None) == ""
def test_ipv6_tuple(self):
assert format_client_address(("::1", 54321)) == "[::1]:54321"
def test_ipv6_full_tuple(self):
assert format_client_address(("2001:db8::1", 8080)) == "[2001:db8::1]:8080"
def test_ipv6_string_passthrough(self):
assert format_client_address("[::1]:54321") == "[::1]:54321"
class TestAccessLogMsg:
"""Tests for access_log_msg formatting."""
def test_without_client(self):
msg = access_log_msg(method="GET", route="/", status="200", latency_ms=1.0)
assert msg == "GET / 200 1.0ms"
def test_with_client_flag_enabled(self, monkeypatch):
monkeypatch.setattr(logging_utils_mod, "RAY_SERVE_LOG_CLIENT_ADDRESS", True)
msg = access_log_msg(
method="GET",
route="/",
status="200",
latency_ms=1.0,
client="10.0.0.1:54321",
)
assert msg == "10.0.0.1:54321 GET / 200 1.0ms"
def test_with_client_flag_disabled(self):
msg = access_log_msg(
method="GET",
route="/",
status="200",
latency_ms=1.0,
client="10.0.0.1:54321",
)
assert msg == "GET / 200 1.0ms"
def test_with_empty_client(self):
msg = access_log_msg(
method="POST", route="/api", status="500", latency_ms=25.3, client=""
)
assert msg == "POST /api 500 25.3ms"
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,227 @@
import pytest
from ray.serve._private.common import DeploymentID, EndpointInfo
from ray.serve._private.proxy_router import (
NO_REPLICAS_MESSAGE,
NO_ROUTES_MESSAGE,
ProxyRouter,
)
from ray.serve._private.test_utils import MockDeploymentHandle
@pytest.fixture
def mock_router():
def mock_get_handle(endpoint, info):
return MockDeploymentHandle(endpoint.name, endpoint.app_name)
yield ProxyRouter(mock_get_handle)
def test_no_match(mock_router: ProxyRouter):
router = mock_router
router.update_routes(
{
DeploymentID(name="endpoint", app_name="default"): EndpointInfo(
route="/hello"
),
DeploymentID(name="endpoint2", app_name="app2"): EndpointInfo(
route="/hello2"
),
},
)
assert router.match_route("/nonexistent") is None
assert router.get_handle_for_endpoint("/nonexistent") is None
def test_default_route(mock_router: ProxyRouter):
router = mock_router
router.update_routes(
{
DeploymentID(name="endpoint", app_name="default"): EndpointInfo(
route="/endpoint"
),
DeploymentID(name="endpoint2", app_name="app2"): EndpointInfo(
route="/endpoint2"
),
},
)
# Route based matching
assert router.match_route("/nonexistent") is None
route, handle, app_is_cross_language = router.match_route("/endpoint")
assert route == "/endpoint"
assert handle == ("endpoint", "default")
assert not app_is_cross_language
# Endpoint based matching
assert router.get_handle_for_endpoint("/nonexistent") is None
route, handle, app_is_cross_language = router.get_handle_for_endpoint("default")
assert route == "/endpoint"
assert handle == ("endpoint", "default")
assert not app_is_cross_language
def test_trailing_slash(mock_router: ProxyRouter):
router = mock_router
router.update_routes(
{
DeploymentID(name="endpoint", app_name="default"): EndpointInfo(
route="/test"
)
},
)
route, handle, _ = router.match_route("/test/")
assert route == "/test" and handle == ("endpoint", "default")
router.update_routes(
{
DeploymentID(name="endpoint", app_name="default"): EndpointInfo(
route="/test/"
)
},
)
assert router.match_route("/test") is None
def test_prefix_match(mock_router):
router = mock_router
router.update_routes(
{
DeploymentID(name="endpoint1", app_name="default"): EndpointInfo(
route="/test/test2"
),
DeploymentID(name="endpoint2", app_name="default"): EndpointInfo(
route="/test"
),
DeploymentID(name="endpoint3", app_name="default"): EndpointInfo(route="/"),
},
)
route, handle, _ = router.match_route("/test/test2/subpath")
assert route == "/test/test2" and handle == ("endpoint1", "default")
route, handle, _ = router.match_route("/test/test2/")
assert route == "/test/test2" and handle == ("endpoint1", "default")
route, handle, _ = router.match_route("/test/test2")
assert route == "/test/test2" and handle == ("endpoint1", "default")
route, handle, _ = router.match_route("/test/subpath")
assert route == "/test" and handle == ("endpoint2", "default")
route, handle, _ = router.match_route("/test/")
assert route == "/test" and handle == ("endpoint2", "default")
route, handle, _ = router.match_route("/test")
assert route == "/test" and handle == ("endpoint2", "default")
route, handle, _ = router.match_route("/test2")
assert route == "/" and handle == ("endpoint3", "default")
route, handle, _ = router.match_route("/")
assert route == "/" and handle == ("endpoint3", "default")
def test_update_routes(mock_router):
router = mock_router
router.update_routes(
{DeploymentID("endpoint", "app1"): EndpointInfo(route="/endpoint")},
)
route, handle, app_is_cross_language = router.match_route("/endpoint")
assert route == "/endpoint"
assert handle == ("endpoint", "app1")
assert not app_is_cross_language
route, handle, app_is_cross_language = router.get_handle_for_endpoint("app1")
assert route == "/endpoint"
assert handle == ("endpoint", "app1")
assert not app_is_cross_language
router.update_routes(
{
DeploymentID(name="endpoint2", app_name="app2"): EndpointInfo(
route="/endpoint2",
app_is_cross_language=True,
),
DeploymentID(name="endpoint3", app_name="app3"): EndpointInfo(
route="/endpoint3",
app_is_cross_language=True,
),
},
)
assert router.match_route("/endpoint") is None
assert router.match_route("app1") is None
route, handle, app_is_cross_language = router.match_route("/endpoint2")
assert route == "/endpoint2"
assert handle == ("endpoint2", "app2")
assert app_is_cross_language
route, handle, app_is_cross_language = router.get_handle_for_endpoint("app2")
assert route == "/endpoint2"
assert handle == ("endpoint2", "app2")
assert app_is_cross_language
class TestReadyForTraffic:
@pytest.mark.parametrize("is_head", [False, True])
def test_route_table_not_populated(self, mock_router, is_head: bool):
"""Proxy router should NOT be ready for traffic if:
- it has not received route table from controller
"""
ready_for_traffic, msg = mock_router.ready_for_traffic(is_head=is_head)
assert not ready_for_traffic
assert msg == NO_ROUTES_MESSAGE
def test_head_route_table_populated_no_replicas(self, mock_router):
"""Proxy router should be ready for traffic if:
- it has received route table from controller
- it hasn't received any replicas yet
- it lives on head node
"""
d_id = DeploymentID(name="A", app_name="B")
mock_router.update_routes({d_id: EndpointInfo(route="/")})
mock_router.handles[d_id].set_running_replicas_populated(False)
ready_for_traffic, msg = mock_router.ready_for_traffic(is_head=True)
assert ready_for_traffic
assert not msg
def test_worker_route_table_populated_no_replicas(self, mock_router):
"""Proxy router should NOT be ready for traffic if:
- it has received route table from controller
- it hasn't received any replicas yet
- it lives on a worker node
"""
d_id = DeploymentID(name="A", app_name="B")
mock_router.update_routes({d_id: EndpointInfo(route="/")})
mock_router.handles[d_id].set_running_replicas_populated(False)
ready_for_traffic, msg = mock_router.ready_for_traffic(is_head=False)
assert not ready_for_traffic
assert msg == NO_REPLICAS_MESSAGE
@pytest.mark.parametrize("is_head", [False, True])
def test_route_table_populated_with_replicas(self, mock_router, is_head: bool):
"""Proxy router should be ready for traffic if:
- it has received route table from controller
- it has received replicas from controller
"""
d_id = DeploymentID(name="A", app_name="B")
mock_router.update_routes({d_id: EndpointInfo(route="/")})
mock_router.handles[d_id].set_running_replicas_populated(True)
ready_for_traffic, msg = mock_router.ready_for_traffic(is_head=is_head)
assert ready_for_traffic
assert not msg
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,977 @@
import json
from typing import List, Optional, Tuple
from unittest import mock
from unittest.mock import patch
import pytest
from ray._common.test_utils import wait_for_condition
from ray._common.utils import Timer
from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache
from ray.serve._private.common import RequestProtocol
from ray.serve._private.constants import (
PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD,
RAY_SERVE_FALLBACK_PROXY_GRPC_PORT,
RAY_SERVE_FALLBACK_PROXY_HTTP_PORT,
)
from ray.serve._private.proxy_state import ProxyState, ProxyStateManager, ProxyWrapper
from ray.serve._private.test_utils import MockTimer
from ray.serve.config import HTTPOptions, ProxyLocation
from ray.serve.schema import LoggingConfig, ProxyStatus
HEAD_NODE_ID = "node_id-index-head"
class MockClusterNodeInfoCache:
def __init__(self):
self.alive_nodes = []
def get_alive_nodes(self):
return self.alive_nodes
def get_alive_node_ids(self):
return {node_id for node_id, _, _ in self.alive_nodes}
class FakeProxyActor:
def __init__(self, *args, **kwargs):
pass
def ready(self):
return json.dumps(["mock_worker_id", "mock_log_file_path"])
def check_health(self):
pass
class FakeProxyWrapper(ProxyWrapper):
def __init__(self, *args, **kwargs):
self.actor_handle = FakeProxyActor(*args, **kwargs)
self.is_ready_response = None
self.is_healthy_response = None
self.is_drained_response = False
self.worker_id = "mock_worker_id"
self.log_file_path = "mock_log_file_path"
self.shutdown = False
self.num_health_checks = 0
self.num_drain_checks = 0
@property
def actor_id(self) -> str:
pass
def is_ready(self, timeout_s: float) -> Optional[bool]:
return self.is_ready_response
def is_healthy(self, timeout_s: float) -> Optional[bool]:
self.num_health_checks += 1
return self.is_healthy_response
def is_drained(self, timeout_s: float) -> Optional[bool]:
self.num_drain_checks += 1
return self.is_drained_response
def is_shutdown(self):
return self.shutdown
def update_draining(self, draining: bool):
pass
def kill(self):
self.shutdown = True
def get_num_health_checks(self):
return self.num_health_checks
def get_num_drain_checks(self):
return self.num_health_checks
def _create_proxy_state_manager(
http_options: HTTPOptions = HTTPOptions(),
head_node_id: str = HEAD_NODE_ID,
cluster_node_info_cache=MockClusterNodeInfoCache(),
actor_proxy_wrapper_class=FakeProxyWrapper,
timer=Timer(),
running_native_proxies: bool = False,
proxy_location=None,
) -> (ProxyStateManager, ClusterNodeInfoCache):
return (
ProxyStateManager(
http_options=http_options,
head_node_id=head_node_id,
cluster_node_info_cache=cluster_node_info_cache,
logging_config=LoggingConfig(),
actor_proxy_wrapper_class=actor_proxy_wrapper_class,
timer=timer,
running_native_proxies=running_native_proxies,
proxy_location=proxy_location,
),
cluster_node_info_cache,
)
def _create_proxy_state(
actor_proxy_wrapper_class=FakeProxyWrapper,
status: ProxyStatus = ProxyStatus.STARTING,
node_id: str = "mock_node_id",
timer=Timer(),
**kwargs,
) -> ProxyState:
state = ProxyState(
actor_proxy_wrapper=actor_proxy_wrapper_class(),
actor_name="alice",
node_id=node_id,
node_ip="mock_node_ip",
node_instance_id="mock_instance_id",
timer=timer,
)
state._set_status(status=status)
return state
@pytest.fixture
def number_of_worker_nodes() -> int:
return 100
@pytest.fixture
def all_nodes(number_of_worker_nodes) -> List[Tuple[str, str, str]]:
return [(HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")] + [
(f"worker-node-id-{i}", f"fake-worker-ip-{i}", f"fake-instance-id-{i}")
for i in range(number_of_worker_nodes)
]
def _reconcile_and_check_proxy_status(state: ProxyState, status: ProxyStatus):
state.reconcile()
assert state.status == status
return True
def _update_and_check_proxy_state_manager(
proxy_state_manager: ProxyStateManager,
node_ids: List[str],
statuses: List[ProxyStatus],
**kwargs,
):
proxy_state_manager.update(**kwargs)
proxy_states = proxy_state_manager._proxy_states
assert all(
[
proxy_states[node_ids[idx]].status == statuses[idx]
for idx in range(len(node_ids))
]
), [proxy_state.status for proxy_state in proxy_states.values()]
return True
def test_node_selection_via_proxy_location(all_nodes):
# `proxy_location` is the placement authority (threaded separately from the
# deprecated HTTPOptions.location); covers HTTP and gRPC (single ProxyActor).
all_node_ids = {node_id for node_id, _, _ in all_nodes}
def mgr(**kwargs):
psm, cache = _create_proxy_state_manager(**kwargs)
cache.alive_nodes = all_nodes
return psm
assert (
mgr(proxy_location=ProxyLocation.Disabled)._get_target_nodes(all_node_ids) == []
)
assert (
mgr(proxy_location=ProxyLocation.HeadOnly)._get_target_nodes(all_node_ids)
== all_nodes[:1]
)
assert (
mgr(proxy_location=ProxyLocation.EveryNode)._get_target_nodes(all_node_ids)
== all_nodes
)
# Default (neither location nor proxy_location) resolves to EveryNode.
assert mgr()._get_target_nodes(all_node_ids) == all_nodes
assert mgr().get_proxy_location() == ProxyLocation.EveryNode
# Explicit (deprecated) HTTPOptions.location overrides proxy_location.
with pytest.warns(DeprecationWarning, match="`location` in HTTPOptions"):
override = HTTPOptions(location=ProxyLocation.HeadOnly)
psm = mgr(http_options=override, proxy_location=ProxyLocation.EveryNode)
assert psm._get_target_nodes(all_node_ids) == all_nodes[:1]
assert psm.get_proxy_location() == ProxyLocation.HeadOnly
def test_node_selection(all_nodes):
all_node_ids = {node_id for node_id, _, _ in all_nodes}
# Test NoServer
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.Disabled)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == []
# Test HeadOnly
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.HeadOnly)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == all_nodes[:1]
# Test EveryNode
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes(all_node_ids) == all_nodes
# Test specific nodes
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
assert proxy_state_manager._get_target_nodes({HEAD_NODE_ID}) == [
(HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")
]
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_manager_restarts_unhealthy_proxies(all_nodes):
"""Test the update method in ProxyStateManager would
kill and restart unhealthy proxies.
"""
timer = MockTimer()
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
timer=timer
)
cluster_node_info_cache.alive_nodes = all_nodes
# First iteration, refresh state
proxy_state_manager.update(proxy_nodes={HEAD_NODE_ID})
prev_proxy_state = proxy_state_manager._proxy_states[HEAD_NODE_ID]
# Mark existing head-node proxy UNHEALTHY
prev_proxy_state._set_status(ProxyStatus.UNHEALTHY)
old_proxy = prev_proxy_state.actor_handle
# Continuously trigger update and wait for status to be changed to HEALTHY.
for _ in range(1):
proxy_state_manager.update(proxy_nodes={HEAD_NODE_ID})
# Advance timer by 5 (to perform a health-check)
timer.advance(5)
new_proxy_state = proxy_state_manager._proxy_states[HEAD_NODE_ID]
# Previous proxy's state stays UNHEALTHY
assert prev_proxy_state.status == ProxyStatus.UNHEALTHY
# Ensure the old proxy is getting shutdown.
assert prev_proxy_state._shutting_down
# New proxy's state should be STARTING
assert new_proxy_state.status == ProxyStatus.STARTING
assert new_proxy_state.proxy_restart_count == 1
new_proxy = new_proxy_state.actor_handle
# Ensure the new proxy is completely different object than old proxy.
assert new_proxy != old_proxy
def test_proxy_state_reconcile_shutting_down():
proxy_state = _create_proxy_state()
previous_status = proxy_state.status
proxy_state.shutdown()
# This should be no-op. The status of the http proxy state will not be changed.
proxy_state.reconcile()
current_status = proxy_state.status
# Ensure the proxy state is in the shutting down state.
assert proxy_state._shutting_down
# Ensure the status didn't change.
assert previous_status == current_status
def test_proxy_state_reconcile_readiness_check_succeed():
proxy_state = _create_proxy_state()
# Configure is_ready to be true
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# Ensure actor_details are set to the initial state when the proxy_state is created.
assert proxy_state.actor_details.worker_id is None
assert proxy_state.actor_details.log_file_path is None
assert proxy_state.actor_details.status == ProxyStatus.STARTING.value
# Continuously trigger update and wait for status to be changed.
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
# Ensure actor_details are updated.
assert proxy_state.actor_details.worker_id == "mock_worker_id"
assert proxy_state.actor_details.log_file_path == "mock_log_file_path"
assert proxy_state.actor_details.status == ProxyStatus.HEALTHY.value
def test_proxy_state_reconcile_readiness_check_pending():
proxy_state = _create_proxy_state()
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# When the proxy readiness check is pending, the proxy wrapper is_ready
# will return None
proxy_state._actor_proxy_wrapper.is_ready_response = None
# Trigger update. The status do not change, while readiness check is pending
for _ in range(10):
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.STARTING
# Unblock is_ready call, trigger update, and wait for status change to HEALTHY.
proxy_state._actor_proxy_wrapper.is_ready_response = True
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
def test_proxy_state_reconcile_readiness_check_fails():
proxy_state = _create_proxy_state()
# Emulate readiness check failure
proxy_state._actor_proxy_wrapper.is_ready_response = False
# Ensure the proxy status before update is STARTING.
assert proxy_state.status == ProxyStatus.STARTING
# First failure shouldn't trigger state-transition to UNHEALTHY
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.STARTING
# After PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD failures, state should
# transition to UNHEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.UNHEALTHY
@patch("ray.serve._private.proxy_state.PROXY_READY_CHECK_TIMEOUT_S", 5)
def test_proxy_state_reconcile_health_check_succeed():
timer = MockTimer()
proxy_state = _create_proxy_state(time=timer)
# Emulate readiness check succeeding
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Should transition to HEALTHY
proxy_state.reconcile()
assert proxy_state.status == ProxyStatus.HEALTHY
# Trigger update few more times and the status continue to be HEALTHY.
for _ in range(10):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5s
timer.advance(5)
# Health-checks should have been performed on every iteration
assert proxy_state._actor_proxy_wrapper.num_health_checks == 10
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_health_check_transient_failures():
timer = MockTimer()
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks failing
proxy_state._actor_proxy_wrapper.is_healthy_response = False
# Reconcile PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1 times, state should
# continue to stay HEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5 (to trigger new health-check)
timer.advance(5)
assert (
proxy_state._actor_proxy_wrapper.get_num_health_checks()
== PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1
)
# Simulate health-checks passing
proxy_state._actor_proxy_wrapper.is_healthy_response = True
wait_for_condition(
condition_predictor=_reconcile_and_check_proxy_status,
state=proxy_state,
status=ProxyStatus.HEALTHY,
)
# Ensure _consecutive_health_check_failures is reset
assert proxy_state._consecutive_health_check_failures == 0
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_health_check_persistent_failures():
timer = MockTimer()
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks failing
proxy_state._actor_proxy_wrapper.is_healthy_response = False
# Reconcile PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1 times, state should
# continue to stay HEALTHY
for _ in range(PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1):
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.HEALTHY)
# Advance timer by 5 (to trigger new health-check)
timer.advance(5)
assert (
proxy_state._actor_proxy_wrapper.get_num_health_checks()
== PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD - 1
)
# On PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD iteration, state transitions to
# UNHEALTHY
_reconcile_and_check_proxy_status(proxy_state, ProxyStatus.UNHEALTHY)
# Ensure _consecutive_health_check_failures is correct
assert proxy_state._consecutive_health_check_failures == 3
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 0)
@pytest.mark.parametrize("number_of_worker_nodes", [0, 1, 2, 3])
def test_proxy_manager_update_proxies_states(all_nodes, number_of_worker_nodes):
"""Test update draining logics.
When update nodes to inactive, head node http proxy should never be draining while
worker node http proxy should change to draining. When update nodes to active, head
node http proxy should continue to be healthy while worker node http proxy should
be healthy.
"""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
node_ids = [node_id for node_id, _, _ in all_nodes]
# No target proxy nodes
proxy_nodes = {HEAD_NODE_ID}
# Head node proxy should continue to be HEALTHY.
# Worker node proxy should turn DRAINING.
wait_for_condition(
condition_predictor=_update_and_check_proxy_state_manager,
proxy_state_manager=manager,
node_ids=node_ids,
statuses=[ProxyStatus.HEALTHY]
+ [ProxyStatus.DRAINING] * number_of_worker_nodes,
proxy_nodes=proxy_nodes,
)
# All nodes are target proxy nodes
proxy_nodes = set(node_ids)
# Head node proxy should continue to be HEALTHY.
# Worker node proxy should turn HEALTHY.
wait_for_condition(
condition_predictor=_update_and_check_proxy_state_manager,
proxy_state_manager=manager,
node_ids=node_ids,
statuses=[ProxyStatus.HEALTHY] * (number_of_worker_nodes + 1),
proxy_nodes=proxy_nodes,
)
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_PERIOD_S", 5)
def test_proxy_state_reconcile_draining_success():
"""Test that the proxy will remain DRAINING even if health check succeeds."""
timer = MockTimer(start_time=0)
# Start with HEALTHY state
proxy_state = _create_proxy_state(status=ProxyStatus.HEALTHY, timer=timer)
# Simulate health-checks passing
proxy_state._actor_proxy_wrapper.is_healthy_response = True
# Simulate is_drained returning false
proxy_state._actor_proxy_wrapper.is_drained_response = False
for _ in range(10):
proxy_state.reconcile(draining=True)
assert proxy_state.status == ProxyStatus.DRAINING
# Advance timer by 5 (to trigger new health-check, drain-check)
timer.advance(5)
assert proxy_state._actor_proxy_wrapper.get_num_health_checks() == 10
assert proxy_state._actor_proxy_wrapper.get_num_drain_checks() == 10
# Make sure the status is still DRAINING
assert proxy_state.status == ProxyStatus.DRAINING
# Simulate is_drained request to ProxyActor pending (for 5 iterations)
proxy_state._actor_proxy_wrapper.is_drained_response = None
for _ in range(5):
proxy_state.reconcile(draining=True)
assert proxy_state.status == ProxyStatus.DRAINING
# Advance timer by 5 (to trigger new health-check, drain-check)
timer.advance(5)
assert proxy_state._actor_proxy_wrapper.get_num_health_checks() == 15
# No new drain checks will occur, since there's a pending one (not completed yet)
assert proxy_state._actor_proxy_wrapper.get_num_drain_checks() == 15
# Simulate draining completed
proxy_state._actor_proxy_wrapper.is_drained_response = True
# Advance timer by 5 (to trigger new health-check, drain-check on next iteration)
timer.advance(5)
proxy_state.reconcile(draining=True)
# State should transition to DRAINED
assert proxy_state.status == ProxyStatus.DRAINED
@patch("ray.serve._private.proxy_state.PROXY_DRAIN_CHECK_PERIOD_S", 5)
@pytest.mark.parametrize("number_of_worker_nodes", [1])
def test_proxy_actor_manager_removing_proxies(all_nodes, number_of_worker_nodes):
"""Test the state transition from DRAINING to UNHEALTHY for the proxy actor."""
assert len(all_nodes) == 2, "There should be 2 nodes in this test"
timer = MockTimer(start_time=0)
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode),
timer=timer,
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.STARTING,
node_id=node_id,
timer=timer,
)
manager._proxy_states[node_id]._actor_proxy_wrapper.is_ready_response = True
# All nodes are target proxy nodes
node_ids = [node_id for node_id, _, _ in all_nodes]
worker_node_id = node_ids[1]
worker_proxy_state = manager._proxy_states[worker_node_id]
# Reconcile all proxies states
manager.update(
proxy_nodes=(set(node_ids)),
)
# Assert all proxies are HEALTHY
proxy_statuses = [manager._proxy_states[node_id].status for node_id in node_ids]
assert [ProxyStatus.HEALTHY, ProxyStatus.HEALTHY] == proxy_statuses
# Reconcile proxies with empty set of target nodes (ie only proxy on the head-node
# should be preserved all the other should be drained)
worker_proxy_state._actor_proxy_wrapper.is_drained_response = False
N = 10
for _ in range(N):
manager.update(
proxy_nodes={HEAD_NODE_ID},
)
timer.advance(5)
# Assert that
# - Head-node proxy is HEALTHY4
# - Worker node proxy is DRAINING
proxy_statuses = [manager._proxy_states[node_id].status for node_id in node_ids]
assert [ProxyStatus.HEALTHY, ProxyStatus.DRAINING] == proxy_statuses
assert worker_proxy_state._actor_proxy_wrapper.get_num_drain_checks() == N
# Mark target proxy as fully drained
worker_proxy_state._actor_proxy_wrapper.is_drained_response = True
# Reconcile proxies with empty set of target nodes (worker node proxy
# will be shutdown by now)
manager.update(
proxy_nodes={HEAD_NODE_ID},
)
assert len(manager._proxy_states) == 1
assert manager._proxy_states[HEAD_NODE_ID].status == ProxyStatus.HEALTHY
def test_is_ready_for_shutdown(all_nodes):
"""Test `is_ready_for_shutdown()` returns True the correct state.
Before `shutdown()` is called, `is_ready_for_shutdown()` should return false. After
`shutdown()` is called and all proxy actor are killed, `is_ready_for_shutdown()`
should return true.
"""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
# Ensure before shutdown, manager is not shutdown
assert not manager.is_ready_for_shutdown()
manager.shutdown()
# Ensure after shutdown, manager is shutdown and all proxy states are shutdown
def check_is_ready_for_shutdown():
return manager.is_ready_for_shutdown()
wait_for_condition(check_is_ready_for_shutdown)
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD", 1)
@pytest.mark.parametrize("number_of_worker_nodes", [1])
def test_proxy_state_manager_timing_out_on_start(number_of_worker_nodes, all_nodes):
"""Test update method on ProxyStateManager when the proxy state is STARTING and
when the ready call takes longer than PROXY_READY_CHECK_TIMEOUT_S.
The proxy state started with STARTING. After update is called, ready calls takes
some time to finish. The proxy state manager will restart the proxy state after
PROXY_READY_CHECK_TIMEOUT_S. After the next period of check_health call,
the proxy state manager will check on backoff timeout, not immediately
restarting the proxy states, and eventually set the proxy state to HEALTHY.
"""
fake_time = MockTimer()
proxy_state_manager, cluster_node_info_cache = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.EveryNode),
timer=fake_time,
)
cluster_node_info_cache.alive_nodes = all_nodes
node_ids = {node[0] for node in all_nodes}
# Run update to create proxy states.
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure 2 proxies are created, one for the head node and another for the worker.
assert len(proxy_state_manager._proxy_states) == len(node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
assert proxy_state.status == ProxyStatus.STARTING
proxy_state._actor_proxy_wrapper.is_ready_response = False
# Capture current proxy states (prior to updating)
prev_proxy_states = dict(proxy_state_manager._proxy_states)
# Trigger PSM to reconcile
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
prev_proxy_state = prev_proxy_states[node_id]
# Assert
# - All proxies are restarted
# - Previous proxy states are UNHEALTHY
# - New proxy states are STARTING
assert proxy_state != prev_proxy_state
assert prev_proxy_state.status == ProxyStatus.UNHEALTHY
assert proxy_state.status == ProxyStatus.STARTING
# Mark new proxy readiness checks as passing
proxy_state._actor_proxy_wrapper.is_ready_response = True
# Capture current proxy states again (prior to updating)
prev_proxy_states = dict(proxy_state_manager._proxy_states)
# Trigger PSM to reconcile
proxy_state_manager.update(proxy_nodes=node_ids)
# Ensure the proxy state statuses before update are STARTING, set the
# readiness check to failing
for node_id in node_ids:
proxy_state = proxy_state_manager._proxy_states[node_id]
prev_proxy_state = prev_proxy_states[node_id]
# Assert
# - All proxies are restarted
# - Previous proxy states are UNHEALTHY
# - New proxy states are STARTING
assert proxy_state == prev_proxy_state
assert prev_proxy_state.status == ProxyStatus.HEALTHY
assert proxy_state.status == ProxyStatus.HEALTHY
def test_proxy_state_manager_get_targets(all_nodes):
"""Test the get_targets method on ProxyStateManager."""
manager, cluster_node_info_cache = _create_proxy_state_manager(
HTTPOptions(location=ProxyLocation.EveryNode)
)
cluster_node_info_cache.alive_nodes = all_nodes
for node_id, _, _ in all_nodes:
manager._proxy_states[node_id] = _create_proxy_state(
status=ProxyStatus.HEALTHY,
node_id=node_id,
)
manager._proxy_states[all_nodes[-1][0]].try_update_status(ProxyStatus.DRAINED)
targets = manager.get_targets(RequestProtocol.HTTP)
assert len(targets) == len(all_nodes) - 1
assert targets[0].ip == "mock_node_ip"
assert targets[0].port == 8000
assert targets[0].instance_id == "mock_instance_id"
assert targets[0].name == "alice"
targets = manager.get_targets(RequestProtocol.GRPC)
assert len(targets) == 0
with pytest.raises(ValueError):
manager.get_targets("invalid_protocol")
class TestFallbackProxy:
"""Tests for fallback proxy lifecycle management in ProxyStateManager."""
HEAD_NODE = (HEAD_NODE_ID, "fake-head-ip", "fake-head-instance-id")
WORKER_NODE = ("worker-node-id-0", "fake-worker-ip-0", "fake-worker-instance-id-0")
def _create_manager_with_fallback(self, timer=None):
"""Helper to create a ProxyStateManager with running_native_proxies=True."""
timer = timer or Timer()
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE, self.WORKER_NODE]
manager, cluster_node_info_cache = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=True,
timer=timer,
)
return manager, cluster_node_info_cache
def test_fallback_proxy_starts_on_head_node(self):
"""When running_native_proxies=True, a fallback proxy should be
started on the head node."""
manager, _ = self._create_manager_with_fallback()
assert manager._fallback_proxy_state is None
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state._node_id == HEAD_NODE_ID
assert manager._fallback_proxy_state._status == ProxyStatus.STARTING
def test_no_fallback_proxy_when_not_native(self):
"""When running_native_proxies=False, no fallback proxy should be started."""
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE]
manager, _ = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=False,
)
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is None
def test_fallback_proxy_uses_correct_ports(self):
"""The fallback proxy should use the fallback ports (8500/9500),
not the default proxy ports (8000/9000)."""
cache = MockClusterNodeInfoCache()
cache.alive_nodes = [self.HEAD_NODE, self.WORKER_NODE]
manager, _ = _create_proxy_state_manager(
http_options=HTTPOptions(location=ProxyLocation.HeadOnly),
cluster_node_info_cache=cache,
running_native_proxies=True,
)
with mock.patch.object(
manager, "_start_proxy", wraps=manager._start_proxy
) as mock_start:
manager.update(proxy_nodes={HEAD_NODE_ID})
fallback_calls = [
call
for call in mock_start.call_args_list
if "fallback" in call.kwargs.get("name", "")
]
assert len(fallback_calls) == 1
kwargs = fallback_calls[0].kwargs
assert kwargs["http_options"].port == RAY_SERVE_FALLBACK_PROXY_HTTP_PORT
assert kwargs["grpc_options"].port == RAY_SERVE_FALLBACK_PROXY_GRPC_PORT
def test_fallback_proxy_not_started_twice(self):
"""Calling update multiple times should not create a second fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
first_fallback = manager._fallback_proxy_state
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is first_fallback
@patch("ray.serve._private.proxy_state.PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD", 1)
def test_stop_unhealthy_fallback_proxy(self):
"""When the fallback proxy becomes unhealthy, it should be stopped
and the restart count incremented."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_restart_count == 0
manager._fallback_proxy_state._set_status(ProxyStatus.UNHEALTHY)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Unhealthy fallback proxy should be stopped and cleared
# A new one should be started in the same update cycle
assert manager._fallback_proxy_restart_count == 1
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state.status == ProxyStatus.STARTING
def test_stop_drained_fallback_proxy(self):
"""When the fallback proxy enters DRAINED status, it should be removed."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
manager._fallback_proxy_state._set_status(ProxyStatus.DRAINED)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Drained fallback proxy is stopped, then a new one is started
assert manager._fallback_proxy_restart_count == 1
assert manager._fallback_proxy_state is not None
assert manager._fallback_proxy_state.status == ProxyStatus.STARTING
def test_fallback_proxy_included_in_handles(self):
"""get_proxy_handles() should include the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
handles = manager.get_proxy_handles()
assert f"fallback-{HEAD_NODE_ID}" in handles
assert (
handles[f"fallback-{HEAD_NODE_ID}"]
is manager._fallback_proxy_state.actor_handle
)
def test_fallback_proxy_included_in_names(self):
"""get_proxy_names() should include the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
names = manager.get_proxy_names()
assert f"fallback-{HEAD_NODE_ID}" in names
assert (
names[f"fallback-{HEAD_NODE_ID}"]
== manager._fallback_proxy_state.actor_name
)
def test_fallback_proxy_included_in_alive_actor_ids(self):
"""get_alive_proxy_actor_ids() should include the fallback proxy's actor ID."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
actor_ids = manager.get_alive_proxy_actor_ids()
assert manager._fallback_proxy_state.actor_id in actor_ids
def test_fallback_proxy_blocks_shutdown_readiness(self):
"""is_ready_for_shutdown() should return False until the fallback
proxy is also shut down."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
# Shut down regular proxies but not the fallback
for proxy_state in manager._proxy_states.values():
proxy_state.shutdown()
proxy_state._actor_proxy_wrapper.shutdown = True
# Fallback proxy is still alive, so not ready for shutdown
assert not manager.is_ready_for_shutdown()
# Now shut down the fallback proxy
manager._fallback_proxy_state.shutdown()
manager._fallback_proxy_state._actor_proxy_wrapper.shutdown = True
assert manager.is_ready_for_shutdown()
def test_shutdown_includes_fallback_proxy(self):
"""shutdown() should also shut down the fallback proxy."""
manager, _ = self._create_manager_with_fallback()
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state is not None
fallback_wrapper = manager._fallback_proxy_state._actor_proxy_wrapper
manager.shutdown()
assert manager._fallback_proxy_state._shutting_down
assert fallback_wrapper.shutdown is True
def test_fallback_proxy_reconciled_during_update(self):
"""The fallback proxy should be reconciled (health checked) during each
update cycle."""
timer = MockTimer()
manager, _ = self._create_manager_with_fallback(timer=timer)
manager.update(proxy_nodes={HEAD_NODE_ID})
# Make fallback proxy ready
manager._fallback_proxy_state._actor_proxy_wrapper.is_ready_response = True
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state.status == ProxyStatus.HEALTHY
# Set health check to pass and verify it runs
manager._fallback_proxy_state._actor_proxy_wrapper.is_healthy_response = True
timer.advance(5)
manager.update(proxy_nodes={HEAD_NODE_ID})
assert manager._fallback_proxy_state.status == ProxyStatus.HEALTHY
assert manager._fallback_proxy_state._actor_proxy_wrapper.num_health_checks > 0
def test_get_fallback_proxy_targets(self):
"""get_fallback_proxy_targets() should return the fallback proxy targets
when the fallback proxy is set and healthy."""
manager, _ = self._create_manager_with_fallback()
# The fallback proxy is not set.
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 0
# The fallback proxy is not healthy.
state = _create_proxy_state()
manager._fallback_proxy_state = state
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 0
# The fallback proxy is healthy.
state.try_update_status(ProxyStatus.HEALTHY)
targets = manager.get_fallback_proxy_targets()
assert len(targets) == 1
target = targets[RequestProtocol.HTTP]
assert target.ip == state.actor_details.node_ip
assert target.port == 8500
assert target.instance_id == state.actor_details.node_instance_id
assert target.name == state.actor_name
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,79 @@
import asyncio
import sys
from unittest.mock import MagicMock
import pytest
from ray.serve._private.common import RequestMetadata
from ray.serve._private.replica import Replica
from ray.serve._private.utils import Semaphore
def _make_metadata() -> RequestMetadata:
return RequestMetadata(
request_id="test-request",
internal_request_id="test-internal-request",
is_direct_ingress=True,
)
def _make_fake_replica(max_ongoing_requests: int):
"""Minimal stand-in exposing the real slot/queue accounting methods."""
fake = MagicMock()
fake._num_queued_requests = 0
fake._semaphore = Semaphore(lambda: max_ongoing_requests)
fake._start_request = Replica._start_request.__get__(fake, Replica)
fake._track_queued_request = Replica._track_queued_request.__get__(fake, Replica)
return fake
async def _wait_for_semaphore_waiter(semaphore: Semaphore):
for _ in range(1000):
await asyncio.sleep(0)
if semaphore._waiters:
return
raise AssertionError("no coroutine blocked on the semaphore")
class TestTrackQueuedRequest:
def test_release_decrements_exactly_once(self):
fake = _make_fake_replica(max_ongoing_requests=1)
with fake._track_queued_request() as release:
assert fake._num_queued_requests == 1
release()
assert fake._num_queued_requests == 0
# Extra calls are a no-op.
release()
assert fake._num_queued_requests == 0
# Exiting the block must not decrement below zero.
assert fake._num_queued_requests == 0
@pytest.mark.asyncio
async def test_count_released_when_cancelled_while_waiting(self):
"""A request cancelled while blocked on the slot is released on block
exit, as the direct-ingress handlers wire it up."""
fake = _make_fake_replica(max_ongoing_requests=1)
# Hold the only slot so the request blocks while queued.
await fake._semaphore.acquire()
async def handler():
with fake._track_queued_request() as release:
async with fake._start_request(_make_metadata()):
release()
task = asyncio.ensure_future(handler())
await _wait_for_semaphore_waiter(fake._semaphore)
assert fake._num_queued_requests == 1
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert fake._num_queued_requests == 0
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,235 @@
import asyncio
import sys
from unittest.mock import MagicMock
import pytest
from ray.serve._private.common import RequestMetadata
from ray.serve._private.replica import Replica
def _make_metadata(*, is_direct_ingress: bool) -> RequestMetadata:
return RequestMetadata(
request_id="test-request",
internal_request_id="test-internal-request",
is_direct_ingress=is_direct_ingress,
)
class FakeUvicornServer:
"""Stand-in for `uvicorn.Server` that records when `should_exit` is set."""
def __init__(self, events):
# Write through __dict__ to avoid recursing into __setattr__.
self.__dict__["_events"] = events
self.__dict__["should_exit"] = False
def __setattr__(self, name, value):
if name == "should_exit":
self._events.append(("http_should_exit", value))
self.__dict__[name] = value
class FakeGrpcServer:
"""Stand-in for a `grpc.aio` server that records graceful stops."""
def __init__(self, events, name):
self._events = events
self._name = name
async def stop(self, grace):
self._events.append((self._name, grace))
def _make_shutdown_fake(
*,
initialized: bool = True,
with_http_server: bool = False,
http_task=None,
with_grpc_server: bool = False,
grpc_task=None,
internal_grpc_port=12345,
grace_period_s: float = 1.0,
):
"""Builds a minimal stand-in for `Replica` for `perform_graceful_shutdown`.
Returns the fake and the ordered list of events it records.
"""
events = []
fake = MagicMock()
fake._shutting_down = False
fake._quiescing = False
fake._user_callable_initialized = initialized
fake._ingress = False
fake._deployment_config.graceful_shutdown_timeout_s = grace_period_s
fake._direct_ingress_http_server = (
FakeUvicornServer(events) if with_http_server else None
)
fake._direct_ingress_http_server_task = http_task
fake._direct_ingress_grpc_server = (
FakeGrpcServer(events, "direct_ingress_grpc_stop") if with_grpc_server else None
)
fake._direct_ingress_grpc_server_task = grpc_task
fake._internal_grpc_port = internal_grpc_port
fake._server = FakeGrpcServer(events, "inter_deployment_stop")
async def drain(min_draining_period_s):
# Quiescing must only start AFTER the drain: during the drain the
# replica must keep serving normally.
assert fake._quiescing is False
events.append(("drain", min_draining_period_s))
fake._drain_ongoing_requests = drain
async def shutdown():
events.append(("shutdown",))
fake.shutdown = shutdown
return fake, events
class TestCanAcceptRequestWhileQuiescing:
@staticmethod
def _bind_quiescing(fake):
# _can_accept_request delegates the quiescing decision to
# _is_replica_quiescing; wire the real helper onto the fake so it runs
# instead of returning a truthy MagicMock.
fake._is_replica_quiescing = lambda rm: Replica._is_replica_quiescing(fake, rm)
def test_handle_path_rejected_when_quiescing(self):
fake = MagicMock()
fake._quiescing = True
self._bind_quiescing(fake)
assert (
Replica._can_accept_request(fake, _make_metadata(is_direct_ingress=False))
is False
)
def test_direct_ingress_not_rejected_when_quiescing(self):
"""Direct ingress requests must NOT be rejected while quiescing.
The HTTP/gRPC servers are shut down gracefully during quiescing, so
any direct ingress request that still arrives is served to
completion; rejecting it would surface a client-visible error
instead of a safe retry.
"""
fake = MagicMock()
fake._quiescing = True
fake.max_queued_requests = -1
fake._num_queued_requests = 0
self._bind_quiescing(fake)
assert (
Replica._can_accept_request(fake, _make_metadata(is_direct_ingress=True))
is True
)
def test_handle_path_accepted_when_not_quiescing(self):
fake = MagicMock()
fake._quiescing = False
fake._semaphore.locked.return_value = False
self._bind_quiescing(fake)
assert (
Replica._can_accept_request(fake, _make_metadata(is_direct_ingress=False))
is True
)
class TestPerformGracefulShutdown:
@pytest.mark.asyncio
async def test_quiesces_and_stops_servers_in_order(self):
"""Drain → quiesce → graceful server stops → shutdown, in order."""
loop = asyncio.get_running_loop()
http_task = loop.create_future()
# Simulate the uvicorn serve task exiting promptly once
# `should_exit` is set.
http_task.set_result(None)
grpc_task = MagicMock()
fake, events = _make_shutdown_fake(
with_http_server=True,
http_task=http_task,
with_grpc_server=True,
grpc_task=grpc_task,
grace_period_s=1.0,
)
await Replica.perform_graceful_shutdown(fake)
assert fake._shutting_down is True
assert fake._quiescing is True
assert [e[0] for e in events] == [
"drain",
"http_should_exit",
"direct_ingress_grpc_stop",
"inter_deployment_stop",
"shutdown",
]
# The grace passed to each stop is the REMAINING shutdown budget at
# that step (deadline-based), so it must be positive and within the
# configured budget.
for name, *args in events:
if name.endswith("_stop"):
assert 0.0 < args[0] <= 1.0
# The direct ingress gRPC server task is cancelled after the
# graceful stop completes.
assert grpc_task.cancel.called
@pytest.mark.asyncio
async def test_http_graceful_close_timeout_falls_back_to_cancel(self):
"""If the HTTP server doesn't exit within the grace period, the
server task is cancelled (the previous abrupt behavior)."""
loop = asyncio.get_running_loop()
# A serve task that never exits on its own.
http_task = loop.create_future()
fake, events = _make_shutdown_fake(
with_http_server=True,
http_task=http_task,
grace_period_s=0.05,
)
await Replica.perform_graceful_shutdown(fake)
# `asyncio.wait_for` cancels the awaited task on timeout.
assert http_task.cancelled()
# Shutdown still completes despite the timeout.
assert fake._quiescing is True
assert events[-1] == ("shutdown",)
@pytest.mark.asyncio
async def test_abrupt_cancel_when_server_object_missing(self):
"""If only the server task exists (no server object), fall back to
the abrupt cancel."""
http_task = MagicMock()
fake, events = _make_shutdown_fake(http_task=http_task)
await Replica.perform_graceful_shutdown(fake)
assert http_task.cancel.called
assert [e[0] for e in events] == [
"drain",
"inter_deployment_stop",
"shutdown",
]
@pytest.mark.asyncio
async def test_uninitialized_replica_skips_drain_and_server_stops(self):
"""A replica that never initialized has no servers to stop and never
served traffic, so only the final shutdown step runs."""
fake, events = _make_shutdown_fake(
initialized=False,
internal_grpc_port=None,
)
await Replica.perform_graceful_shutdown(fake)
assert events == [("shutdown",)]
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,50 @@
import pytest
from ray.serve._private.common import RequestMetadata, RequestProtocol
def test_request_metadata():
"""Test logic on RequestMetadata.
Ensure the default values are set correctly and both is_http_request and
is_grpc_request returns the correct value. When the _request_protocol is set to
HTTP, is_http_request should return True and is_grpc_request should return False.
When the _request_protocol is set to gRPC, is_http_request should return False and
is_grpc_request should return True.
"""
request_id = "request-id"
internal_request_id = "internal-request-id"
request_metadata = RequestMetadata(
request_id=request_id,
internal_request_id=internal_request_id,
)
# Ensure the default values are set correctly.
assert request_metadata.request_id == request_id
assert request_metadata.call_method == "__call__"
assert request_metadata.route == ""
assert request_metadata.app_name == ""
assert request_metadata.multiplexed_model_id == ""
assert request_metadata.session_id == ""
assert request_metadata.is_streaming is False
assert request_metadata._request_protocol == RequestProtocol.UNDEFINED
assert request_metadata.is_http_request is False
assert request_metadata.is_grpc_request is False
# is_http_request and is_grpc_request returns the correct values when the
# _request_protocol is set to HTTP.
request_metadata._request_protocol = RequestProtocol.HTTP
assert request_metadata.is_http_request is True
assert request_metadata.is_grpc_request is False
# is_http_request and is_grpc_request returns the correct values when the
# _request_protocol is set to gRPC.
request_metadata._request_protocol = RequestProtocol.GRPC
assert request_metadata.is_http_request is False
assert request_metadata.is_grpc_request is True
if __name__ == "__main__":
import sys
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
import asyncio
import sys
import pytest
from ray.serve._private.common import (
DeploymentHandleSource,
RequestMetadata,
)
from ray.serve._private.request_router import PendingRequest
from ray.serve._private.test_utils import (
FAKE_REPLICA_DEPLOYMENT_ID as DEPLOYMENT_ID,
FakeRunningReplica,
MockTimer,
)
from ray.serve._private.utils import generate_request_id
from ray.serve.experimental.round_robin_router import RoundRobinRouter
TIMER = MockTimer()
def _make_request(model_id: str = "") -> PendingRequest:
return PendingRequest(
args=[],
kwargs={},
metadata=RequestMetadata(
request_id=generate_request_id(),
internal_request_id=generate_request_id(),
multiplexed_model_id=model_id,
),
)
@pytest.fixture
def router(request) -> RoundRobinRouter:
if not hasattr(request, "param"):
request.param = {}
params = dict(request.param)
use_replica_queue_len_cache = params.pop("use_replica_queue_len_cache", False)
TIMER.reset()
request_router = RoundRobinRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="fake-actor-id",
self_actor_handle=None,
use_replica_queue_len_cache=use_replica_queue_len_cache,
get_curr_time_s=TIMER.time,
initial_backoff_s=0.001,
backoff_multiplier=1,
max_backoff_s=0.001,
)
request_router.initialize_state(**params)
yield request_router
assert request_router.curr_num_routing_tasks == 0
assert request_router.num_pending_requests == 0
def _make_replicas(*replica_ids: str, **kwargs):
replicas = [FakeRunningReplica(replica_id, **kwargs) for replica_id in replica_ids]
for replica in replicas:
replica.set_queue_len_response(0)
return replicas
def _ranked_replica_unique_ids(ranks):
return [[replica.replica_id.unique_id for replica in rank] for rank in ranks]
def test_initialize_state_ignores_router_kwargs(router):
router.initialize_state(unused=True)
def test_round_robin_counter_initialized_randomly(router):
assert 0 <= router._round_robin_counter < 2**31
@pytest.mark.asyncio
async def test_choose_replicas_round_robin_order(router):
replicas = _make_replicas("r2", "r0", "r1")
router.update_replicas(replicas)
router._round_robin_counter = 0
await asyncio.sleep(0)
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request())
) == [["r2"], ["r0"], ["r1"]]
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request())
) == [["r0"], ["r1"], ["r2"]]
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request())
) == [["r1"], ["r2"], ["r0"]]
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request())
) == [["r2"], ["r0"], ["r1"]]
@pytest.mark.asyncio
async def test_choose_replicas_ignores_request_metadata(router):
replicas = [
FakeRunningReplica("r0", model_ids={"model-a"}),
FakeRunningReplica("r1"),
FakeRunningReplica("r2", model_ids={"model-a"}),
]
for replica in replicas:
replica.set_queue_len_response(0)
router.update_replicas(replicas)
router._round_robin_counter = 0
await asyncio.sleep(0)
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request(model_id="model-a"))
) == [["r0"], ["r1"], ["r2"]]
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request(model_id="model-a"))
) == [["r1"], ["r2"], ["r0"]]
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request(model_id="model-a"))
) == [["r2"], ["r0"], ["r1"]]
@pytest.mark.parametrize(
"router", [{"use_replica_queue_len_cache": True}], indirect=True
)
@pytest.mark.asyncio
async def test_choose_replicas_includes_cached_full_replica_first(router):
replicas = [
FakeRunningReplica("r0", max_ongoing_requests=1),
FakeRunningReplica("r1", max_ongoing_requests=1),
FakeRunningReplica("r2", max_ongoing_requests=1),
]
for replica in replicas:
replica.set_queue_len_response(0)
router.update_replicas(replicas)
router.replica_queue_len_cache.update(replicas[0].replica_id, 1)
router._round_robin_counter = 0
await asyncio.sleep(0)
# Selection should preserve strict round-robin order even if fulfillment
# later rejects the cached-full replica and advances to the next rank.
assert _ranked_replica_unique_ids(
await router.choose_replicas(replicas, _make_request())
) == [["r0"], ["r1"], ["r2"]]
@pytest.mark.asyncio
async def test_choose_replica_falls_back_when_current_candidate_full(router):
replicas = [
FakeRunningReplica("r0", max_ongoing_requests=1),
FakeRunningReplica("r1"),
FakeRunningReplica("r2"),
]
replicas[0].set_queue_len_response(1)
replicas[1].set_queue_len_response(0)
replicas[2].set_queue_len_response(0)
router.update_replicas(replicas)
router._round_robin_counter = 0
await asyncio.sleep(0)
chosen = await router._choose_replica_for_request(_make_request())
assert chosen.replica_id.unique_id == "r1"
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_choose_replicas_empty_candidates(router):
counter = router._round_robin_counter
assert await router.choose_replicas([], _make_request()) == []
assert router._round_robin_counter == counter
@pytest.mark.asyncio
async def test_choose_replicas_sets_should_backoff(router):
# Without should_backoff, the base class tight-loops choose_replicas when
# every replica is at capacity (see _choose_replicas_with_backoff).
replicas = _make_replicas("r0", "r1")
router.update_replicas(replicas)
await asyncio.sleep(0)
request = _make_request()
assert request.routing_context.should_backoff is False
await router.choose_replicas(replicas, request)
assert request.routing_context.should_backoff is True
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,319 @@
import sys
import uuid
from typing import Any, Dict, List
from unittest.mock import MagicMock, call
import pytest
from ray.serve.api import deployment
from ray.serve.schema import (
CeleryAdapterConfig,
TaskProcessorAdapter,
TaskProcessorConfig,
TaskResult,
)
from ray.serve.task_consumer import task_consumer, task_handler
class MockTaskProcessorAdapter(TaskProcessorAdapter):
"""Mock adapter for testing task processor functionality."""
def __init__(self, config: TaskProcessorConfig):
self._config = config
self._start_consumer_received: bool = False
self._stop_consumer_received: bool = False
self.register_task_handle_mock = MagicMock()
def initialize(self, consumer_concurrency: int = 3):
pass
def register_task_handle(self, func, name=None):
self.register_task_handle_mock(func, name=name)
def enqueue_task_sync(
self, task_name, args=None, kwargs=None, **options
) -> TaskResult:
pass
def get_task_status_sync(self, task_id) -> TaskResult:
pass
def start_consumer(self, **kwargs):
self._start_consumer_received = True
def stop_consumer(self, timeout: float = 10.0):
self._stop_consumer_received = True
def cancel_task_sync(self, task_id) -> bool:
pass
def get_metrics_sync(self) -> Dict[str, Any]:
pass
def health_check_sync(self) -> List[Dict]:
pass
@pytest.fixture
def config():
"""Provides a mock TaskProcessorConfig."""
queue_name = f"test_queue_{uuid.uuid4().hex}"
return TaskProcessorConfig(
queue_name=queue_name,
adapter_config=CeleryAdapterConfig(
broker_url="fake://",
backend_url="fake://",
),
adapter=MockTaskProcessorAdapter,
)
class TestTaskHandlerDecorator:
"""Test the task_handler decorator."""
def _create_and_test_handler(self, decorator_args=None, expected_name=None):
"""Helper to create and test a task handler."""
mock = MagicMock()
if decorator_args is None:
@task_handler
def test_handler():
mock()
else:
@task_handler(**decorator_args)
def test_handler():
mock()
test_handler()
assert mock.call_count == 1
assert test_handler._task_name == expected_name
def test_task_handler_decorator_with_name(self):
self._create_and_test_handler(
decorator_args={"name": "my_task"}, expected_name="my_task"
)
def test_task_handler_decorator_without_name(self):
self._create_and_test_handler(expected_name="test_handler")
@pytest.mark.parametrize("invalid_name", ["", " ", 123])
def test_task_handler_decorator_invalid_name(self, invalid_name):
"""Test various invalid task names."""
with pytest.raises(
ValueError,
match=f"Task name must be a non-empty string, got {invalid_name}",
):
@task_handler(name=invalid_name)
def my_task_handler():
pass
def test_task_handler_on_callable_object_without_name_attr(self):
"""Test that AttributeError is raised for callables with no __name__."""
class MyCallable:
"""A simple callable class without a __name__ attribute on instances."""
def __call__(self):
pass
with pytest.raises(AttributeError):
task_handler(MyCallable())
class TestTaskConsumerDecorator:
"""Test the task_consumer decorator."""
def _verify_and_cleanup(self, instance, expected_calls=None):
"""Verify consumer and cleanup instance."""
instance.initialize_callable(5)
adapter = instance._adapter
assert adapter._start_consumer_received
if expected_calls is not None:
if expected_calls:
calls = [call(method, name=name) for method, name in expected_calls]
adapter.register_task_handle_mock.assert_has_calls(
calls, any_order=False
)
assert adapter.register_task_handle_mock.call_count == len(
expected_calls
)
else:
adapter.register_task_handle_mock.assert_not_called()
del instance
def _run_consumer_test(
self, config, consumer_class_factory, expected_calls_factory=None
):
"""Run a consumer test with factory functions."""
consumer_class = consumer_class_factory(config)
instance = consumer_class()
expected_calls = (
expected_calls_factory(instance) if expected_calls_factory else None
)
self._verify_and_cleanup(instance, expected_calls)
def test_task_consumer_basic(self, config):
"""Test basic functionality of the task_consumer decorator."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler
def my_task(self):
pass
return MyConsumer
self._run_consumer_test(
config, make_consumer, lambda inst: [(inst.my_task, "my_task")]
)
def test_task_consumer_multiple_handlers(self, config):
"""Test with multiple task handlers."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler
def task1(self):
pass
@task_handler
def task2(self):
pass
return MyConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [(inst.task1, "task1"), (inst.task2, "task2")],
)
def test_task_consumer_custom_names(self, config):
"""Test task handlers with and without custom names."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
@task_handler(name="custom_task")
def task1(self):
pass
@task_handler
def task2(self):
pass
return MyConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [(inst.task1, "custom_task"), (inst.task2, "task2")],
)
def test_task_consumer_init_args(self, config):
"""Test that __init__ arguments are passed correctly."""
@task_consumer(task_processor_config=config)
class MyConsumer:
def __init__(self, value):
self.value = value
instance = MyConsumer(value=42)
assert instance.value == 42
self._verify_and_cleanup(instance)
def test_task_consumer_no_handlers(self, config):
"""Test with a class that has no task handlers."""
def make_consumer(cfg):
@task_consumer(task_processor_config=cfg)
class MyConsumer:
def some_method(self):
pass
return MyConsumer
self._run_consumer_test(config, make_consumer, lambda inst: [])
def test_task_consumer_inheritance(self, config):
"""Test that inherited task handlers are registered."""
def make_consumer(cfg):
class BaseConsumer:
@task_handler
def base_task(self):
pass
@task_consumer(task_processor_config=cfg)
class DerivedConsumer(BaseConsumer):
@task_handler
def derived_task(self):
pass
return DerivedConsumer
self._run_consumer_test(
config,
make_consumer,
lambda inst: [
(inst.base_task, "base_task"),
(inst.derived_task, "derived_task"),
],
)
def test_task_consumer_no_args_decorator(self):
"""Test using @task_consumer without arguments raises TypeError."""
with pytest.raises(TypeError):
@task_consumer
class MyConsumer:
pass
def test_default_deployment_name_stays_same_with_task_consumer(config):
"""Test that the default deployment name is the class name when using task_consumer with serve.deployment."""
@deployment
@task_consumer(task_processor_config=config)
class MyTaskConsumer:
@task_handler
def my_task(self):
pass
# The deployment name should default to the class name
assert MyTaskConsumer.name == "MyTaskConsumer"
def test_task_consumer_preserves_metadata(config):
class OriginalConsumer:
"""Docstring for a task consumer."""
value: int
wrapped_cls = task_consumer(task_processor_config=config)(OriginalConsumer)
assert wrapped_cls.__name__ == OriginalConsumer.__name__
assert wrapped_cls.__qualname__ == OriginalConsumer.__qualname__
assert wrapped_cls.__module__ == OriginalConsumer.__module__
assert wrapped_cls.__doc__ == OriginalConsumer.__doc__
assert (
wrapped_cls.__annotations__["value"]
== OriginalConsumer.__annotations__["value"]
)
assert wrapped_cls.__annotations__["_adapter"] is TaskProcessorAdapter
assert getattr(wrapped_cls, "__wrapped__", None) is OriginalConsumer
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))
@@ -0,0 +1,858 @@
import asyncio
import pickle
import sys
import threading
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Callable, Dict, Generator, Optional, Tuple
import pytest
from fastapi import FastAPI
from starlette.requests import Request
from starlette.responses import PlainTextResponse
import ray.serve._private.replica as replica_module
from ray import serve
from ray.serve._private.common import (
DeploymentID,
RequestMetadata,
RequestProtocol,
)
from ray.serve._private.config import DeploymentConfig
from ray.serve._private.http_util import ASGIReceiveProxy
from ray.serve._private.replica import UserCallableWrapper
from ray.serve.generated import serve_pb2
class BasicClass:
def __call__(self, suffix: Optional[str] = None, raise_exception: bool = False):
if raise_exception:
raise RuntimeError("uh-oh!")
return "hi" + (suffix if suffix is not None else "")
async def call_async(
self, suffix: Optional[str] = None, raise_exception: bool = False
):
if raise_exception:
raise RuntimeError("uh-oh!")
return "hi" + (suffix if suffix is not None else "")
def call_generator(
self, n: int, raise_exception: bool = False
) -> Generator[int, None, None]:
for i in range(n):
yield i
if raise_exception:
raise RuntimeError("uh-oh!")
async def call_async_generator(
self, n: int, raise_exception: bool = False
) -> AsyncGenerator[int, None]:
for i in range(n):
yield i
if raise_exception:
raise RuntimeError("uh-oh!")
def basic_sync_function(suffix: Optional[str] = None, raise_exception: bool = False):
if raise_exception:
raise RuntimeError("uh-oh!")
return "hi" + (suffix if suffix is not None else "")
async def basic_async_function(
suffix: Optional[str] = None, raise_exception: bool = False
):
if raise_exception:
raise RuntimeError("uh-oh!")
return "hi" + (suffix if suffix is not None else "")
def basic_sync_generator(n: int, raise_exception: bool = False):
for i in range(n):
yield i
if raise_exception:
raise RuntimeError("uh-oh!")
async def basic_async_generator(n: int, raise_exception: bool = False):
for i in range(n):
yield i
if raise_exception:
raise RuntimeError("uh-oh!")
def _make_user_callable_wrapper(
callable: Optional[Callable] = None,
*,
init_args: Optional[Tuple[Any]] = None,
init_kwargs: Optional[Dict[str, Any]] = None,
run_sync_methods_in_threadpool: bool = False,
run_user_code_in_separate_thread: bool = True,
) -> UserCallableWrapper:
return UserCallableWrapper(
callable if callable is not None else BasicClass,
init_args or tuple(),
init_kwargs or dict(),
deployment_id=DeploymentID(name="test_name"),
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
local_testing_mode=False,
deployment_config=DeploymentConfig(max_ongoing_requests=100),
actor_id="test-actor-id",
ray_actor_options={},
)
def _make_request_metadata(
*,
call_method: Optional[str] = None,
is_http_request: bool = False,
is_grpc_request: bool = False,
is_streaming: bool = False,
) -> RequestMetadata:
protocol = RequestProtocol.UNDEFINED
if is_http_request:
protocol = RequestProtocol.HTTP
if is_grpc_request:
protocol = RequestProtocol.GRPC
return RequestMetadata(
request_id="test_request",
internal_request_id="test_internal_request",
call_method=call_method if call_method is not None else "__call__",
_request_protocol=protocol,
is_streaming=is_streaming,
)
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.asyncio
async def test_calling_initialize_twice(run_user_code_in_separate_thread: bool):
user_callable_wrapper = _make_user_callable_wrapper(
run_user_code_in_separate_thread=run_user_code_in_separate_thread
)
await user_callable_wrapper.initialize_callable()
assert isinstance(user_callable_wrapper.user_callable, BasicClass)
with pytest.raises(RuntimeError):
await user_callable_wrapper.initialize_callable()
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.asyncio
async def test_calling_methods_before_initialize(
run_user_code_in_separate_thread: bool,
):
user_callable_wrapper = _make_user_callable_wrapper(
run_user_code_in_separate_thread=run_user_code_in_separate_thread
)
with pytest.raises(RuntimeError):
await user_callable_wrapper.call_user_method(None, tuple(), dict())
with pytest.raises(RuntimeError):
await user_callable_wrapper.call_user_health_check()
with pytest.raises(RuntimeError):
await user_callable_wrapper.call_reconfigure(None, rank=0)
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
@pytest.mark.asyncio
async def test_basic_class_callable(
run_user_code_in_separate_thread: bool, run_sync_methods_in_threadpool: bool
):
user_callable_wrapper = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
# Call non-generator method with is_streaming.
request_metadata = _make_request_metadata(is_streaming=True)
with pytest.raises(TypeError, match="did not return a generator."):
async for _ in user_callable_wrapper.call_user_generator(
request_metadata, tuple(), dict()
):
pass
# Test calling default sync `__call__` method.
request_metadata = _make_request_metadata()
assert (
await user_callable_wrapper.call_user_method(request_metadata, tuple(), dict())
) == "hi"
assert (
await user_callable_wrapper.call_user_method(
request_metadata, ("-arg",), dict()
)
== "hi-arg"
)
assert (
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"suffix": "-kwarg"}
)
== "hi-kwarg"
)
with pytest.raises(RuntimeError, match="uh-oh"):
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"raise_exception": True}
)
# Call non-generator async method with is_streaming.
request_metadata = _make_request_metadata(
call_method="call_async", is_streaming=True
)
with pytest.raises(TypeError, match="did not return a generator."):
async for _ in user_callable_wrapper.call_user_generator(
request_metadata, tuple(), dict()
):
pass
# Test calling `call_async` method.
request_metadata = _make_request_metadata(call_method="call_async")
assert (
await user_callable_wrapper.call_user_method(request_metadata, tuple(), dict())
== "hi"
)
assert (
await user_callable_wrapper.call_user_method(
request_metadata, ("-arg",), dict()
)
== "hi-arg"
)
assert (
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"suffix": "-kwarg"}
)
== "hi-kwarg"
)
with pytest.raises(RuntimeError, match="uh-oh"):
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"raise_exception": True}
)
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
@pytest.mark.asyncio
async def test_basic_class_callable_generators(
run_sync_methods_in_threadpool: bool, run_user_code_in_separate_thread: bool
):
user_callable_wrapper = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
result_list = []
# Call sync generator without is_streaming.
request_metadata = _make_request_metadata(
call_method="call_generator", is_streaming=False
)
with pytest.raises(
TypeError, match="Method 'call_generator' returned a generator."
):
await user_callable_wrapper.call_user_method(
request_metadata,
(10,),
dict(),
)
# Call sync generator.
request_metadata = _make_request_metadata(
call_method="call_generator", is_streaming=True
)
async for result in user_callable_wrapper.call_user_generator(
request_metadata, (10,), dict()
):
result_list.append(result)
assert result_list == list(range(10))
result_list.clear()
# Call sync generator raising exception.
with pytest.raises(RuntimeError, match="uh-oh"):
async for result in user_callable_wrapper.call_user_generator(
request_metadata,
(10,),
{"raise_exception": True},
):
result_list.append(result)
assert result_list == [0]
result_list.clear()
# Call async generator without is_streaming.
request_metadata = _make_request_metadata(
call_method="call_async_generator", is_streaming=False
)
with pytest.raises(
TypeError, match="Method 'call_async_generator' returned a generator."
):
await user_callable_wrapper.call_user_method(
request_metadata,
(10,),
dict(),
)
# Call async generator.
request_metadata = _make_request_metadata(
call_method="call_async_generator", is_streaming=True
)
async for result in user_callable_wrapper.call_user_generator(
request_metadata, (10,), dict()
):
result_list.append(result)
assert result_list == list(range(10))
result_list.clear()
# Call async generator raising exception.
with pytest.raises(RuntimeError, match="uh-oh"):
async for result in user_callable_wrapper.call_user_generator(
request_metadata,
(10,),
{"raise_exception": True},
):
result_list.append(result)
assert result_list == [0]
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
@pytest.mark.parametrize("fn", [basic_sync_function, basic_async_function])
@pytest.mark.asyncio
async def test_basic_function_callable(
fn: Callable,
run_sync_methods_in_threadpool: bool,
run_user_code_in_separate_thread: bool,
):
user_callable_wrapper = _make_user_callable_wrapper(
fn,
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
# Call non-generator function with is_streaming.
request_metadata = _make_request_metadata(is_streaming=True)
with pytest.raises(TypeError, match="did not return a generator."):
async for _ in user_callable_wrapper.call_user_generator(
request_metadata, tuple(), dict()
):
pass
request_metadata = _make_request_metadata()
assert (
await user_callable_wrapper.call_user_method(request_metadata, tuple(), dict())
) == "hi"
assert (
await user_callable_wrapper.call_user_method(
request_metadata, ("-arg",), dict()
)
) == "hi-arg"
assert (
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"suffix": "-kwarg"}
)
) == "hi-kwarg"
with pytest.raises(RuntimeError, match="uh-oh"):
await user_callable_wrapper.call_user_method(
request_metadata, tuple(), {"raise_exception": True}
)
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
@pytest.mark.parametrize("fn", [basic_sync_generator, basic_async_generator])
@pytest.mark.asyncio
async def test_basic_function_callable_generators(
fn: Callable,
run_sync_methods_in_threadpool: bool,
run_user_code_in_separate_thread: bool,
):
user_callable_wrapper = _make_user_callable_wrapper(
fn,
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
result_list = []
# Call generator function without is_streaming.
request_metadata = _make_request_metadata(is_streaming=False)
with pytest.raises(
TypeError, match=f"Method '{fn.__name__}' returned a generator."
):
await user_callable_wrapper.call_user_method(
request_metadata,
(10,),
dict(),
)
# Call generator function.
request_metadata = _make_request_metadata(
call_method="call_generator", is_streaming=True
)
async for result in user_callable_wrapper.call_user_generator(
request_metadata, (10,), dict()
):
result_list.append(result)
assert result_list == list(range(10))
result_list.clear()
# Call generator function raising exception.
with pytest.raises(RuntimeError, match="uh-oh"):
async for result in user_callable_wrapper.call_user_generator(
request_metadata,
(10,),
{"raise_exception": True},
):
result_list.append(result)
assert result_list == [0]
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.asyncio
async def test_callable_with_async_init(run_user_code_in_separate_thread: bool):
class AsyncInitializer:
async def __init__(self, msg: str):
await asyncio.sleep(0.001)
self._msg = msg
def __call__(self) -> str:
return self._msg
msg = "hello world"
user_callable_wrapper = _make_user_callable_wrapper(
AsyncInitializer,
init_args=(msg,),
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
request_metadata = _make_request_metadata()
assert (
await user_callable_wrapper.call_user_method(request_metadata, tuple(), dict())
) == msg
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("async_del", [False, True])
@pytest.mark.asyncio
async def test_destructor_only_called_once(
async_del: bool, run_user_code_in_separate_thread: bool
):
num_destructor_calls = 0
if async_del:
class DestroyerOfNothing:
async def __del__(self) -> str:
nonlocal num_destructor_calls
num_destructor_calls += 1
else:
class DestroyerOfNothing:
def __del__(self) -> str:
nonlocal num_destructor_calls
num_destructor_calls += 1
user_callable_wrapper = _make_user_callable_wrapper(
DestroyerOfNothing,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
# Call `call_destructor` many times in parallel; only the first one should actually
# run the `__del__` method.
await asyncio.gather(*[user_callable_wrapper.call_destructor() for _ in range(100)])
assert num_destructor_calls == 1
class gRPCClass:
def greet(self, msg: serve_pb2.UserDefinedMessage):
return serve_pb2.UserDefinedResponse(greeting=f"Hello {msg.greeting}!")
def stream(self, msg: serve_pb2.UserDefinedMessage):
for i in range(10):
yield serve_pb2.UserDefinedResponse(greeting=f"Hello {msg.greeting} {i}!")
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
@pytest.mark.asyncio
async def test_grpc_unary_request(
run_sync_methods_in_threadpool: bool, run_user_code_in_separate_thread: bool
):
user_callable_wrapper = _make_user_callable_wrapper(
gRPCClass,
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
request_metadata = _make_request_metadata(call_method="greet", is_grpc_request=True)
result = await user_callable_wrapper.call_user_method(
request_metadata, (serve_pb2.UserDefinedResponse(greeting="world"),), dict()
)
assert isinstance(result, serve_pb2.UserDefinedResponse)
assert result.greeting == "Hello world!"
@pytest.mark.asyncio
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
async def test_grpc_streaming_request(
run_sync_methods_in_threadpool: bool, run_user_code_in_separate_thread: bool
):
user_callable_wrapper = _make_user_callable_wrapper(
gRPCClass,
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
await user_callable_wrapper.initialize_callable()
result_list = []
request_metadata = _make_request_metadata(
call_method="stream", is_grpc_request=True, is_streaming=True
)
async for result in user_callable_wrapper.call_user_generator(
request_metadata,
(serve_pb2.UserDefinedResponse(greeting="world"),),
dict(),
):
result_list.append(result)
assert len(result_list) == 10
for i, result in enumerate(result_list):
assert isinstance(result, serve_pb2.UserDefinedResponse)
assert result.greeting == f"Hello world {i}!"
class RawRequestHandler:
async def __call__(self, request: Request) -> str:
msg = await request.body()
return PlainTextResponse(f"Hello {msg}!")
app = FastAPI()
@serve.ingress(app)
class FastAPIRequestHandler:
@app.get("/")
async def handle_root(self, request: Request) -> str:
msg = await request.body()
return PlainTextResponse(f"Hello {msg}!")
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.parametrize("callable", [RawRequestHandler, FastAPIRequestHandler])
@pytest.mark.asyncio
async def test_http_handler(
callable: Callable, monkeypatch, run_user_code_in_separate_thread: bool
):
user_callable_wrapper = _make_user_callable_wrapper(
callable, run_user_code_in_separate_thread=run_user_code_in_separate_thread
)
await user_callable_wrapper.initialize_callable()
@dataclass
class MockReplicaContext:
servable_object: Callable
monkeypatch.setattr(
serve,
"get_replica_context",
lambda: MockReplicaContext(user_callable_wrapper.user_callable),
)
asgi_scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.1"},
"http_version": "1.1",
"server": ("127.0.0.1", 8000),
"client": ("127.0.0.1", 51517),
"scheme": "http",
"method": "GET",
"root_path": "",
"path": "/",
"raw_path": b"/",
"query_string": b"",
"headers": [
(b"host", b"localhost:8000"),
(b"user-agent", b"curl/8.1.2"),
(b"accept", b"*/*"),
(b"x-request-id", b"e45c04ad-bcd8-434f-8998-05689227e103"),
],
}
asgi_messages = [
{"type": "http.request", "body": b'"world"', "more_body": False},
{"type": "http.disconnect"},
]
async def receive_asgi_messages(_: str):
return pickle.dumps(asgi_messages)
result_list = []
request_metadata = _make_request_metadata(is_http_request=True, is_streaming=True)
async for result in user_callable_wrapper.call_http_entrypoint(
request_metadata,
lambda *args: None,
asgi_scope,
ASGIReceiveProxy(asgi_scope, request_metadata, receive_asgi_messages),
):
result_list.extend(result)
assert result_list[0]["type"] == "http.response.start"
assert result_list[0]["status"] == 200
assert "headers" in result_list[0]
assert result_list[1] == {
"type": "http.response.body",
"body": b"Hello b'\"world\"'!",
}
failing_init_app = FastAPI()
@serve.ingress(failing_init_app)
class FailingFastAPIRequestHandler:
def __init__(self):
raise RuntimeError("init failed")
@pytest.mark.parametrize("run_user_code_in_separate_thread", [False, True])
@pytest.mark.asyncio
async def test_fastapi_init_failure_destructor_is_noop(
monkeypatch, run_user_code_in_separate_thread: bool
):
logged_messages = []
def mock_exception(msg, *args, **kwargs):
logged_messages.append(msg)
monkeypatch.setattr(replica_module.logger, "exception", mock_exception)
user_callable_wrapper = _make_user_callable_wrapper(
FailingFastAPIRequestHandler,
run_user_code_in_separate_thread=run_user_code_in_separate_thread,
)
with pytest.raises(RuntimeError, match="init failed"):
await user_callable_wrapper.initialize_callable()
await user_callable_wrapper.call_destructor()
assert logged_messages == []
class TestSeparateThread:
@pytest.mark.asyncio
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
async def test_user_code_runs_on_separate_loop(
self, run_sync_methods_in_threadpool: bool
):
main_loop = asyncio.get_running_loop()
class GetLoop:
def __init__(self):
self._constructor_loop = asyncio.get_running_loop()
async def check_health(self):
check_health_loop = asyncio.get_running_loop()
assert (
check_health_loop == self._constructor_loop
), "User constructor and health check should run on the same loop."
return check_health_loop
async def call_async(self) -> Optional[asyncio.AbstractEventLoop]:
user_method_loop = asyncio.get_running_loop()
assert (
user_method_loop == self._constructor_loop
), "User constructor and other methods should run on the same loop."
return user_method_loop
def call_sync(self):
if run_sync_methods_in_threadpool:
with pytest.raises(RuntimeError, match="no running event loop"):
asyncio.get_running_loop()
user_method_loop = None
else:
user_method_loop = asyncio.get_running_loop()
assert (
user_method_loop == self._constructor_loop
), "User constructor and other methods should run on the same loop."
return user_method_loop
user_callable_wrapper = _make_user_callable_wrapper(
GetLoop,
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=True,
)
await user_callable_wrapper.initialize_callable()
# Async methods should all run on the same loop.
request_metadata = _make_request_metadata(call_method="call_async")
user_code_loop = await user_callable_wrapper.call_user_method(
request_metadata, tuple(), dict()
)
assert isinstance(user_code_loop, asyncio.AbstractEventLoop)
assert user_code_loop != main_loop
# Sync methods should run on the same loop if run_sync_methods_in_threadpool is off,
# else run in no asyncio loop.
request_metadata = _make_request_metadata(call_method="call_sync")
user_code_loop = await user_callable_wrapper.call_user_method(
request_metadata, tuple(), dict()
)
if run_sync_methods_in_threadpool:
assert user_code_loop is None
else:
assert isinstance(user_code_loop, asyncio.AbstractEventLoop)
assert user_code_loop != main_loop
# `check_health` method asserts that it runs on the correct loop.
await user_callable_wrapper.call_user_health_check()
@pytest.mark.asyncio
async def test_no_user_health_check_not_blocked(self):
"""
If there is no user-defined health check, call_user_health_check() returns None
and does not interact with the user code event loop at all. The watchdog
(RAY_SERVE_USER_HEALTH_CHECK_PROBE_MAX_FAIL > 0) handles loop-stall detection
separately. Without it enabled, a blocked loop does not affect health checks.
"""
sync_event = threading.Event()
class LoopBlocker:
async def __call__(self) -> str:
# Block the loop until the event is set.
sync_event.wait()
return "Sorry I got stuck!"
user_callable_wrapper = _make_user_callable_wrapper(
LoopBlocker,
run_user_code_in_separate_thread=True,
)
await user_callable_wrapper.initialize_callable()
request_metadata = _make_request_metadata()
blocked_future = user_callable_wrapper.call_user_method(
request_metadata, tuple(), dict()
)
_, pending = await asyncio.wait([blocked_future], timeout=0.01)
assert len(pending) == 1
for _ in range(100):
# If this called something on the event loop, it'd be blocked.
# Instead, call_user_health_check returns None when there's no user
# health check configured and the probe counter hasn't exceeded the
# threshold.
assert user_callable_wrapper.call_user_health_check() is None
sync_event.set()
assert await blocked_future == "Sorry I got stuck!"
@pytest.mark.asyncio
async def test_user_loop_watchdog_not_started_when_max_fail_zero(monkeypatch):
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_MAX_FAIL", 0)
w = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=False,
run_user_code_in_separate_thread=True,
)
await w.initialize_callable()
w.start_user_loop_watchdog(asyncio.get_running_loop())
assert w._user_loop_probe_task is None
@pytest.mark.asyncio
async def test_user_loop_watchdog_started_when_eligible(monkeypatch):
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_MAX_FAIL", 1)
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_INTERVAL_S", 3600.0)
w = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=False,
run_user_code_in_separate_thread=True,
)
await w.initialize_callable()
w.start_user_loop_watchdog(asyncio.get_running_loop())
assert w._user_loop_probe_task is not None
w.stop_user_loop_watchdog()
await asyncio.sleep(0.01)
assert w._user_loop_probe_task is None
@pytest.mark.asyncio
async def test_user_loop_watchdog_not_started_with_user_check_health(monkeypatch):
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_MAX_FAIL", 2)
class WithHealth:
async def check_health(self):
pass
def __call__(self):
return "ok"
w = _make_user_callable_wrapper(
WithHealth,
run_sync_methods_in_threadpool=False,
run_user_code_in_separate_thread=True,
)
await w.initialize_callable()
w.start_user_loop_watchdog(asyncio.get_running_loop())
assert w._user_loop_probe_task is None
@pytest.mark.asyncio
@pytest.mark.parametrize("run_sync_methods_in_threadpool", [False, True])
async def test_call_user_health_check_raises_when_probe_failures_exhausted(
monkeypatch, run_sync_methods_in_threadpool: bool
):
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_MAX_FAIL", 2)
w = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=run_sync_methods_in_threadpool,
run_user_code_in_separate_thread=True,
)
await w.initialize_callable()
w._user_loop_probe_consecutive_fail_count = 2
with pytest.raises(RuntimeError, match="User event loop unresponsive"):
w.call_user_health_check()
@pytest.mark.asyncio
async def test_user_loop_watchdog_started_when_sync_in_threadpool(monkeypatch):
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_MAX_FAIL", 2)
monkeypatch.setattr(replica_module, "USER_HEALTH_CHECK_PROBE_INTERVAL_S", 3600.0)
w = _make_user_callable_wrapper(
run_sync_methods_in_threadpool=True,
run_user_code_in_separate_thread=True,
)
await w.initialize_callable()
w.start_user_loop_watchdog(asyncio.get_running_loop())
assert w._user_loop_probe_task is not None
w.stop_user_loop_watchdog()
await asyncio.sleep(0.01)
assert w._user_loop_probe_task is None
if __name__ == "__main__":
# Tests are timing out on Windows for an unknown reason. Given this is just a unit
# test, running on Linux and Mac should be sufficient.
if sys.platform != "win32":
sys.exit(pytest.main(["-v", "-s", __file__]))