357 lines
15 KiB
Python
357 lines
15 KiB
Python
"""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)
|