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

721 lines
26 KiB
Python

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__]))