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

663 lines
22 KiB
Python

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