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

329 lines
12 KiB
Python

# flake8: noqa
import asyncio
import logging
import random
import time
from collections import deque
from dataclasses import dataclass
from typing import Deque, Dict, List, Optional, Set, Tuple
import ray
from ray import serve
from ray._common.utils import get_or_create_event_loop
from ray.serve._private.common import DeploymentID, DeploymentTargetInfo
from ray.serve._private.constants import SERVE_CONTROLLER_NAME, SERVE_NAMESPACE
from ray.serve._private.long_poll import LongPollClient, LongPollNamespace
logger = logging.getLogger("ray.serve")
@dataclass
class CapacityQueueStats:
"""Statistics for a CapacityQueue."""
queue_size: int
num_waiters: int
num_replicas: int
total_capacity: int
total_in_flight: int
total_acquires: int
total_releases: int
total_timeouts: int
max_waiters_seen: int
@dataclass
class ReplicaCapacityInfo:
"""Tracks capacity information for a single replica."""
replica_id: str
max_capacity: int
in_flight: int = 0
@ray.remote(num_cpus=0)
class CapacityQueue:
"""Centralized capacity manager that routes requests to the least-loaded replica.
A Ray actor that tracks per-replica in-flight counts and hands out capacity
tokens. Routers call ``acquire()`` to get a replica_id, route to it, then
call ``release()`` when done. If no capacity is available, acquire blocks
until ``acquire_timeout_s`` expires (returns None).
Replica membership is kept in sync via long-poll subscription to the Serve
controller. After a crash/restart, CapacityQueue may temporarily over-provision;
this self-heals because replicas enforce their own limits and reject excess.
"""
def __init__(
self,
acquire_timeout_s: float,
token_ttl_s: Optional[float],
_enable_long_poll: bool = True,
):
self._acquire_timeout_s = acquire_timeout_s
self._token_ttl_s = token_ttl_s
# Waiters: (asyncio.Future, timestamp) for requests waiting for capacity
self._waiters: Deque[Tuple[asyncio.Future, float]] = deque()
# Registered replicas and their capacity info
self._replicas: Dict[str, ReplicaCapacityInfo] = {}
# Track when each in-flight token was acquired: {replica_id -> [timestamp, ...]}
self._in_flight_timestamps: Dict[str, Deque[float]] = {}
# Statistics
self._total_acquires: int = 0
self._total_releases: int = 0
self._total_timeouts: int = 0
self._total_ttl_reclaims: int = 0
self._max_waiters_seen: int = 0
# Subscribe to replica updates from the Serve controller so the queue
# automatically registers new replicas and unregisters dead ones.
self._long_poll_client: Optional[LongPollClient] = None
if _enable_long_poll:
deployment_context = serve.get_deployment_actor_context()
deployment_id = deployment_context.deployment_id
controller_handle = ray.get_actor(
SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE
)
self._long_poll_client = LongPollClient(
controller_handle,
{
(
LongPollNamespace.DEPLOYMENT_TARGETS,
deployment_id,
): self._update_deployment_targets,
},
call_in_event_loop=get_or_create_event_loop(),
client_id=f"{type(self).__name__}:{ray.get_runtime_context().get_actor_id()}",
)
# Start background TTL reaper if token_ttl_s is set.
if self._token_ttl_s is not None and self._token_ttl_s > 0:
self._ttl_task = get_or_create_event_loop().create_task(
self._reap_expired_tokens()
)
async def _reap_expired_tokens(self) -> None:
"""Periodically reclaim tokens that have exceeded their TTL."""
interval = self._token_ttl_s / 2
while True:
await asyncio.sleep(interval)
now = time.time()
reclaimed = 0
for replica_id, timestamps in list(self._in_flight_timestamps.items()):
while timestamps and (now - timestamps[0]) > self._token_ttl_s:
timestamps.popleft()
if replica_id in self._replicas:
self._decrement_in_flight(replica_id)
reclaimed += 1
if reclaimed > 0:
self._total_ttl_reclaims += reclaimed
logger.info(f"TTL reaper reclaimed {reclaimed} expired token(s).")
self._fulfill_waiters()
def _decrement_in_flight(self, replica_id: str) -> None:
"""Decrement in-flight count for a replica, clamping at zero."""
info = self._replicas[replica_id]
info.in_flight = max(0, info.in_flight - 1)
def register_replica(self, replica_id: str, capacity: int) -> None:
"""Register a replica with its capacity.
If the replica is already registered, this is a no-op.
"""
if replica_id in self._replicas:
return
self._replicas[replica_id] = ReplicaCapacityInfo(
replica_id=replica_id,
max_capacity=capacity,
in_flight=0,
)
logger.debug(
f"Registered replica {replica_id} with capacity {capacity}. "
f"Total replicas: {len(self._replicas)}."
)
self._fulfill_waiters()
def unregister_replica(self, replica_id: str) -> None:
"""Unregister a replica and remove its capacity."""
if replica_id not in self._replicas:
return
info = self._replicas.pop(replica_id)
self._in_flight_timestamps.pop(replica_id, None)
logger.debug(
f"Unregistered replica {replica_id} "
f"(had {info.in_flight}/{info.max_capacity} in-flight). "
f"Total replicas: {len(self._replicas)}."
)
def _update_deployment_targets(
self, deployment_target_info: DeploymentTargetInfo
) -> None:
"""Handle deployment target updates from the controller (via long poll).
Automatically registers new replicas and unregisters removed ones so the
queue always reflects the set of live replicas.
"""
running_replicas = deployment_target_info.running_replicas
current_ids: Set[str] = {r.replica_id.unique_id for r in running_replicas}
registered_ids: Set[str] = set(self._replicas.keys())
for rid in registered_ids - current_ids:
self.unregister_replica(rid)
replica_by_uid = {r.replica_id.unique_id: r for r in running_replicas}
for uid in current_ids - registered_ids:
self.register_replica(uid, replica_by_uid[uid].max_ongoing_requests)
def _get_least_loaded_replica(self) -> Optional[str]:
"""Find the replica with fewest in-flight requests that has capacity.
Ties are broken randomly.
"""
best_replicas: List[str] = []
best_in_flight: float = float("inf")
for replica_id, info in self._replicas.items():
available = info.max_capacity - info.in_flight
if available > 0:
if info.in_flight < best_in_flight:
best_replicas = [replica_id]
best_in_flight = info.in_flight
elif info.in_flight == best_in_flight:
best_replicas.append(replica_id)
if not best_replicas:
return None
return random.choice(best_replicas)
def _get_total_available_capacity(self) -> int:
"""Get total available capacity across all replicas."""
return sum(
max(0, info.max_capacity - info.in_flight)
for info in self._replicas.values()
)
def _has_available_capacity(self) -> bool:
"""Check if any replica has available capacity."""
return any(
info.max_capacity > info.in_flight for info in self._replicas.values()
)
async def acquire(self, timeout_s: Optional[float] = None) -> Optional[str]:
"""Acquire a capacity token from the least loaded replica.
Returns the replica_id to route to, or None on timeout.
Caller MUST call release() when the request completes.
"""
self._total_acquires += 1
timeout = timeout_s if timeout_s is not None else self._acquire_timeout_s
# Fast path: find least loaded replica
replica_id = self._get_least_loaded_replica()
if replica_id is not None:
self._replicas[replica_id].in_flight += 1
self._record_acquire_timestamp(replica_id)
return replica_id
# Slow path: wait for capacity
loop = asyncio.get_running_loop()
future: asyncio.Future[str] = loop.create_future()
waiter_entry = (future, time.time())
self._waiters.append(waiter_entry)
self._max_waiters_seen = max(self._max_waiters_seen, len(self._waiters))
try:
replica_id = await asyncio.wait_for(future, timeout=timeout)
self._record_acquire_timestamp(replica_id)
return replica_id
except asyncio.TimeoutError:
self._total_timeouts += 1
self._waiters = deque((f, t) for f, t in self._waiters if f is not future)
return None
except asyncio.CancelledError:
self._waiters = deque((f, t) for f, t in self._waiters if f is not future)
raise
def release(self, replica_id: str) -> None:
"""Release a capacity token when a request completes.
MUST be called after acquire() when the request finishes.
"""
self._total_releases += 1
if replica_id not in self._replicas:
logger.warning(f"Release called for unknown replica {replica_id}.")
return
self._decrement_in_flight(replica_id)
# Remove the oldest timestamp for this replica.
if replica_id in self._in_flight_timestamps:
ts = self._in_flight_timestamps[replica_id]
if ts:
ts.popleft()
self._fulfill_waiters()
def _record_acquire_timestamp(self, replica_id: str) -> None:
"""Record the timestamp of an acquired token for TTL tracking."""
if self._token_ttl_s is not None:
if replica_id not in self._in_flight_timestamps:
self._in_flight_timestamps[replica_id] = deque()
self._in_flight_timestamps[replica_id].append(time.time())
def _fulfill_waiters(self) -> None:
"""Try to fulfill waiting requests with available capacity."""
while self._waiters and self._has_available_capacity():
future, _ = self._waiters.popleft()
if future.done():
continue
replica_id = self._get_least_loaded_replica()
if replica_id is None:
self._waiters.appendleft((future, time.time()))
break
self._replicas[replica_id].in_flight += 1
self._record_acquire_timestamp(replica_id)
future.set_result(replica_id)
def get_stats(self) -> CapacityQueueStats:
"""Get queue statistics."""
total_capacity = sum(r.max_capacity for r in self._replicas.values())
total_in_flight = sum(r.in_flight for r in self._replicas.values())
return CapacityQueueStats(
queue_size=self._get_total_available_capacity(),
num_waiters=len(self._waiters),
num_replicas=len(self._replicas),
total_capacity=total_capacity,
total_in_flight=total_in_flight,
total_acquires=self._total_acquires,
total_releases=self._total_releases,
total_timeouts=self._total_timeouts,
max_waiters_seen=self._max_waiters_seen,
)
def get_queue_length(self) -> int:
"""Get total available capacity."""
return self._get_total_available_capacity()
def get_num_waiters(self) -> int:
"""Get number of requests waiting for capacity."""
return len(self._waiters)
def get_registered_replicas(self) -> List[str]:
"""Get list of registered replica IDs."""
return list(self._replicas.keys())
def get_replica_in_flight(self) -> Dict[str, Tuple[int, int]]:
"""Get per-replica (in_flight, max_capacity) for convergence checks."""
return {
rid: (info.in_flight, info.max_capacity)
for rid, info in self._replicas.items()
}