chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,328 @@
# 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()
}
@@ -0,0 +1,303 @@
# flake8: noqa
import asyncio
import logging
from typing import Dict, List, Optional
import ray
from ray.exceptions import RayActorError
from ray.serve._private.common import ReplicaID
from ray.serve._private.constants import (
SERVE_DEPLOYMENT_ACTOR_PREFIX,
SERVE_NAMESPACE,
)
from ray.serve._private.request_router.pow_2_router import (
PowerOfTwoChoicesRequestRouter,
)
from ray.serve.request_router import (
LocalityMixin,
MultiplexMixin,
PendingRequest,
ReplicaResult,
RequestRouter,
RunningReplica,
)
logger = logging.getLogger("ray.serve")
DEFAULT_MAX_FAULT_RETRIES = 3
class CapacityQueueRouter(LocalityMixin, MultiplexMixin, RequestRouter):
"""Custom request router that uses a CapacityQueue deployment actor.
Acquires capacity tokens from a centralized CapacityQueue before routing
requests. Each token guarantees the target replica has capacity.
If the capacity queue is unavailable (dead, not yet discovered, or times out),
the router retries with exponential backoff up to MAX_FAULT_RETRIES times,
then falls back to the standard power-of-two-choices algorithm for that
request.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._capacity_queue = None
self._capacity_queue_actor_name: Optional[str] = None
self._capacity_queue_full_name: Optional[str] = None
# Tokens acquired but not yet accepted by the replica (pending the
# rejection protocol handshake). On rejection the token is
# intentionally leaked so the CQ's in_flight stays elevated, teaching
# it that the replica is busy.
self._pending_tokens: Dict[str, str] = {}
# Tokens whose replica accepted the request. Released when the
# request completes or is cancelled.
self._acquired_tokens: Dict[str, str] = {}
# Reverse index: unique_id string -> ReplicaID for O(1) lookup.
# Rebuilt by update_replicas whenever the replica set changes.
self._uid_to_replica_id: Dict[str, ReplicaID] = {}
def initialize_state(self, **kwargs):
if "capacity_queue_actor_name" not in kwargs:
raise ValueError(
"CapacityQueueRouter requires 'capacity_queue_actor_name' in "
"request_router_kwargs."
)
self._capacity_queue_actor_name = kwargs["capacity_queue_actor_name"]
self._max_fault_retries = kwargs.get(
"max_fault_retries", DEFAULT_MAX_FAULT_RETRIES
)
def _try_discover_capacity_queue(self) -> bool:
"""Try to discover the CapacityQueue deployment actor.
TODO (jeffreywang): Find a better way to discover the CapacityQueue actor.
Returns True if discovered, False otherwise (does not raise).
"""
if self._capacity_queue_full_name is not None:
try:
self._capacity_queue = ray.get_actor(
self._capacity_queue_full_name, namespace=SERVE_NAMESPACE
)
return True
except Exception:
pass
return False
prefix = (
f"{SERVE_DEPLOYMENT_ACTOR_PREFIX}"
f"{self._deployment_id.app_name}::"
f"{self._deployment_id.name}::"
)
suffix = f"::{self._capacity_queue_actor_name}"
try:
actors = ray.util.list_named_actors(all_namespaces=True)
for actor_info in actors:
if actor_info["namespace"] == SERVE_NAMESPACE:
name = actor_info["name"]
if name.startswith(prefix) and name.endswith(suffix):
self._capacity_queue = ray.get_actor(
name, namespace=SERVE_NAMESPACE
)
self._capacity_queue_full_name = name
return True
except Exception:
pass
return False
def update_replicas(self, replicas: List[RunningReplica]):
super().update_replicas(replicas)
self._uid_to_replica_id = {rid.unique_id: rid for rid in self._replicas}
def _safe_release(self, replica_id: str) -> None:
"""Release a token, handling queue actor death."""
if self._capacity_queue is None:
return
try:
self._capacity_queue.release.remote(replica_id)
except RayActorError:
self._capacity_queue = None
async def _choose_replica_for_request(
self, pending_request: PendingRequest, *, is_retry: bool = False
) -> RunningReplica:
"""Choose a replica by acquiring a token from the capacity queue.
This overrides the base-class method to bypass the routing-task
machinery (choose_replicas -> probe -> retry loop). The CQ acquire
runs directly in the caller's coroutine so the event loop stays
responsive.
On transient CQ faults (actor death, discovery failure), retries with
exponential backoff up to MAX_FAULT_RETRIES times, then falls back to
power-of-two-choices for this request.
"""
internal_request_id = pending_request.metadata.internal_request_id
# On retry (replica rejected), drop the old pending token WITHOUT
# releasing it. The leaked token keeps in_flight elevated on the CQ,
# teaching it that the replica is busy. The TTL reaper eventually
# reclaims it.
if is_retry:
self._pending_tokens.pop(internal_request_id, None)
# Wait for at least one replica.
while len(self._replicas) == 0:
self._replicas_updated_event.clear()
await self._replicas_updated_event.wait()
fault_attempt = 0
capacity_depleted_attempt = 0
acquired_replica_id = None
try:
while True:
# Too many consecutive faults — fall back to Pow2.
if fault_attempt >= self._max_fault_retries:
return await super()._choose_replica_for_request(
pending_request, is_retry=is_retry
)
# Discover the CQ actor if we don't have a handle.
if self._capacity_queue is None:
if not self._try_discover_capacity_queue():
await self._backoff(fault_attempt)
fault_attempt += 1
continue
# Acquire a token from the CQ.
acquire_ref = None
try:
acquire_ref = self._capacity_queue.acquire.remote()
acquired_replica_id = await acquire_ref
except asyncio.CancelledError:
if acquire_ref is not None:
ray.cancel(acquire_ref)
raise
except RayActorError:
logger.warning(
"Lost connection to CapacityQueue actor: "
f"{self._capacity_queue_full_name}."
)
self._capacity_queue = None
await self._backoff(fault_attempt)
fault_attempt += 1
continue
except Exception:
logger.warning(
"Unexpected error acquiring from CapacityQueue: "
f"{self._capacity_queue_full_name}.",
exc_info=True,
)
await self._backoff(fault_attempt)
fault_attempt += 1
continue
# CQ returned None — capacity exhausted. This is normal
# backpressure, not a fault, so it doesn't increment
# fault_attempt or trigger Pow2 fallback. Apply backoff
# to avoid a hot loop when acquire_timeout_s is low.
if acquired_replica_id is None:
await self._backoff(capacity_depleted_attempt)
capacity_depleted_attempt += 1
continue
# O(1) lookup via reverse index.
rid = self._uid_to_replica_id.get(acquired_replica_id)
replica = self._replicas.get(rid) if rid is not None else None
if replica is not None:
# Store as pending until on_request_routed confirms
# acceptance. If the replica rejects, the token is
# intentionally NOT released.
self._pending_tokens[internal_request_id] = acquired_replica_id
acquired_replica_id = None
fault_attempt = 0
capacity_depleted_attempt = 0
return replica
# Replica not found — release token and wait for replica update.
# This happens during replica transitions when the CQ knows about
# a new replica before the local router does. Without waiting,
# the CQ fast path creates a hot loop returning the same unknown
# replica.
logger.debug(
f"CapacityQueue returned unknown replica {acquired_replica_id}; "
"releasing token and waiting for replica update."
)
self._safe_release(acquired_replica_id)
acquired_replica_id = None
# Wait for the router's local replica state to be updated via long polling.
# This ensures we don't create a hot loop when the queue is ahead of us.
self._replicas_updated_event.clear()
await self._replicas_updated_event.wait()
except asyncio.CancelledError:
# If cancelled after acquiring a token but before storing it in
# _pending_tokens, release it to avoid leaking capacity.
if acquired_replica_id is not None:
self._safe_release(acquired_replica_id)
raise
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> List[List[RunningReplica]]:
"""Pow2 candidate selection — no CQ calls.
Only reached when _choose_replica_for_request falls back to the
base class after MAX_FAULT_RETRIES consecutive CQ faults.
"""
return await PowerOfTwoChoicesRequestRouter.choose_replicas(
self,
candidate_replicas=candidate_replicas,
pending_request=pending_request,
)
def on_request_routed(
self,
pending_request: PendingRequest,
replica_id: ReplicaID,
result: ReplicaResult,
):
"""Promote a pending token to acquired once the replica accepts.
Only called when the rejection protocol is active and the replica
accepts. On rejection this is NOT called, so the token stays in
_pending_tokens and is intentionally leaked on retry to teach the
CQ that the replica is busy.
"""
rid = pending_request.metadata.internal_request_id
token = self._pending_tokens.pop(rid, None)
if token is not None:
self._acquired_tokens[rid] = token
def on_request_completed(
self,
replica_id: ReplicaID,
internal_request_id: str,
):
"""
Release the capacity token when a request completes.
"""
token = self._acquired_tokens.pop(internal_request_id, None)
if token is None:
token = self._pending_tokens.pop(internal_request_id, None)
if token is not None:
self._safe_release(token)
def on_request_cancelled(
self,
internal_request_id: str,
):
"""Release the capacity token when a request is cancelled."""
token = self._acquired_tokens.pop(internal_request_id, None)
if token is None:
token = self._pending_tokens.pop(internal_request_id, None)
if token is not None:
self._safe_release(token)
@@ -0,0 +1,356 @@
"""Consistent-hash request router to honor session stickiness.
ConsistentHashRouter implements a consistent-hash ring with virtual nodes
to map session IDs to replicas. When the assigned replica rejects the request
due to backpressure, the router falls back to the next replica in the ring,
with at most DEFAULT_NUM_FALLBACK_REPLICAS replicas before backing off the request.
"""
import asyncio
import bisect
import logging
import time
from typing import FrozenSet, List, Optional
import mmh3
from ray.serve._private.common import ReplicaID
from ray.serve._private.constants import SERVE_LOGGER_NAME, SERVE_SESSION_ID
from ray.serve._private.request_router.common import PendingRequest
from ray.serve._private.request_router.replica_wrapper import RunningReplica
from ray.serve._private.request_router.request_router import RequestRouter
logger = logging.getLogger(SERVE_LOGGER_NAME)
# Fixed seed for consistent hashing. A seed change invalidates every running
# affinity map in the fleet and would silently remap every session the instant
# a new router picks up the controller broadcast.
CONSISTENT_HASH_SEED = 0xF9B4CA77
DEFAULT_NUM_VIRTUAL_NODES = 100
DEFAULT_NUM_FALLBACK_REPLICAS = 2
def _hash_bytes(key: bytes) -> int:
"""Hash ``key`` with MurmurHash3 and return the low 64 bits."""
return mmh3.hash64(key, seed=CONSISTENT_HASH_SEED, signed=False)[0]
class ConsistentHashRouter(RequestRouter):
"""Routes each request to a replica chosen by consistent hashing over
``session_id`` (falling back to ``internal_request_id``).
Every ``ConsistentHashRouter`` instance owns a private ring built from
the controller's long-poll replica broadcast. Routers on different
processes converge because they consume the same broadcast and use the
same hash + vnode count; router restarts are free because a fresh
router rebuilds the identical ring.
Consistent hashing is a deterministic, affinity-first strategy: the chosen
replica is a pure function of session ID and ring state. Mixing in queue-depth,
locality, or multiplexed model signals breaks determinism and therefore breaks
affinity.
Kwargs (passed via ``RequestRouterConfig.request_router_kwargs``):
- ``num_virtual_nodes`` (int, default ``100``): vnodes per replica on
the hash ring. Higher values spread sessions more evenly across
replicas at the cost of a larger ring.
- ``num_fallback_replicas`` (int, default ``2``): number of clockwise
successor replicas tried after the primary if it rejects (e.g.
backpressure). Set to ``0`` for strict affinity (no fallback).
Session affinity:
The routing key is ``RequestMetadata.session_id`` — populated upstream
from the HTTP header named by ``SERVE_SESSION_ID`` (set via env var
``RAY_SERVE_SESSION_ID_HEADER_KEY``; default ``x-session-id``).
Requests without that header fall back to ``internal_request_id``
(a fresh UUID per request) and therefore distribute uniformly across
replicas — same code path, no affinity. See
``ray.serve._private.constants.SERVE_SESSION_ID``.
Usage (Ray Serve LLM):
.. code-block:: python
from ray.serve.config import RequestRouterConfig
from ray.serve.llm import LLMConfig, ModelLoadingConfig
LLMConfig(
model_loading_config=ModelLoadingConfig(
model_id="Qwen/Qwen3-0.6B-FP8",
model_source="Qwen/Qwen3-0.6B-FP8",
),
deployment_config=dict(
request_router_config=RequestRouterConfig(
request_router_class=(
"ray.serve.experimental.consistent_hash_router."
"ConsistentHashRouter"
),
request_router_kwargs={
"num_virtual_nodes": 100,
"num_fallback_replicas": 2,
},
),
),
)
The session header itself is set globally on the cluster:
.. code-block:: bash
# Clients sending sessions on x-correlation-id (e.g. aiperf):
export RAY_SERVE_SESSION_ID_HEADER_KEY=x-correlation-id
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Ring state
self._ring_hashes: List[int] = []
self._ring_replicas: List[ReplicaID] = []
# Change-detection cache: Compared against the incoming set on
# each update_replicas tick.
self._replica_set_snapshot: FrozenSet[ReplicaID] = frozenset()
# Tunables populated by ``initialize_state``.
self._num_virtual_nodes: int = DEFAULT_NUM_VIRTUAL_NODES
self._num_fallback_replicas: int = DEFAULT_NUM_FALLBACK_REPLICAS
def initialize_state(self, **kwargs) -> None:
num_virtual_nodes = kwargs.get("num_virtual_nodes", DEFAULT_NUM_VIRTUAL_NODES)
num_fallback_replicas = kwargs.get(
"num_fallback_replicas", DEFAULT_NUM_FALLBACK_REPLICAS
)
if not isinstance(num_virtual_nodes, int) or num_virtual_nodes <= 0:
raise ValueError(
f"num_virtual_nodes must be a positive int, got {num_virtual_nodes!r}."
)
if not isinstance(num_fallback_replicas, int) or num_fallback_replicas < 0:
raise ValueError(
"num_fallback_replicas must be a non-negative int, got "
f"{num_fallback_replicas!r}."
)
self._num_virtual_nodes = num_virtual_nodes
self._num_fallback_replicas = num_fallback_replicas
logger.info(
f"ConsistentHashRouter initialized for {self._deployment_id}: "
f"num_virtual_nodes={self._num_virtual_nodes}, "
f"num_fallback_replicas={self._num_fallback_replicas}, "
f"session_id_header={SERVE_SESSION_ID!r} "
"(set via RAY_SERVE_SESSION_ID_HEADER_KEY)."
)
def update_replicas(self, replicas: List[RunningReplica]) -> None:
"""
Update available replicas and rebuild the ring when the set changes.
"""
super().update_replicas(replicas)
new_snapshot = frozenset(self._replica_id_set)
if new_snapshot == self._replica_set_snapshot:
return
self._rebuild_ring(new_snapshot)
def _rebuild_ring(self, new_snapshot: FrozenSet[ReplicaID]) -> None:
"""
Rebuild ring state from the current replica set.
Every vnode is hashed from f"{unique_id}:{vnode_index}". Using
the replica's unique_id rather than the full ReplicaID keeps the
key stable across controller restarts.
"""
new_hashes: List[int] = []
new_replicas: List[ReplicaID] = []
for replica_id in new_snapshot:
for i in range(self._num_virtual_nodes):
key = f"{replica_id.unique_id}:{i}".encode()
new_hashes.append(_hash_bytes(key))
new_replicas.append(replica_id)
if new_hashes:
paired = sorted(zip(new_hashes, new_replicas), key=lambda p: p[0])
new_hashes = [p[0] for p in paired]
new_replicas = [p[1] for p in paired]
self._ring_hashes = new_hashes
self._ring_replicas = new_replicas
self._replica_set_snapshot = new_snapshot
logger.info(
f"Rebuilt consistent-hash ring for {self._deployment_id}: "
f"{len(new_snapshot)} replicas, "
f"{len(new_hashes)} vnodes."
)
def _routing_key(self, pending_request: Optional[PendingRequest]) -> Optional[str]:
"""Prefer session_id; fall back to internal_request_id.
Hashing on internal_request_id (a fresh UUID per request)
spreads session-less traffic uniformly through the same code
path, avoiding mixing with another routing strategy (e.g. pow-2).
"""
if pending_request is None:
return None
metadata = pending_request.metadata
if metadata.session_id:
return metadata.session_id
# internal_request_id is always populated by the proxy or by
# get_request_metadata's fallback to generate_request_id().
return metadata.internal_request_id
def _lookup_ranked_replicas(self, routing_key: str) -> List[ReplicaID]:
"""
Walk the ring and return the primary plus up to K distinct
clockwise successor replicas.
"""
if not self._ring_hashes:
return []
key_hash = _hash_bytes(routing_key.encode())
start_idx = bisect.bisect_right(self._ring_hashes, key_hash)
if start_idx == len(self._ring_hashes):
# Wrapped past the last vnode
start_idx = 0
# Walk clockwise from the owner, collecting the primary and up to K
# fallback replicas, ensuring that each replica only appears once
# even if it owns multiple virtual nodes.
ranked: List[ReplicaID] = []
seen: set = set()
max_ranked = 1 + self._num_fallback_replicas
n = len(self._ring_hashes)
num_replicas = len(self._replica_set_snapshot)
for offset in range(n):
replica_id = self._ring_replicas[(start_idx + offset) % n]
if replica_id in seen:
continue
seen.add(replica_id)
ranked.append(replica_id)
if len(ranked) == max_ranked or len(seen) == num_replicas:
break
return ranked
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> List[List[RunningReplica]]:
"""Return ranked candidates for the outer retry loop.
Each rank is a single-element list containing one replica, so the
retry loop walks primary -> fallback_1 -> fallback_2 in strict
hash order with backoff.
"""
if not candidate_replicas:
return []
if pending_request is not None:
# Enable exponential-backoff sleep between outer retry iterations.
pending_request.routing_context.should_backoff = True
routing_key = self._routing_key(pending_request)
if routing_key is None:
# Returning [] here would livelock the framework's retry loop.
# Yield candidates so the task can loop back for real requests.
return [candidate_replicas]
ranked_ids = self._lookup_ranked_replicas(routing_key)
ranks: List[List[RunningReplica]] = []
for replica_id in ranked_ids:
replica = self._replicas.get(replica_id)
if replica is not None:
ranks.append([replica])
return ranks
async def _fulfill_pending_requests(self):
"""Overrides the base loop with two consistent-hash-specific
invariants:
1. Exit immediately when there is nothing to route (rather than
routing for a None pending request, which the base class does
assuming a FIFO fallback that ConsistentHashRouter does not
provide).
2. The routing task that owns a popped pending request must keep
retrying the inner loop until the request is fulfilled, even if
there are now more routing tasks than the target. Without this,
concurrent same-session bursts would orphan their pending
requests under load.
"""
try:
while len(self._routing_tasks) <= self.target_num_routing_tasks:
start_time = time.time()
backoff_index = 0
pending_request = self._get_next_pending_request_to_route()
if pending_request is None:
# No work for this task; let it exit. Future calls to
# _maybe_start_routing_tasks (on new requests or replica
# updates) will spawn fresh tasks. Drain done entries
# from the front of the fulfill deque first so that
# ``num_pending_requests`` accurately reflects active
# work for the next round of task accounting.
while (
len(self._pending_requests_to_fulfill) > 0
and self._pending_requests_to_fulfill[0].future.done()
):
self._pending_requests_to_fulfill.popleft()
return
request_metadata = pending_request.metadata
gen_choose_replicas_with_backoff = self._choose_replicas_with_backoff(
pending_request
)
try:
async for candidates in gen_choose_replicas_with_backoff:
while (
len(self._pending_requests_to_fulfill) > 0
and self._pending_requests_to_fulfill[0].future.done()
):
self._pending_requests_to_fulfill.popleft()
# Note: unlike the base class, we deliberately do NOT
# break on `len(routing_tasks) > target` here when our
# popped pending_request hasn't been fulfilled yet, so
# that the popping task always finishes its work.
if (
pending_request.future.done()
and len(self._routing_tasks) > self.target_num_routing_tasks
):
break
replica = await self._select_from_candidate_replicas(
candidates, backoff_index
)
if replica is not None:
self._fulfill_next_pending_request(
replica, request_metadata
)
break
backoff_index += 1
if backoff_index >= 50 and backoff_index % 50 == 0:
routing_time_elapsed = time.time() - start_time
warning_log = (
"Failed to route request after "
f"{backoff_index} attempts over "
f"{routing_time_elapsed:.2f}s. Retrying. "
f"Request ID: {request_metadata.request_id}."
)
logger.warning(warning_log)
finally:
await gen_choose_replicas_with_backoff.aclose()
except Exception:
logger.exception("Unexpected error in _fulfill_pending_requests.")
finally:
self._routing_tasks.remove(asyncio.current_task(loop=self._event_loop))
self.num_routing_tasks_gauge.set(self.curr_num_routing_tasks)
@@ -0,0 +1,90 @@
"""Round-robin request router.
RoundRobinRouter cycles through the candidate replicas passed in by Serve and
returns strictly ordered singleton ranks. Each request starts at the current
round-robin cursor; if that replica cannot fulfill the request, Serve tries the
next replica in order, wrapping around the candidate list.
"""
import random
from collections.abc import Sequence
from typing import List, Optional
from ray.serve._private.request_router.common import (
PendingRequest,
)
from ray.serve._private.request_router.replica_wrapper import (
RunningReplica,
)
from ray.serve._private.request_router.request_router import (
FIFOMixin,
RequestRouter,
)
class _RoundRobinReplicaRanks(Sequence[List[RunningReplica]]):
"""Lazy ordered singleton ranks for a strict round-robin attempt."""
def __init__(
self,
replicas: List[RunningReplica],
start_index: int,
):
self._replicas = replicas
self._start_index = start_index
def __len__(self) -> int:
return len(self._replicas)
def __getitem__(self, index):
if isinstance(index, slice):
return list(self)[index]
num_replicas = len(self._replicas)
if index < 0:
index += num_replicas
if index < 0 or index >= num_replicas:
raise IndexError(index)
return [self._replicas[(self._start_index + index) % num_replicas]]
def __iter__(self):
num_replicas = len(self._replicas)
for offset in range(num_replicas):
yield [self._replicas[(self._start_index + offset) % num_replicas]]
class RoundRobinRouter(FIFOMixin, RequestRouter):
"""Routes requests by cycling through candidate replicas.
Each call to ``choose_replicas`` advances a shared cursor by one position
and returns ordered singleton ranks starting from that cursor. This upholds
strict round-robin ordering while still allowing Serve's existing selector
to continue to the next replica if the current one is full.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._round_robin_counter = random.randrange(2**31)
def initialize_state(self, **kwargs) -> None:
pass
async def choose_replicas(
self,
candidate_replicas: List[RunningReplica],
pending_request: Optional[PendingRequest] = None,
) -> Sequence[List[RunningReplica]]:
if not candidate_replicas:
return []
if pending_request is not None:
# Enable exponential-backoff sleep between outer retry iterations.
# Without this, the base class tight-loops calling choose_replicas
# when every replica is at capacity.
pending_request.routing_context.should_backoff = True
index = self._round_robin_counter % len(candidate_replicas)
self._round_robin_counter += 1
return _RoundRobinReplicaRanks(candidate_replicas, index)