Files
ray-project--ray/python/ray/serve/tests/test_capacity_queue_router.py
T
2026-07-13 13:17:40 +08:00

881 lines
32 KiB
Python

import sys
import uuid
from collections import Counter
import pytest
import ray
from ray import serve
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.serve._private.constants import SERVE_DEPLOYMENT_ACTOR_PREFIX, SERVE_NAMESPACE
from ray.serve._private.test_utils import check_running
from ray.serve.config import DeploymentActorConfig, RequestRouterConfig
from ray.serve.context import _get_internal_replica_context
from ray.serve.experimental.capacity_queue import (
CapacityQueue,
)
def _deploy_capacity_queue_app(
num_replicas: int = 3,
max_ongoing_requests: int = 5,
acquire_timeout_s: float = 0.5,
token_ttl_s: float = 5,
):
"""Deploy a simple app with CapacityQueue deployment actor and CapacityQueueRouter."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": acquire_timeout_s,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
initial_backoff_s=0.01,
backoff_multiplier=2.0,
max_backoff_s=0.1,
),
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
ray_actor_options={"num_cpus": 0},
)
class App:
def __init__(self):
context = _get_internal_replica_context()
self.replica_id = context.replica_id
self.unique_id = context.replica_id.unique_id
async def __call__(self):
return self.unique_id
handle = serve.run(App.bind())
return handle
def _deploy_blocking_capacity_queue_app(
signal_actor_name: str,
num_replicas: int = 2,
max_ongoing_requests: int = 5,
):
"""Deploy an app whose requests block until a SignalActor is triggered."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
initial_backoff_s=0.01,
backoff_multiplier=2.0,
max_backoff_s=0.1,
),
num_replicas=num_replicas,
max_ongoing_requests=max_ongoing_requests,
ray_actor_options={"num_cpus": 0},
)
class BlockingApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self):
signal = ray.get_actor(signal_actor_name)
await signal.wait.remote()
return self.unique_id
handle = serve.run(BlockingApp.bind())
return handle
def _find_capacity_queue_handle():
"""Find the CapacityQueue deployment actor."""
actors = ray.util.list_named_actors(all_namespaces=True)
for actor_info in actors:
if (
actor_info["namespace"] == SERVE_NAMESPACE
and "capacity_queue" in actor_info["name"]
and SERVE_DEPLOYMENT_ACTOR_PREFIX in actor_info["name"]
):
return ray.get_actor(actor_info["name"], namespace=SERVE_NAMESPACE)
return None
class TestCapacityQueueRouterBasic:
"""Basic integration tests for the capacity queue router."""
def test_single_request(self, serve_instance):
"""A single request should be routed to one of the replicas."""
handle = _deploy_capacity_queue_app(num_replicas=2)
# Wait for deployment to be healthy
wait_for_condition(check_running, timeout=30)
response = handle.remote().result(timeout_s=10)
assert isinstance(response, str)
assert len(response) > 0
def test_multiple_requests_distributed(self, serve_instance):
"""Requests should be distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send enough requests that all replicas should receive at least one
num_requests = 30
responses = []
for _ in range(num_requests):
r = handle.remote().result(timeout_s=10)
responses.append(r)
unique_replicas = set(responses)
# All replicas should have received at least one request
assert len(unique_replicas) == num_replicas, (
f"Expected {num_replicas} unique replicas, got {len(unique_replicas)}: "
f"{unique_replicas}"
)
def test_concurrent_requests(self, serve_instance):
"""Concurrent requests should be distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send concurrent requests
refs = [handle.remote() for _ in range(30)]
responses = [ref.result(timeout_s=30) for ref in refs]
unique_replicas = set(responses)
assert len(unique_replicas) == num_replicas
def test_capacity_queue_stats(self, serve_instance):
"""The capacity queue should track stats correctly.
Some early requests may fall back to power-of-two-choices before the
router discovers the queue, so we assert >= rather than exact counts.
"""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue_handle = _find_capacity_queue_handle()
assert queue_handle is not None, "CapacityQueue deployment actor not found"
# Wait for queue to have replicas before sending requests so most
# go through the queue path (not power-of-two-choices fallback).
wait_for_condition(
lambda: ray.get(queue_handle.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Send some requests
for _ in range(10):
handle.remote().result(timeout_s=10)
# Wait for all releases to settle (on_request_completed is async)
def _stats_settled():
stats = ray.get(queue_handle.get_stats.remote())
assert stats.num_replicas == 2
assert stats.total_in_flight == 0
# Most requests should go through the queue. Some may fall back
# to power-of-two-choices, so use >= with a lower bound.
assert stats.total_acquires >= 5
assert stats.total_releases >= 5
return True
wait_for_condition(_stats_settled, timeout=10)
class TestCapacityQueueRouterLoadBalancing:
"""Tests for load balancing behavior."""
def test_least_loaded_balancing(self, serve_instance):
"""Requests should be balanced across replicas (least-loaded)."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
# Send sequential requests - should round-robin approximately
num_requests = 60
responses = []
for _ in range(num_requests):
r = handle.remote().result(timeout_s=10)
responses.append(r)
counter = Counter(responses)
# Each replica should get roughly equal share
expected_per_replica = num_requests / num_replicas
for replica_id, count in counter.items():
assert (
count >= expected_per_replica * 0.3
), f"Replica {replica_id} got {count} requests, expected ~{expected_per_replica}"
class TestCapacityQueueRouterWithSingleReplica:
"""Tests with a single replica to verify basic token flow."""
def test_single_replica_all_requests(self, serve_instance):
"""With one replica, all requests should go to the same replica."""
handle = _deploy_capacity_queue_app(num_replicas=1)
wait_for_condition(check_running, timeout=30)
responses = set()
for _ in range(10):
r = handle.remote().result(timeout_s=10)
responses.add(r)
assert len(responses) == 1
class TestCapacityQueueRouterPowerOfTwoFallback:
"""Tests that the router falls back to power-of-two-choices when the
queue is unavailable."""
def test_requests_succeed_without_queue(self, serve_instance):
"""Requests succeed via power-of-two-choices even when the queue is
killed immediately."""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Kill the queue so all subsequent requests must use fallback.
ray.kill(queue)
for _ in range(5):
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
def test_requests_distributed_without_queue(self, serve_instance):
"""In fallback mode, requests are still distributed across replicas."""
num_replicas = 3
handle = _deploy_capacity_queue_app(num_replicas=num_replicas)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == num_replicas,
timeout=15,
)
# Kill the queue.
ray.kill(queue)
responses = []
for _ in range(30):
r = handle.remote().result(timeout_s=15)
responses.append(r)
unique_replicas = set(responses)
assert len(unique_replicas) == num_replicas
class TestCapacityQueueRouterFailures:
def test_unreleased_token_recovered_by_ttl(self, serve_instance):
"""Leaked tokens are automatically reclaimed after the TTL expires.
When a token is acquired but never released (e.g. a router process
dies between acquire() and release()), the queue's in_flight count
stays elevated. With token_ttl_s configured, a background reaper
reclaims expired tokens and restores full capacity.
"""
token_ttl_s = 2.0
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=3,
ray_actor_options={"num_cpus": 0},
)
class TtlApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self):
return self.unique_id
handle = serve.run(TtlApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Simulate a router acquiring a token then crashing (never releases).
leaked = ray.get(queue.acquire.remote(timeout_s=5))
assert leaked is not None
stats = ray.get(queue.get_stats.remote())
assert stats.total_in_flight == 1
assert stats.queue_size == 2 # 3 capacity - 1 leaked
# Remaining capacity still serves requests.
resp = handle.remote().result(timeout_s=10)
assert isinstance(resp, str)
# After the TTL expires, the reaper reclaims the leaked token and
# full capacity is restored.
def _capacity_restored():
s = ray.get(queue.get_stats.remote())
return s.total_in_flight == 0 and s.queue_size == 3
wait_for_condition(_capacity_restored, timeout=token_ttl_s + 5)
def test_replica_death_releases_token_and_recovers(self, serve_instance):
"""When a replica dies mid-request, its token is released and
the queue stops routing to it after the long-poll update."""
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=2,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class CrashApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, crash: bool = False):
if crash:
import os
os._exit(1)
return self.unique_id
handle = serve.run(CrashApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Crash one replica by sending a request that exits the process.
try:
handle.remote(crash=True).result(timeout_s=5)
except Exception:
pass # Expected — the replica died.
# The controller detects the death, removes the replica, and starts
# a replacement. Long poll updates the queue. Eventually the queue
# should recover to 2 replicas (the survivor + the replacement)
# with full capacity and no leaked in-flight counts.
def _cluster_fully_recovered():
stats = ray.get(queue.get_stats.remote())
assert stats.num_replicas == 2
assert stats.total_capacity == 4 # 2 replicas * max_ongoing_requests=2
assert stats.total_in_flight == 0
return True
wait_for_condition(_cluster_fully_recovered, timeout=30)
# Requests still succeed — routed to the surviving / replacement replica.
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
def test_capacity_queue_death_and_recovery(self, serve_instance):
"""When the CapacityQueue actor dies, the router falls back to
power-of-two-choices and requests continue to succeed. Once the
controller recreates the queue, the router rediscovers it and
resumes token-based routing.
"""
handle = _deploy_capacity_queue_app(num_replicas=2)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 2,
timeout=15,
)
# Verify requests work before the kill.
resp = handle.remote().result(timeout_s=10)
assert isinstance(resp, str)
# Kill the capacity queue actor.
ray.kill(queue)
# Requests should STILL succeed via power-of-two-choices fallback
# even while the queue is dead.
resp = handle.remote().result(timeout_s=15)
assert isinstance(resp, str)
# The controller recreates the deployment actor. The new queue starts
# fresh and gets replicas via long poll. Wait for it to appear.
def _queue_recovered():
new_q = _find_capacity_queue_handle()
if new_q is None:
return False
stats = ray.get(new_q.get_stats.remote())
return stats.num_replicas == 2
wait_for_condition(_queue_recovered, timeout=30)
# After recovery, requests go through the queue again. Verify the
# new queue is being used by checking that acquires increase.
new_queue = _find_capacity_queue_handle()
stats_before = ray.get(new_queue.get_stats.remote())
for _ in range(3):
handle.remote().result(timeout_s=10)
def _queue_used():
stats = ray.get(new_queue.get_stats.remote())
return stats.total_acquires > stats_before.total_acquires
wait_for_condition(_queue_used, timeout=10)
def test_capacity_queue_restarts_with_full_capacity(self, serve_instance):
"""
After a queue restart, it bootstraps with full capacity even though
replicas may have in-flight requests from before the crash.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
handle = _deploy_blocking_capacity_queue_app(
signal_actor_name=signal_name,
num_replicas=1,
max_ongoing_requests=2,
)
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Send a blocking request — occupies 1 of 2 slots on the replica.
ref = handle.remote()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 1,
timeout=10,
)
# Kill the capacity queue.
ray.kill(queue)
# Wait for the controller to recreate it.
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
# The new queue shows full capacity (2) even though the replica still
# has 1 in-flight request from before the crash.
new_queue = _find_capacity_queue_handle()
stats = ray.get(new_queue.get_stats.remote())
assert stats.total_capacity == 2
assert stats.total_in_flight == 0 # Queue doesn't know about the old request
# Release the signal so the blocked request finishes.
ray.get(signal.send.remote())
try:
ref.result(timeout_s=10)
except Exception:
pass # May fail since the queue died mid-request
# Cleanup
ray.kill(signal)
def test_queue_converges_after_restart(self, serve_instance):
"""After the queue restarts, its per-replica token view converges to
match actual replica capacity.
Setup: 1 replica, max_ongoing_requests=5, 3 blocked requests.
1. Send 3 blocking requests occupying 3/5 slots. Queue correctly
shows in_flight=3, available_tokens=2 for the replica.
2. Kill the queue — it restarts with in_flight=0, thinking the
replica has 5 available tokens (stale).
3. The router sends requests via the stale queue. Tokens for the
3 occupied slots get rejected. Unreleased rejection tokens
ratchet in_flight up, teaching the queue the correct state.
4. Release the blocking requests — replica frees all 5 slots.
5. TTL reaper clears phantom in_flight entries from rejections.
6. Assert per-replica convergence: available_tokens == max_capacity.
"""
token_ttl_s = 2.0
max_ongoing = 5
num_blocked = 3
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": token_ttl_s,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=max_ongoing,
ray_actor_options={"num_cpus": 0},
)
class ConvergeApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(ConvergeApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Step 1: Occupy 3 of 5 slots with blocking requests.
blocking_refs = [handle.remote(block=True) for _ in range(num_blocked)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == num_blocked,
timeout=15,
)
# Verify pre-crash per-replica state: in_flight=3, capacity=5.
replica_info = ray.get(queue.get_replica_in_flight.remote())
assert len(replica_info) == 1
for rid, (in_flight, max_cap) in replica_info.items():
assert max_cap == max_ongoing
assert in_flight == num_blocked
assert max_cap - in_flight == 2 # 2 available tokens
# Step 2: Kill the queue. It restarts with in_flight=0.
ray.kill(queue)
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
new_queue = _find_capacity_queue_handle()
# Verify stale state: queue thinks replica has 5 available tokens.
stale_info = ray.get(new_queue.get_replica_in_flight.remote())
for rid, (in_flight, max_cap) in stale_info.items():
assert in_flight == 0
assert max_cap == max_ongoing
# Step 3 & 4: Release the blocking requests so the replica frees up.
ray.get(signal.send.remote())
for ref in blocking_refs:
try:
ref.result(timeout_s=15)
except Exception:
pass # May fail — queue died while these were in flight.
# Send requests to exercise the queue and trigger any rejection-based
# learning for the stale window.
for _ in range(5):
handle.remote().result(timeout_s=15)
# Step 5 & 6: Wait for TTL reaper, then verify per-replica convergence.
# available_tokens (max_capacity - in_flight) must equal max_capacity
# because the replica has 0 real in-flight after the signal release.
def _per_replica_converged():
info = ray.get(new_queue.get_replica_in_flight.remote())
if len(info) != 1:
return False
for in_flight, max_cap in info.values():
if max_cap - in_flight != max_ongoing:
return False
return True
wait_for_condition(_per_replica_converged, timeout=token_ttl_s + 10)
# Final assertion: in_flight is exactly 0, all 5 tokens available.
final_info = ray.get(new_queue.get_replica_in_flight.remote())
for rid, (in_flight, max_cap) in final_info.items():
assert (
in_flight == 0
), f"Replica {rid}: expected converged in_flight=0, got {in_flight}"
assert max_cap - in_flight == max_ongoing
ray.kill(signal)
def test_capacity_depleted_backoff_and_recovery(self, serve_instance):
"""
When all replicas are at capacity, the router backs off and
retries until capacity frees up.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class DepletedApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(DepletedApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=10,
)
# Fill both slots with blocking requests.
blocking_refs = [handle.remote(block=True) for _ in range(2)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 2,
timeout=10,
)
# Send a third request — will be blocked waiting for capacity.
waiting_ref = handle.remote(block=False)
# Wait for at least one CQ timeout — proves the router hit the
# depleted path and backed off (total_timeouts is 0 before this
# since the blocking requests were acquired via the fast path).
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_timeouts >= 1,
timeout=30,
)
# Release the blockers so capacity frees up.
ray.get(signal.send.remote())
for ref in blocking_refs:
ref.result(timeout_s=15)
# The waiting request should complete once capacity is available.
result = waiting_ref.result(timeout_s=15)
assert isinstance(result, str)
ray.kill(signal)
def test_rejection_teaches_cq_after_restart(self, serve_instance):
"""
After a CQ restart, rejected tokens are NOT released back to the
CQ, so in_flight stays elevated and the CQ learns the replica is busy.
"""
signal_name = f"block_signal_{uuid.uuid4().hex[:8]}"
signal = SignalActor.options(name=signal_name).remote()
@serve.deployment(
deployment_actors=[
DeploymentActorConfig(
name="capacity_queue",
actor_class=CapacityQueue,
init_kwargs={
"acquire_timeout_s": 0.5,
"token_ttl_s": 5,
},
actor_options={"num_cpus": 0},
),
],
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.capacity_queue_router:CapacityQueueRouter"
),
request_router_kwargs={
"capacity_queue_actor_name": "capacity_queue",
},
),
num_replicas=1,
max_ongoing_requests=2,
ray_actor_options={"num_cpus": 0},
)
class RejectApp:
def __init__(self):
context = _get_internal_replica_context()
self.unique_id = context.replica_id.unique_id
async def __call__(self, block: bool = False):
if block:
sig = ray.get_actor(signal_name)
await sig.wait.remote()
return self.unique_id
handle = serve.run(RejectApp.bind())
wait_for_condition(check_running, timeout=30)
queue = _find_capacity_queue_handle()
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).num_replicas == 1,
timeout=15,
)
# Step 1: Saturate the replica — 2/2 slots occupied.
blocking_refs = [handle.remote(block=True) for _ in range(2)]
wait_for_condition(
lambda: ray.get(queue.get_stats.remote()).total_in_flight == 2,
timeout=10,
)
# Step 2: Kill the CQ. It restarts with in_flight=0.
ray.kill(queue)
def _new_queue_ready():
q = _find_capacity_queue_handle()
if q is None:
return False
stats = ray.get(q.get_stats.remote())
return stats.num_replicas == 1
wait_for_condition(_new_queue_ready, timeout=30)
new_queue = _find_capacity_queue_handle()
stale_stats = ray.get(new_queue.get_stats.remote())
assert stale_stats.total_in_flight == 0 # Stale: thinks replica is idle
# Step 3: Send a non-blocking request. The stale CQ issues a token,
# the replica rejects (full), the router retries. The rejected token
# is NOT released, so the CQ's in_flight ratchets up.
new_ref = handle.remote(block=False)
# Step 4: The CQ should have learned — in_flight > 0 because the
# rejected token was not released.
def _cq_learned():
stats = ray.get(new_queue.get_stats.remote())
return stats.total_in_flight > 0
wait_for_condition(_cq_learned, timeout=15)
# Step 5: Release the blockers so all requests complete.
ray.get(signal.send.remote())
for ref in blocking_refs:
try:
ref.result(timeout_s=15)
except Exception:
pass
result = new_ref.result(timeout_s=15)
assert isinstance(result, str)
ray.kill(signal)
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v", "-s"]))