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

1576 lines
58 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import math
import sys
from unittest.mock import MagicMock, patch
import pytest
from ray.serve._private.autoscaling_state import DeploymentAutoscalingState
from ray.serve._private.common import DeploymentID, ReplicaID, TimeStampedValue
from ray.serve._private.constants import (
CONTROL_LOOP_INTERVAL_S,
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY,
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY,
)
from ray.serve._private.gang_scheduling_autoscaling_policy import (
GangSchedulingAutoscalingPolicy,
)
from ray.serve._private.metrics_utils import (
aggregate_timeseries,
merge_instantaneous_total,
time_weighted_average,
)
from ray.serve.autoscaling_policy import (
_apply_app_level_autoscaling_config,
_apply_autoscaling_config,
_apply_delay_logic,
_apply_scaling_factors,
replica_queue_length_autoscaling_policy,
)
from ray.serve.config import (
AggregationFunction,
AutoscalingConfig,
AutoscalingContext,
GangSchedulingConfig,
)
wrapped_replica_queue_length_autoscaling_policy = _apply_autoscaling_config(
replica_queue_length_autoscaling_policy
)
def create_context_with_overrides(
base_ctx: AutoscalingContext, **kwargs
) -> AutoscalingContext:
"""Helper to create a new AutoscalingContext with specified attributes overridden.
Args:
base_ctx: The base AutoscalingContext to copy values from.
**kwargs: Attributes to override in the new context.
Returns:
A new AutoscalingContext with overridden values.
"""
# Get all constructor parameters with defaults from base context
params = {
"config": base_ctx.config,
"deployment_id": base_ctx.deployment_id,
"deployment_name": base_ctx.deployment_name,
"app_name": base_ctx.app_name,
"current_num_replicas": base_ctx.current_num_replicas,
"target_num_replicas": base_ctx.target_num_replicas,
"running_replicas": base_ctx.running_replicas,
"total_num_requests": base_ctx.total_num_requests,
"total_queued_requests": base_ctx.total_queued_requests,
"aggregated_metrics": base_ctx.aggregated_metrics,
"raw_metrics": base_ctx.raw_metrics,
"capacity_adjusted_min_replicas": base_ctx.capacity_adjusted_min_replicas,
"capacity_adjusted_max_replicas": base_ctx.capacity_adjusted_max_replicas,
"policy_state": base_ctx.policy_state,
"last_scale_up_time": base_ctx.last_scale_up_time,
"last_scale_down_time": base_ctx.last_scale_down_time,
"current_time": base_ctx.current_time,
"total_pending_async_requests": base_ctx.total_pending_async_requests,
}
# Override with provided kwargs
params.update(kwargs)
return AutoscalingContext(**params)
def test_exclude_early_partial_period_in_timeseries_aggregation():
"""Test that time-weighted average excludes the early partial period when
series have misaligned start times.
When merging multiple timeseries (e.g., from different replicas), series
that start late are implicitly 0 before their first data point. This
undercounts the total and biases the mean downward. The fix excludes
the partial period by starting the averaging window when all series
have contributed at least one point.
"""
# Two replicas with misaligned starts: r1 starts at 0.2, r2 starts at 0.1
# From 0.1 to 0.2, only r2 contributes (total=3); from 0.2 onward both (total=8)
base_time = 1000.0 # Use epoch-like timestamps
series1 = [
TimeStampedValue(base_time + 0.2, 5.0),
TimeStampedValue(base_time + 0.8, 7.0),
TimeStampedValue(base_time + 1.5, 6.0),
]
series2 = [
TimeStampedValue(base_time + 0.1, 3.0),
TimeStampedValue(base_time + 0.9, 4.0),
TimeStampedValue(base_time + 1.4, 8.0),
]
merged = merge_instantaneous_total([series1, series2])
# Merged: (0.1, 3), (0.2, 8), (0.8, 10), (0.9, 11), (1.4, 15), (1.5, 14)
last_window_s = 0.5 # Extend window to base_time + 2.0
# Without aligned window: includes partial period [0.1, 0.2) where total=3
avg_without_aligned = time_weighted_average(
merged,
window_start=None,
window_end=None,
last_window_s=last_window_s,
)
# With aligned window (start at 0.2 when all series have reported): excludes partial
aligned_start = base_time + 0.2
avg_with_aligned = time_weighted_average(
merged,
window_start=aligned_start,
window_end=None,
last_window_s=last_window_s,
)
# The aligned average should be higher because we excluded the period
# [0.1, 0.2) where the total was underestimated (3 instead of 8)
assert avg_with_aligned > avg_without_aligned, (
f"Aligned avg ({avg_with_aligned}) should exceed unaligned ({avg_without_aligned}) "
"since we exclude the partial period with underestimated total"
)
# Verify aggregate_timeseries with window_start produces the aligned result
result = aggregate_timeseries(
merged,
AggregationFunction.MEAN,
last_window_s=last_window_s,
window_start=aligned_start,
)
assert result == avg_with_aligned
def _run_upscale_downscale_flow(
policy,
config: AutoscalingConfig,
ctx: AutoscalingContext,
overload_requests: int,
start_replicas: int,
upscale_target: int,
):
"""
This runs the upscale and downscale flow to test the delays during upscale and
downscale.
This can be used by both the default autoscaling policy and custom autoscaling policy
with default parameters to verify scaling is properly enabled.
The downscale flow is from upscale_target upto zero.
Uses a mocked clock so that each policy call advances wall-clock time by
CONTROL_LOOP_INTERVAL_S, matching what the real control loop would do.
Wait counts use math.ceil(delay_s * ticks_per_second): int(delay / interval)
undercounts when float division yields 5.999... for 0.6/0.1, but wall-clock
delay logic requires elapsed >= delay_s.
"""
ticks_per_second = round(1.0 / CONTROL_LOOP_INTERVAL_S)
upscale_wait_periods = math.ceil(config.upscale_delay_s * ticks_per_second)
downscale_wait_periods = math.ceil(config.downscale_delay_s * ticks_per_second)
if config.downscale_to_zero_delay_s:
downscale_to_zero_wait_periods = math.ceil(
config.downscale_to_zero_delay_s * ticks_per_second
)
else:
downscale_to_zero_wait_periods = math.ceil(
config.downscale_delay_s * ticks_per_second
)
# Initialize local policy_state from the base context, so both default and
# decorated policies can persist their internal state
policy_state = ctx.policy_state or {}
# Track a fake clock that advances by CONTROL_LOOP_INTERVAL_S per call,
# simulating real control-loop timing for the wall-clock delay logic.
# Uses integer division (tick / ticks_per_second) instead of multiplication
# (tick * 0.1) to avoid IEEE 754 double-rounding: a*0.1 rounds 0.1 first then
# multiplies, whereas a/10 is a single correctly-rounded operation.
fake_tick = [0]
def _advance_time():
current = fake_tick[0] / ticks_per_second
fake_tick[0] += 1
return current
with patch("ray.serve.autoscaling_policy.time") as mock_time:
mock_time.time = _advance_time
# We should scale up only after enough consecutive scale-up decisions.
for i in range(upscale_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=start_replicas,
target_num_replicas=start_replicas,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == start_replicas, i
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=start_replicas,
target_num_replicas=start_replicas,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == upscale_target
no_requests = 0
# We should scale down only after enough consecutive scale-down decisions.
for i in range(downscale_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == upscale_target, i
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1
# We should scale down to zero only after enough consecutive
# downscale-to-zero decisions.
for i in range(downscale_to_zero_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1, i
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 0
# Get some scale-up decisions, but not enough to trigger a scale up.
for i in range(int(upscale_wait_periods / 2)):
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1, i
# Interrupt with a scale-down decision.
ctx = create_context_with_overrides(
ctx,
total_num_requests=0,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
_, policy_state = policy(ctx=ctx)
# The counter should be reset, so it should require
# `upscale_wait_periods` more periods before we actually scale up.
for i in range(upscale_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1, i
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == upscale_target
# Get some scale-down decisions, but not enough to trigger a scale
# down.
for i in range(int(downscale_wait_periods / 2)):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == upscale_target, i
# Interrupt with a scale-up decision.
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
_, policy_state = policy(ctx=ctx)
# The counter should be reset so it should require
# `downscale_wait_periods` more periods before we actually scale down.
for i in range(downscale_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == upscale_target, i
# First scale down to 1 replica
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=upscale_target,
target_num_replicas=upscale_target,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1
# Scale down to 0, but not enough to trigger a complete scale down
# to zero.
for i in range(int(downscale_to_zero_wait_periods / 2)):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1, i
# Interrupt with a scale-up decision.
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
_, policy_state = policy(ctx=ctx)
# The counter should be reset so it should require
# `downscale_to_zero_wait_periods` more periods before we actually
# scale down.
for i in range(downscale_to_zero_wait_periods):
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 1, i
ctx = create_context_with_overrides(
ctx,
total_num_requests=no_requests,
current_num_replicas=1,
target_num_replicas=1,
policy_state=policy_state,
)
new_num_replicas, policy_state = policy(ctx=ctx)
assert new_num_replicas == 0
class TestReplicaQueueLengthPolicy:
@pytest.mark.parametrize(
"use_upscale_smoothing_factor,use_upscaling_factor",
[(True, True), (True, False), (False, True)],
)
def test_scaling_factor_scale_up_from_0_replicas(
self, use_upscale_smoothing_factor, use_upscaling_factor
):
"""Test that the scaling factor is respected when scaling up
from 0 replicas.
"""
min_replicas = 0
max_replicas = 2
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
upscale_smoothing_factor=10 if use_upscale_smoothing_factor else None,
upscaling_factor=10 if use_upscaling_factor else None,
)
ctx = AutoscalingContext(
target_num_replicas=0,
total_num_requests=1,
current_num_replicas=0,
config=config,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state={},
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
# 1 * 10
assert new_num_replicas == 10
if use_upscale_smoothing_factor:
config.upscale_smoothing_factor = 0.5
if use_upscaling_factor:
config.upscaling_factor = 0.5
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
# math.ceil(1 * 0.5)
assert new_num_replicas == 1
@pytest.mark.parametrize(
"use_downscale_smoothing_factor,use_downscaling_factor",
[(True, True), (True, False), (False, True)],
)
def test_scaling_factor_scale_down_to_0_replicas(
self, use_downscale_smoothing_factor, use_downscaling_factor
):
"""Test that a deployment scales down to 0 for non-default smoothing factors."""
# With smoothing factor > 1, the desired number of replicas should
# immediately drop to 0 (while respecting upscale and downscale delay)
min_replicas = 0
max_replicas = 5
policy_state = {}
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
downscale_smoothing_factor=10 if use_downscale_smoothing_factor else None,
downscaling_factor=10 if use_downscaling_factor else None,
upscale_delay_s=0,
downscale_delay_s=0,
)
ctx = AutoscalingContext(
config=config,
total_num_requests=0,
current_num_replicas=5,
target_num_replicas=5,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
# Downscaling to 0 first stops at 1
assert new_num_replicas == 1
# Need to trigger this the second time to go to zero
ctx.target_num_replicas = 1
ctx.current_num_replicas = 1
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 0
# With smoothing factor < 1, the desired number of replicas shouldn't
# get stuck at a positive number, and instead should eventually drop
# to zero
if use_downscale_smoothing_factor:
config.downscale_smoothing_factor = 0.2
if use_downscaling_factor:
config.downscaling_factor = 0.2
# policy_manager = AutoscalingPolicyManager(config)
num_replicas = 5
for _ in range(5):
ctx = create_context_with_overrides(
ctx,
total_num_requests=0,
current_num_replicas=num_replicas,
target_num_replicas=num_replicas,
)
num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert num_replicas == 0
@pytest.mark.parametrize("downscale_to_zero_delay_s", [None, 300])
def test_upscale_downscale_delay(self, downscale_to_zero_delay_s):
"""Unit test for upscale_delay_s, downscale_delay_s and downscale_to_zero_delay_s"""
min_replicas = 0
max_replicas = 2
policy_state = {}
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
target_ongoing_requests=1,
upscale_delay_s=30.0,
downscale_delay_s=600.0,
downscale_to_zero_delay_s=downscale_to_zero_delay_s,
)
# Use overload_requests that naturally produce the desired upscale_target
# without depending on bounds clamping (bounds are applied at a higher
# level in get_decision_num_replicas, not in the policy wrapper).
# desired = start_replicas * (overload_requests / target_ongoing_requests)
# = 1 * (2 / 1) = 2
overload_requests = 2
ctx = AutoscalingContext(
config=config,
total_num_requests=1,
current_num_replicas=0,
target_num_replicas=0,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
# Scale up when there are 0 replicas and current_handle_queued_queries > 0
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 1
# Run the basic upscale/downscale flow
_run_upscale_downscale_flow(
wrapped_replica_queue_length_autoscaling_policy,
config,
ctx,
overload_requests=overload_requests,
start_replicas=1,
upscale_target=2,
)
def test_replicas_delayed_startup(self):
"""Unit test simulating replicas taking time to start up."""
min_replicas = 1
max_replicas = 200
policy_state = {}
config = {
"min_replicas": min_replicas,
"max_replicas": max_replicas,
"upscale_delay_s": 0,
"downscale_delay_s": 100000,
"target_ongoing_requests": 1,
}
config = AutoscalingConfig(**config)
ctx = AutoscalingContext(
config=config,
target_num_replicas=1,
total_num_requests=100,
current_num_replicas=1,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
# new_num_replicas = policy_manager.get_decision_num_replicas(1, 100, 1)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 100
# New target is 100, but no new replicas finished spinning up during this
# timestep.
ctx = create_context_with_overrides(
ctx,
total_num_requests=100,
current_num_replicas=1,
target_num_replicas=100,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 100
# Two new replicas spun up during this timestep.
ctx = create_context_with_overrides(
ctx,
total_num_requests=123,
current_num_replicas=3,
target_num_replicas=100,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 123
# A lot of queries got drained and a lot of replicas started up, but
# new_num_replicas should not decrease, because of the downscale delay.
ctx = create_context_with_overrides(
ctx,
total_num_requests=10,
current_num_replicas=4,
target_num_replicas=123,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == 123
@pytest.mark.parametrize("delay_s", [30.0, 0.0])
def test_fluctuating_ongoing_requests(self, delay_s):
"""
Simulates a workload that switches between too many and too few
ongoing requests.
"""
min_replicas = 1
max_replicas = 10
policy_state = {}
config = {
"min_replicas": min_replicas,
"max_replicas": max_replicas,
"upscale_delay_s": delay_s,
"downscale_delay_s": delay_s,
"target_ongoing_requests": 50,
}
config = AutoscalingConfig(**config)
if delay_s > 0:
wait_periods = int(delay_s / CONTROL_LOOP_INTERVAL_S)
assert wait_periods > 1
underload_requests, overload_requests = 2 * 20, 100
trials = 1000
ctx = AutoscalingContext(
config=config,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
target_num_replicas=None,
total_num_requests=None,
current_num_replicas=None,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
new_num_replicas = None
for trial in range(trials):
if trial % 2 == 0:
ctx = create_context_with_overrides(
ctx,
total_num_requests=overload_requests,
current_num_replicas=1,
target_num_replicas=1,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(
ctx=ctx
)
if delay_s > 0:
assert new_num_replicas == 1, trial
else:
assert new_num_replicas == 2, trial
else:
ctx = create_context_with_overrides(
ctx,
total_num_requests=underload_requests,
current_num_replicas=2,
target_num_replicas=2,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(
ctx=ctx
)
if delay_s > 0:
assert new_num_replicas == 2, trial
else:
assert new_num_replicas == 1, trial
@pytest.mark.parametrize("ongoing_requests", [20, 100, 10])
def test_single_replica_receives_all_requests(self, ongoing_requests):
target_requests = 5
min_replicas = 1
max_replicas = 50
policy_state = {}
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
target_ongoing_requests=target_requests,
upscale_delay_s=0.0,
downscale_delay_s=0.0,
)
ctx = AutoscalingContext(
config=config,
total_num_requests=ongoing_requests,
current_num_replicas=4,
target_num_replicas=4,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
new_num_replicas, _ = wrapped_replica_queue_length_autoscaling_policy(ctx=ctx)
assert new_num_replicas == ongoing_requests / target_requests
def test_callable_and_direct_values(self):
config = AutoscalingConfig(min_replicas=1, max_replicas=10)
deployment_id = DeploymentID(name="test", app_name="test_app")
replica_id = ReplicaID(unique_id="r1", deployment_id=deployment_id)
# Test callables with lazy evaluation and caching
call_counts = {"requests": 0, "queued": 0, "agg": 0, "raw": 0}
ctx = AutoscalingContext(
config=config,
deployment_id=None,
deployment_name="test",
app_name=None,
current_num_replicas=5,
target_num_replicas=5,
running_replicas=[],
total_num_requests=lambda: (
call_counts.update({"requests": call_counts["requests"] + 1}),
42.0,
)[1],
total_queued_requests=lambda: (
call_counts.update({"queued": call_counts["queued"] + 1}),
10.0,
)[1],
aggregated_metrics=lambda: (
call_counts.update({"agg": call_counts["agg"] + 1}),
{"m": {replica_id: 5.0}},
)[1],
raw_metrics=lambda: (
call_counts.update({"raw": call_counts["raw"] + 1}),
{"m": {replica_id: [TimeStampedValue(1.0, 5.0)]}},
)[1],
capacity_adjusted_min_replicas=1,
capacity_adjusted_max_replicas=10,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
total_pending_async_requests=0,
)
# Callables not executed until accessed
assert all(c == 0 for c in call_counts.values())
# First access executes callables
assert ctx.total_num_requests == 42.0
assert ctx.total_queued_requests == 10.0
assert ctx.aggregated_metrics == {"m": {replica_id: 5.0}}
assert ctx.raw_metrics["m"][replica_id][0].value == 5.0
assert all(c == 1 for c in call_counts.values())
# Second access uses cached values
_ = ctx.total_num_requests
_ = ctx.total_queued_requests
_ = ctx.aggregated_metrics
_ = ctx.raw_metrics
assert all(c == 1 for c in call_counts.values())
# Test direct values (non-callable)
ctx2 = AutoscalingContext(
config=config,
deployment_id=None,
deployment_name="test",
app_name=None,
current_num_replicas=5,
target_num_replicas=5,
running_replicas=[],
total_num_requests=100.0,
total_queued_requests=20.0,
aggregated_metrics={"m2": {replica_id: 15.0}},
raw_metrics={"m2": {replica_id: [TimeStampedValue(2.0, 25.0)]}},
capacity_adjusted_min_replicas=1,
capacity_adjusted_max_replicas=10,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
total_pending_async_requests=0,
)
assert ctx2.total_num_requests == 100.0
assert ctx2.total_queued_requests == 20.0
assert ctx2.aggregated_metrics == {"m2": {replica_id: 15.0}}
assert ctx2.raw_metrics["m2"][replica_id][0].value == 25.0
class TestAutoscalingConfigParameters:
def test_apply_scaling_factors_upscale(self):
config = AutoscalingConfig(
min_replicas=1, max_replicas=30, upscaling_factor=0.5
)
desired_num_replicas = 20
current_num_replicas = 10
result = _apply_scaling_factors(
desired_num_replicas, current_num_replicas, config
)
# Expected: 10 + 0.5 * (20 - 10) = 15
assert result == 15
def test_apply_scaling_factors_downscale(self):
config = AutoscalingConfig(
min_replicas=1, max_replicas=30, downscaling_factor=0.5
)
desired_num_replicas = 5
current_num_replicas = 20
result = _apply_scaling_factors(
desired_num_replicas, current_num_replicas, config
)
# Expected: 20 - 0.5 * (20 - 5) = ceil(12.5) = 13
assert result == 13
def test_apply_scaling_factors_stuck_downscale(self):
config = AutoscalingConfig(
min_replicas=1, max_replicas=30, downscaling_factor=0.5
)
desired_num_replicas = 9
current_num_replicas = 10
result = _apply_scaling_factors(
desired_num_replicas, current_num_replicas, config
)
# Expected: 10 - 0.5 * (10 - 9) = 9.5 = ceil(9.5) = 10. The logic then adjusts it to 9.
assert result == 9
def test_upscale_delay_respected_with_slow_loop_steps(self):
"""Regression test: upscale_delay_s must be respected even when each
control loop step takes longer than CONTROL_LOOP_INTERVAL_S.
Previously, the code counted consecutive iterations and compared to
int(delay_s / CONTROL_LOOP_INTERVAL_S), assuming each iteration is
exactly 0.1s. With 600ms/iteration and upscale_delay_s=100s that
caused a ~6× overshoot (~600s actual vs 100s configured).
The fix uses wall-clock timestamps, so the upscale fires after exactly
upscale_delay_s of real elapsed time regardless of iteration duration.
This test simulates slow iterations via _now and verifies that:
- upscale fires after ~upscale_delay_s of simulated time (not 6× later)
- the elapsed simulated time is within one iteration of upscale_delay_s
"""
upscale_delay_s = 100.0
# Simulate a loaded cluster: 500ms loop step + 100ms sleep = 600ms/iter.
loop_step_duration_s = 0.5
actual_iteration_s = loop_step_duration_s + CONTROL_LOOP_INTERVAL_S
config = AutoscalingConfig(
min_replicas=1,
max_replicas=10,
upscale_delay_s=upscale_delay_s,
)
ctx = AutoscalingContext(
target_num_replicas=1,
current_num_replicas=1,
config=config,
capacity_adjusted_min_replicas=1,
capacity_adjusted_max_replicas=10,
policy_state={},
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_num_requests=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
# Simulate wall-clock time advancing by actual_iteration_s per call.
start_time = 0.0
simulated_now = start_time
fire_time = None
iterations = 0
decision = ctx.target_num_replicas
while decision == ctx.target_num_replicas:
decision, ctx.policy_state = _apply_delay_logic(
desired_num_replicas=5,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=simulated_now,
)
if decision != ctx.target_num_replicas:
fire_time = simulated_now
simulated_now += actual_iteration_s
iterations += 1
actual_elapsed_s = fire_time - start_time
# With wall-clock tracking, actual elapsed time must be close to
# upscale_delay_s — within one iteration at most.
assert actual_elapsed_s <= upscale_delay_s + actual_iteration_s, (
f"Upscale fired too late: elapsed={actual_elapsed_s:.1f}s "
f"vs configured={upscale_delay_s}s"
)
assert actual_elapsed_s >= upscale_delay_s, (
f"Upscale fired too early: elapsed={actual_elapsed_s:.1f}s "
f"vs configured={upscale_delay_s}s"
)
# Overshoot must be at most one iteration (not 6× as in the old code).
overshoot_factor = actual_elapsed_s / upscale_delay_s
assert (
overshoot_factor < 1.1
), f"Expected ≤1.1× overshoot with wall-clock fix, got {overshoot_factor:.2f}×"
def test_apply_delay_logic_upscale(self):
"""Test upscale delay uses wall-clock time."""
config = AutoscalingConfig(
min_replicas=1,
max_replicas=10,
upscale_delay_s=0.3,
)
ctx = AutoscalingContext(
target_num_replicas=1,
current_num_replicas=1,
config=config,
capacity_adjusted_min_replicas=1,
capacity_adjusted_max_replicas=10,
policy_state={},
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_num_requests=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
base_time = 1000.0
# Simulate calls before the delay elapses — should not scale up.
for i in range(3):
decision, ctx.policy_state = _apply_delay_logic(
desired_num_replicas=5,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time + i * 0.05,
)
assert decision == 1, f"Should not scale up on iteration {i}"
# Simulate a call after the delay has elapsed (use +0.31 to avoid float precision
# issues: 1000.3 - 1000.0 evaluates to 0.2999... in IEEE 754).
decision, _ = _apply_delay_logic(
desired_num_replicas=5,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time + 0.31,
)
assert decision == 5
def test_apply_delay_logic_downscale(self):
"""Test downscale delay uses wall-clock time."""
config = AutoscalingConfig(
min_replicas=0,
max_replicas=10,
downscale_to_zero_delay_s=0.4,
downscale_delay_s=0.3,
)
ctx = AutoscalingContext(
target_num_replicas=4,
current_num_replicas=4,
config=config,
capacity_adjusted_min_replicas=0,
capacity_adjusted_max_replicas=10,
policy_state={},
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_num_requests=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
base_time = 1000.0
# Downscale from 4->1: calls before downscale_delay_s (0.3s) elapses
for i in range(3):
decision_num_replicas, ctx.policy_state = _apply_delay_logic(
desired_num_replicas=0,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time + i * 0.05,
)
assert decision_num_replicas == 4, f"Should not scale down on iteration {i}"
# Call after 0.3s has elapsed — should downscale 4->1 (use +0.31 to avoid
# float precision issues: 1000.3 - 1000.0 evaluates to 0.2999... in IEEE 754).
decision_num_replicas, ctx.policy_state = _apply_delay_logic(
desired_num_replicas=0,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time + 0.31,
)
assert decision_num_replicas == 1
ctx.target_num_replicas = decision_num_replicas
# Downscale from 1->0: calls before downscale_to_zero_delay_s (0.4s) elapses
base_time2 = base_time + 0.5
for i in range(3):
decision_num_replicas, ctx.policy_state = _apply_delay_logic(
desired_num_replicas=0,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time2 + i * 0.1,
)
assert (
decision_num_replicas == 1
), f"Should not scale down from 1 to 0 on tick {i}"
# Call after 0.4s has elapsed — should downscale 1->0 (use +0.41 to avoid
# float precision issues with large base timestamps).
decision_num_replicas, _ = _apply_delay_logic(
desired_num_replicas=0,
curr_target_num_replicas=ctx.target_num_replicas,
config=ctx.config,
policy_state=ctx.policy_state,
_now=base_time2 + 0.41,
)
assert decision_num_replicas == 0
@_apply_autoscaling_config
def simple_custom_policy(ctx: AutoscalingContext):
"""
Custom policy to check default parameters are applied
"""
if ctx.total_num_requests > 0:
desired_num_replicas = 3
else:
desired_num_replicas = 0
return desired_num_replicas, {}
@_apply_app_level_autoscaling_config
def simple_app_level_policy(ctxs):
"""App-level policy that always requests scaling up to 5 replicas."""
return {deployment_id: 5 for deployment_id in ctxs.keys()}, {}
class TestCustomPolicyWithDefaultParameters:
@pytest.mark.parametrize("downscale_to_zero_delay_s", [None, 300])
def test_upscale_downscale_delay(self, downscale_to_zero_delay_s):
"""Unit test for upscale_delay_s, downscale_delay_s and downscale_to_zero_delay_s"""
min_replicas = 0
max_replicas = 4
policy_state = {}
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
target_ongoing_requests=1,
upscale_delay_s=20.0,
downscale_delay_s=400.0,
downscale_to_zero_delay_s=downscale_to_zero_delay_s,
)
ctx = AutoscalingContext(
config=config,
total_num_requests=1,
current_num_replicas=0,
target_num_replicas=0,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=None,
current_time=None,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
# Scale up when there are 0 replicas and current_handle_queued_queries > 0
new_num_replicas, _ = simple_custom_policy(ctx=ctx)
assert new_num_replicas == 1
_run_upscale_downscale_flow(
simple_custom_policy,
config,
ctx,
overload_requests=70,
start_replicas=1,
upscale_target=3,
)
@pytest.mark.parametrize(
"current_replicas, total_requests, upscale_factor, downscale_factor, expected_replicas",
[
# Upscale cases
# current=1, desired_raw=3
# factor=0.5 → ceil(1 + 0.5*(3-1)) = 2
(1, 100, 0.5, None, 2),
# factor=1.0 → ceil(1 + 1.0*(3-1)) = 3
(1, 100, 1.0, None, 3),
# Downscale cases
# current=3, desired_raw=0
# factor=0.5 → ceil(3 + 0.5*(0-3)) = ceil(1.5) = 2
(3, 0, None, 0.5, 2),
# factor=1.0 → max(ceil(3 + 1.0*(0-3)),1) = 1
(3, 0, None, 1.0, 1),
],
)
def test_apply_scaling_factors(
self,
current_replicas,
total_requests,
upscale_factor,
downscale_factor,
expected_replicas,
):
"""
The test checks if the scaling factors are applied
"""
config = AutoscalingConfig(
min_replicas=0,
max_replicas=10,
upscaling_factor=upscale_factor,
downscaling_factor=downscale_factor,
upscale_delay_s=0.0,
downscale_delay_s=0.0,
downscale_to_zero_delay_s=0.0,
)
ctx = AutoscalingContext(
config=config,
deployment_id=None,
deployment_name="test",
app_name=None,
current_num_replicas=current_replicas,
target_num_replicas=current_replicas,
running_replicas=None,
total_num_requests=0,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
capacity_adjusted_min_replicas=config.min_replicas,
capacity_adjusted_max_replicas=config.max_replicas,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
total_pending_async_requests=0,
)
ctx = create_context_with_overrides(ctx, total_num_requests=total_requests)
num_replicas, _ = simple_custom_policy(ctx)
assert num_replicas == expected_replicas
class TestAppLevelPolicyWithDefaultParameters:
def test_cold_start_fast_path(self):
"""App-level decorator should cold-start immediately (0 -> 1) even with delays."""
config = AutoscalingConfig(
min_replicas=0,
max_replicas=10,
target_ongoing_requests=10,
upscale_delay_s=20.0,
downscale_delay_s=200.0,
)
d1 = DeploymentID(name="d1", app_name="app")
d2 = DeploymentID(name="d2", app_name="app")
contexts = {
d1: AutoscalingContext(
config=config,
deployment_id=d1,
deployment_name="d1",
app_name="app",
current_num_replicas=0,
target_num_replicas=0,
running_replicas=[],
total_num_requests=1,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
capacity_adjusted_min_replicas=0,
capacity_adjusted_max_replicas=10,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
total_pending_async_requests=0,
),
d2: AutoscalingContext(
config=config,
deployment_id=d2,
deployment_name="d2",
app_name="app",
current_num_replicas=0,
target_num_replicas=0,
running_replicas=[],
total_num_requests=1,
total_queued_requests=None,
aggregated_metrics=None,
raw_metrics=None,
capacity_adjusted_min_replicas=0,
capacity_adjusted_max_replicas=10,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
total_pending_async_requests=0,
),
}
decisions, _ = simple_app_level_policy(contexts)
assert decisions[d1] == 1
assert decisions[d2] == 1
class TestGangSchedulingAutoscalingPolicy:
"""Tests for GangSchedulingAutoscalingPolicy which aligns replica counts
to gang size multiples."""
def _make_policy(self, gang_size, inner_result):
def base_scaling_policy(ctx):
return inner_result, {}
return GangSchedulingAutoscalingPolicy(base_scaling_policy, gang_size)
def _make_ctx(self, current_num_replicas):
return AutoscalingContext(
deployment_id=DeploymentID(name="test", app_name="app"),
deployment_name="test",
app_name=None,
current_num_replicas=current_num_replicas,
target_num_replicas=0,
running_replicas=[],
total_num_requests=0,
total_queued_requests=0,
aggregated_metrics=None,
raw_metrics=None,
capacity_adjusted_min_replicas=0,
capacity_adjusted_max_replicas=0,
policy_state={},
last_scale_up_time=None,
last_scale_down_time=None,
current_time=None,
config=None,
total_pending_async_requests=0,
)
@pytest.mark.parametrize(
"raw_autoscaling_decision,expected",
[
(5, 8), # ceil(5/4)*4 = 8
(7, 8), # ceil(7/4)*4 = 8
(8, 8), # already aligned
(9, 12), # ceil(9/4)*4 = 12
],
)
def test_rounds_up(self, raw_autoscaling_decision, expected):
"""Always rounds up, independent of current replica count."""
policy = self._make_policy(gang_size=4, inner_result=raw_autoscaling_decision)
for current in [4, 12]:
ctx = self._make_ctx(current_num_replicas=current)
assert policy(ctx)[0] == expected
def test_zero_replicas_unchanged(self):
ctx = self._make_ctx(current_num_replicas=4)
policy = self._make_policy(gang_size=4, inner_result=0)
assert policy(ctx)[0] == 0
def test_gang_size_one_no_op(self):
ctx = self._make_ctx(current_num_replicas=3)
policy = self._make_policy(gang_size=1, inner_result=5)
assert policy(ctx)[0] == 5
def _create_state(self, gang_size, min_replicas, max_replicas, running=0):
"""Create a DeploymentAutoscalingState with gang config registered."""
dep_id = DeploymentID(name="test", app_name="app")
state = DeploymentAutoscalingState(dep_id)
info = MagicMock()
info.deployment_config.autoscaling_config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
)
info.deployment_config.gang_scheduling_config = GangSchedulingConfig(
gang_size=gang_size,
)
info.target_capacity = None
info.target_capacity_direction = None
info.config_changed.return_value = False
state.register(info, curr_target_num_replicas=min_replicas)
state._running_replicas = list(range(running))
return state
def test_integration_with_deployment_state(self):
"""Test that gang autoscaling policy is auto-injected when registering with gang config."""
state = self._create_state(gang_size=4, min_replicas=4, max_replicas=16)
assert isinstance(state._policy, GangSchedulingAutoscalingPolicy)
assert state._policy._gang_size == 4
def test_integration_no_gang(self):
"""Without gang config, the policy is not wrapped."""
dep_id = DeploymentID(name="test", app_name="app")
state = DeploymentAutoscalingState(dep_id)
info = MagicMock()
info.deployment_config.autoscaling_config = AutoscalingConfig(
min_replicas=1,
max_replicas=10,
)
info.deployment_config.gang_scheduling_config = None
info.target_capacity = None
info.target_capacity_direction = None
info.config_changed.return_value = False
state.register(info, curr_target_num_replicas=1)
assert not isinstance(state._policy, GangSchedulingAutoscalingPolicy)
def test_integration_scale_up_respects_max(self):
"""Gang alignment rounds up, but apply_bounds clips to max."""
state = self._create_state(
gang_size=4, min_replicas=4, max_replicas=8, running=4
)
# ceil(9/4)*4 = 12, clipped to max_replicas=8
state._policy = GangSchedulingAutoscalingPolicy(
lambda ctx: (9, {}), gang_size=4
)
decision = state.get_decision_num_replicas(curr_target_num_replicas=4)
assert decision == 8
def test_integration_scale_down_respects_min(self):
"""Gang alignment rounds up, but apply_bounds clips to min."""
state = self._create_state(
gang_size=4, min_replicas=4, max_replicas=16, running=8
)
# ceil(1/4)*4 = 4, clipped to min_replicas=4
state._policy = GangSchedulingAutoscalingPolicy(
lambda ctx: (1, {}), gang_size=4
)
decision = state.get_decision_num_replicas(curr_target_num_replicas=8)
assert decision == 4
class TestWarmupScalingFeedbackLoop:
"""Regression tests for a feedback loop that causes deployments to scale
to max_replicas during warmup when upscaling_factor > 1.
When current_num_replicas == 0 (replicas scheduled but not yet RUNNING)
and there is no traffic, the policy should hold the target steady.
"""
@pytest.mark.parametrize("upscaling_factor", [0.5, 1.0, 1.5, 2.0, 10.0])
def test_no_runaway_scaling_during_warmup(self, upscaling_factor):
min_replicas = 2
max_replicas = 10
config = AutoscalingConfig(
min_replicas=min_replicas,
max_replicas=max_replicas,
target_ongoing_requests=0.2,
upscaling_factor=upscaling_factor,
upscale_delay_s=5.0,
downscale_delay_s=30.0,
)
target = min_replicas
policy_state = {}
for tick in range(100):
ctx = AutoscalingContext(
config=config,
current_num_replicas=0,
target_num_replicas=target,
total_num_requests=0,
total_queued_requests=0,
capacity_adjusted_min_replicas=min_replicas,
capacity_adjusted_max_replicas=max_replicas,
policy_state=policy_state,
deployment_id=None,
deployment_name=None,
app_name=None,
running_replicas=[],
current_time=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
new_target, policy_state = wrapped_replica_queue_length_autoscaling_policy(
ctx=ctx
)
assert new_target == target, (
f"Tick {tick}: target changed from {target} to {new_target} "
f"with upscaling_factor={upscaling_factor}, "
f"current_num_replicas=0, total_num_requests=0. "
f"This indicates a feedback loop bug."
)
target = new_target
assert target == min_replicas
class TestAppLevelPolicyStateIsolation:
"""
Test that no internal state cross contamination when a user policy returns the same
dict object for multiple deployments.
"""
def _make_context(self, dep_id, policy_state=None):
config = AutoscalingConfig(
min_replicas=1,
max_replicas=5,
upscale_delay_s=100,
downscale_delay_s=100,
)
return AutoscalingContext(
config=config,
current_num_replicas=2,
target_num_replicas=2,
total_num_requests=0,
total_queued_requests=0,
capacity_adjusted_min_replicas=config.min_replicas,
capacity_adjusted_max_replicas=config.max_replicas,
policy_state=policy_state or {},
deployment_id=dep_id,
deployment_name=dep_id.name,
app_name=dep_id.app_name,
running_replicas=[],
current_time=None,
aggregated_metrics=None,
raw_metrics=None,
last_scale_up_time=None,
last_scale_down_time=None,
total_pending_async_requests=0,
)
def test_shared_user_state_does_not_contaminate_internal_state(self):
d1 = DeploymentID("d1", "app")
d2 = DeploymentID("d2", "app")
fake_now = 1000.0
d1_internal_state = {
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY: 3,
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY: fake_now,
}
d2_internal_state = {
SERVE_AUTOSCALING_DECISION_COUNTERS_KEY: 0,
SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY: None,
}
shared_state = {"counter": 5}
def fake_policy(contexts):
return {d1: 3, d2: 3}, {d1: shared_state, d2: shared_state}
wrapped = _apply_app_level_autoscaling_config(fake_policy)
contexts = {
d1: self._make_context(d1, policy_state=d1_internal_state),
d2: self._make_context(d2, policy_state=d2_internal_state),
}
with patch("ray.serve.autoscaling_policy.time") as mock_time:
mock_time.time = lambda: fake_now
_, final_state = wrapped(contexts)
# d1 had counter=3, timestamp=fake_now. Delay logic sees scale-up
# (desired=3 > target=2), counter was positive so it increments to 4.
# Delay hasn't elapsed (0s < 100s) so no reset.
assert final_state[d1][SERVE_AUTOSCALING_DECISION_COUNTERS_KEY] == 4
assert final_state[d1][SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY] == fake_now
# user state remains intact
assert final_state[d1]["counter"] == 5
# d2 had counter=0, timestamp=None. Delay logic sees scale-up,
# increments counter to 1, sets timestamp to fake_now.
assert final_state[d2][SERVE_AUTOSCALING_DECISION_COUNTERS_KEY] == 1
assert final_state[d2][SERVE_AUTOSCALING_DECISION_TIMESTAMP_KEY] == fake_now
# user state remains intact
assert final_state[d2]["counter"] == 5
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))