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

619 lines
21 KiB
Python

import asyncio
import sys
from collections import Counter
from typing import Optional
import pytest
from ray._common.utils import get_or_create_event_loop
from ray.exceptions import ActorDiedError, ActorUnavailableError
from ray.serve._private.common import (
DeploymentHandleSource,
RequestMetadata,
)
from ray.serve._private.request_router import PendingRequest
from ray.serve._private.test_utils import (
FAKE_REPLICA_DEFAULT_MAX_ONGOING_REQUESTS as DEFAULT_MAX_ONGOING_REQUESTS,
FAKE_REPLICA_DEPLOYMENT_ID as DEPLOYMENT_ID,
FakeRunningReplica,
MockTimer,
)
from ray.serve._private.utils import generate_request_id
from ray.serve.experimental.consistent_hash_router import (
DEFAULT_NUM_FALLBACK_REPLICAS,
DEFAULT_NUM_VIRTUAL_NODES,
ConsistentHashRouter,
_hash_bytes,
)
TIMER = MockTimer()
def _make_request(
session_id: str = "", request_id: Optional[str] = None
) -> PendingRequest:
internal_id = request_id if request_id is not None else generate_request_id()
return PendingRequest(
args=[],
kwargs={},
metadata=RequestMetadata(
request_id=generate_request_id(),
internal_request_id=internal_id,
session_id=session_id,
),
)
@pytest.fixture
def router(request) -> ConsistentHashRouter:
"""Construct a ConsistentHashRouter on its own loop so its
asyncio.Event attaches to the correct loop, then return it to
the active test loop."""
params = getattr(request, "param", {})
async def build(loop: asyncio.AbstractEventLoop) -> ConsistentHashRouter:
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="fake-actor-id",
self_actor_handle=None,
use_replica_queue_len_cache=False,
get_curr_time_s=TIMER.time,
)
r.initialize_state(**params)
r.initial_backoff_s = 0.001
r.backoff_multiplier = 1
r.max_backoff_s = 0.001
return r
s = asyncio.new_event_loop().run_until_complete(build(get_or_create_event_loop()))
TIMER.reset()
yield s
# Confirm routing loop exits cleanly when there's no residual work.
assert s.curr_num_routing_tasks == 0
assert s.num_pending_requests == 0
def test_hash_bytes():
assert _hash_bytes(b"hello") == _hash_bytes(b"hello")
assert _hash_bytes(b"sess_0000") != _hash_bytes(b"sess_0001")
h = _hash_bytes(b"arbitrary")
assert isinstance(h, int)
assert 0 <= h < (1 << 64)
@pytest.mark.parametrize(
"kwargs",
[
{"num_virtual_nodes": 0},
{"num_virtual_nodes": -5},
{"num_virtual_nodes": "many"},
{"num_fallback_replicas": -1},
],
)
def test_initialize_state_invalid_args(kwargs):
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="a",
self_actor_handle=None,
)
with pytest.raises(ValueError):
r.initialize_state(**kwargs)
def test_initialize_state_valid_args():
r = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="a",
self_actor_handle=None,
)
r.initialize_state(num_virtual_nodes=42, num_fallback_replicas=7)
assert r._num_virtual_nodes == 42
assert r._num_fallback_replicas == 7
def test_ring_defaults(router):
assert router._num_virtual_nodes == DEFAULT_NUM_VIRTUAL_NODES
assert router._num_fallback_replicas == DEFAULT_NUM_FALLBACK_REPLICAS
@pytest.mark.asyncio
@pytest.mark.parametrize("router", [{"num_fallback_replicas": 5}], indirect=True)
async def test_explicit_num_fallback_replicas_caps_gracefully(router):
"""When the operator explicitly requests more fallbacks than there
are replicas, the lookup must stop early gracefully."""
# 3 replicas with num_fallback_replicas=5 means we'd want a rank list
# of 6 but only 3 distinct replicas exist; the loop must cap at 3.
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
ranked = router._lookup_ranked_replicas("any_session")
assert len(ranked) == 3
assert len(set(ranked)) == 3
@pytest.mark.asyncio
async def test_default_num_fallback_replicas_caps_gracefully(
router,
):
"""Defaulted num_fallback_replicas must also stop early gracefully when
there are fewer replicas than 1 + DEFAULT_NUM_FALLBACK_REPLICAS."""
assert router._num_fallback_replicas == DEFAULT_NUM_FALLBACK_REPLICAS
replicas = [
FakeRunningReplica(f"r{i}") for i in range(DEFAULT_NUM_FALLBACK_REPLICAS)
]
router.update_replicas(replicas)
ranked = router._lookup_ranked_replicas("any_session")
assert len(ranked) == len(replicas)
assert len(set(ranked)) == len(replicas)
@pytest.mark.asyncio
async def test_choose_replicas_no_replicas_returns_empty(router):
result = await router.choose_replicas(
candidate_replicas=[], pending_request=_make_request("sess_x")
)
assert result == []
@pytest.mark.asyncio
async def test_choose_replicas_stickiness(router):
"""Repeated calls with the same session_id must map to the same primary replica."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(replicas)
sess = "user_123"
first = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request(sess)
)
for _ in range(10):
again = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request(sess)
)
assert again[0][0].replica_id == first[0][0].replica_id
@pytest.mark.asyncio
@pytest.mark.parametrize("router", [{"num_fallback_replicas": 0}], indirect=True)
async def test_choose_replicas_no_fallbacks(router):
"""K=0 -> a single rank containing only the primary."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("sess_x")
)
assert len(ranks) == 1
assert len(ranks[0]) == 1
@pytest.mark.asyncio
async def test_choose_replicas_fallback_caps_at_replica_count(router):
replicas = [FakeRunningReplica("ra"), FakeRunningReplica("rb")]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("sess_x")
)
assert len(ranks) == 2
flat = [rep.replica_id for rank in ranks for rep in rank]
assert len(flat) == len(set(flat)) == 2
@pytest.mark.asyncio
async def test_choose_replicas_missing_session_spreads_across_replicas(router):
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
primaries = set()
for _ in range(20):
ranks = await router.choose_replicas(
candidate_replicas=replicas,
pending_request=_make_request(session_id=""),
)
primaries.add(ranks[0][0].replica_id)
assert len(primaries) > 1
@pytest.mark.asyncio
async def test_lookup_wraps_around_past_max_vnode(router, monkeypatch):
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
max_vnode = max(router._ring_hashes)
monkeypatch.setattr(
"ray.serve.experimental.consistent_hash_router._hash_bytes",
lambda key: max_vnode + 1,
)
ranked = router._lookup_ranked_replicas("anything")
# Wrapping around sends us to index 0 so the primary is whichever replica
# owns the smallest vnode.
assert ranked[0] == router._ring_replicas[0]
@pytest.mark.asyncio
@pytest.mark.parametrize("key_hash", [0, (1 << 64) - 1])
async def test_lookup_at_hash_space_boundaries(router, monkeypatch, key_hash):
"""Key hashing to exactly 0 or 2**64 - 1 must still produce a valid owner."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
monkeypatch.setattr(
"ray.serve.experimental.consistent_hash_router._hash_bytes",
lambda key: key_hash,
)
ranked = router._lookup_ranked_replicas("anything")
assert len(ranked) == 1 + DEFAULT_NUM_FALLBACK_REPLICAS
assert ranked[0] in {r.replica_id for r in replicas}
@pytest.mark.asyncio
async def test_balanced_distribution(router):
"""100 vnodes per replica should distribute random sessions close to uniform."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas)
counts: Counter = Counter()
for i in range(2000):
ranks = await router.choose_replicas(
candidate_replicas=replicas,
pending_request=_make_request(f"sess_{i}"),
)
counts[ranks[0][0].replica_id.unique_id] += 1
fair = 2000 / 5
for c in counts.values():
assert 0.5 * fair < c < 2 * fair
@pytest.mark.asyncio
async def test_scale_up_remaps_small_fraction(router):
"""Adding the Nth replica should remap ~1/N of sessions."""
initial = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(initial)
sessions = [f"sess_{i}" for i in range(1000)]
mapping_before = {}
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=initial, pending_request=_make_request(s)
)
mapping_before[s] = ranks[0][0].replica_id
# Scale from 4 to 5.
scaled = initial + [FakeRunningReplica("r_new")]
router.update_replicas(scaled)
moved = 0
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=scaled, pending_request=_make_request(s)
)
if ranks[0][0].replica_id != mapping_before[s]:
moved += 1
# Expected: 1/5 = 20%. Upper bound generous to avoid flake on
# random-cluster draws of the hash.
assert moved < 0.4 * len(
sessions
), f"Scale-up remapped {moved}/{len(sessions)} sessions; expected ~200"
assert moved > 0
@pytest.mark.asyncio
async def test_scale_down_remaps_only_owned_sessions(router):
"""Removing one replica out of R should remap roughly 1/R sessions.
Sessions that were not on the removed replica must stay put."""
initial = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(initial)
sessions = [f"sess_{i}" for i in range(1000)]
mapping_before = {}
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=initial, pending_request=_make_request(s)
)
mapping_before[s] = ranks[0][0].replica_id
# Drop the first replica from the set.
dropped_id = initial[0].replica_id
remaining = initial[1:]
router.update_replicas(remaining)
moved_owned = 0
moved_other = 0
for s in sessions:
ranks = await router.choose_replicas(
candidate_replicas=remaining, pending_request=_make_request(s)
)
new = ranks[0][0].replica_id
prev = mapping_before[s]
if prev == dropped_id:
moved_owned += 1
elif new != prev:
moved_other += 1
# Every session that was on the dropped replica must move.
expected_owned = sum(1 for v in mapping_before.values() if v == dropped_id)
assert moved_owned == expected_owned
# Sessions not owned by the dropped replica stay put.
assert moved_other == 0
@pytest.mark.asyncio
async def test_fallback_when_primary_at_max_ongoing(router):
"""Primary saturated -> the outer retry loop must walk to fallback_1."""
loop = get_or_create_event_loop()
replicas = [FakeRunningReplica(f"r{i}") for i in range(4)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
# Saturate the primary, leave fallbacks free.
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
else:
r.set_queue_len_response(0)
chosen = await loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
assert chosen.replica_id == fallback_id
@pytest.mark.asyncio
async def test_backoff_when_all_exhausted_then_retries_on_drain(router):
"""When every ranked replica is at max_ongoing_requests, the routing
task backs off and loops. When capacity opens up, it picks the newly
freed replica on the next iteration -- without us having to call
update_replicas."""
loop = get_or_create_event_loop()
# K+1 = 3 replicas so every one is in the ranked set.
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
for r in replicas:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
router.update_replicas(replicas)
task = loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
# Nothing should be ready: the task is stuck in the backoff loop
# because every replica reports itself as full.
done, _ = await asyncio.wait([task], timeout=0.02)
assert len(done) == 0
# Drain the primary. The retry loop's next probe sees the open slot
# and the task completes.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(0)
chosen = await task
assert chosen.replica_id == primary_id
@pytest.mark.asyncio
async def test_dead_primary_routes_to_fallback(router):
"""``ActorDiedError`` drops the primary from ``_replicas``; the
ring's stale reference is filtered out and the request lands on
the fallback instead of hanging."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
# Find the primary + fallback for "user_42" through the ring.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
# Primary's probe raises ActorDiedError; fallbacks respond normally.
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(queue_len=0, exception=ActorDiedError())
else:
r.set_queue_len_response(0)
chosen = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen.replica_id == fallback_id
# The dead replica should be gone from _replicas, so subsequent
# requests on this session never probe it again.
assert primary_id not in router._replicas
@pytest.mark.asyncio
async def test_unavailable_primary_falls_over_then_recovers(router):
"""``ActorUnavailableError`` is transient: request falls over to
the fallback, the replica stays in ``_replicas``, and sticky
traffic returns to the primary once it recovers."""
replicas = [FakeRunningReplica(f"r{i}") for i in range(3)]
router.update_replicas(replicas)
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
fallback_id = ranks[1][0].replica_id
primary = next(r for r in replicas if r.replica_id == primary_id)
# Primary raises ActorUnavailableError; fallbacks accept.
primary.set_queue_len_response(
queue_len=0,
exception=ActorUnavailableError(
error_message="temporarily unavailable",
actor_id=b"a" * 16,
),
)
for r in replicas:
if r.replica_id != primary_id:
r.set_queue_len_response(0)
chosen = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen.replica_id == fallback_id
# Unlike the died case, the replica must still be in _replicas --
# ActorUnavailableError is transient.
assert primary_id in router._replicas
# Recover the primary. Next sticky request must return to it.
primary.set_queue_len_response(queue_len=0, exception=None)
chosen2 = await router._choose_replica_for_request(_make_request("user_42"))
assert chosen2.replica_id == primary_id
@pytest.mark.asyncio
async def test_backoff_engaged_when_all_ranks_saturated(router):
router.initial_backoff_s = 999
router.backoff_multiplier = 1
router.max_backoff_s = 999
loop = get_or_create_event_loop()
# 1 + DEFAULT_NUM_FALLBACK_REPLICAS replicas -> the full ranked set is
# saturated, so _choose_replicas_with_backoff exhausts every yielded rank
# and falls through to the backoff branch.
replicas = [
FakeRunningReplica(f"r{i}") for i in range(1 + DEFAULT_NUM_FALLBACK_REPLICAS)
]
for r in replicas:
r.set_queue_len_response(DEFAULT_MAX_ONGOING_REQUESTS)
router.update_replicas(replicas)
task = loop.create_task(
router._choose_replica_for_request(_make_request("user_42"))
)
done, _ = await asyncio.wait([task], timeout=0.1)
assert len(done) == 0
# The routing task must have entered the backoff branch.
assert router.num_routing_tasks_in_backoff >= 1
# Free up the primary. With a 999s backoff sleep already in flight, the
# task must remain pending.
ranks = await router.choose_replicas(
candidate_replicas=replicas, pending_request=_make_request("user_42")
)
primary_id = ranks[0][0].replica_id
for r in replicas:
if r.replica_id == primary_id:
r.set_queue_len_response(0)
done, _ = await asyncio.wait([task], timeout=0.1)
assert len(done) == 0, (
"routing task completed within the sleep window -- "
"exponential backoff is not being applied"
)
task.cancel()
try:
await task
except BaseException:
pass
router._routing_tasks.clear()
router._pending_requests_to_fulfill.clear()
router._pending_requests_to_route.clear()
@pytest.mark.asyncio
async def test_concurrent_same_session_does_not_orphan(router):
"""Concurrent same-session requests at the rank-list size must all
resolve, not livelock.
Regression: the routing task that pops a pending from the route deque
is the one responsible for fulfilling it. If the inner retry loop
breaks early under load (e.g., on the routing-task-shedding check after
a probe miss) without fulfilling, that pending used to become an
orphan: still in `_pending_requests_to_fulfill` / `_pending_requests_by_id`
but with no task actively routing it. Surviving routing tasks would
then loop with `request_metadata=None`, and because ConsistentHashRouter
uses strict-metadata-match fulfillment (no FIFO fallback like pow2),
they couldn't pick up the orphan, livelocking the router.
"""
loop = get_or_create_event_loop()
# 1 + DEFAULT_NUM_FALLBACK_REPLICAS replicas so the full rank list is
# exactly the same set across requests (no replica outside the ranks).
replicas = [
FakeRunningReplica(f"r{i}") for i in range(1 + DEFAULT_NUM_FALLBACK_REPLICAS)
]
for r in replicas:
r.set_queue_len_response(0)
router.update_replicas(replicas)
# Fire (1 + num_fallback_replicas + 2) concurrent same-session requests.
# That's strictly more than the rank-list size, exercising the path
# where the routing task that owns a popped pending might temporarily
# exceed the dynamically computed task target.
burst_size = 1 + DEFAULT_NUM_FALLBACK_REPLICAS + 2
tasks = [
loop.create_task(
router._choose_replica_for_request(_make_request("burst-session"))
)
for _ in range(burst_size)
]
done, pending = await asyncio.wait(tasks, timeout=2.0)
assert len(pending) == 0, (
f"{len(pending)}/{burst_size} concurrent same-session requests "
"did not resolve -- ConsistentHashRouter livelocked under load."
)
chosen_replicas = {t.result().replica_id for t in done}
# Affinity preserved: every burst request lands on the same primary.
assert len(chosen_replicas) == 1
@pytest.mark.asyncio
async def test_two_routers_route_same_session_to_same_replica(router):
"""Two independent routers with the same replica set must pick the same
replica for the same session_id."""
replicas_a = [FakeRunningReplica(f"r{i}") for i in range(5)]
replicas_b = [FakeRunningReplica(f"r{i}") for i in range(5)]
router.update_replicas(replicas_a)
other = ConsistentHashRouter(
deployment_id=DEPLOYMENT_ID,
handle_source=DeploymentHandleSource.REPLICA,
self_actor_id="other",
self_actor_handle=None,
)
other.initialize_state()
other.update_replicas(replicas_b)
for i in range(20):
sess = f"user_{i}"
ranks_a = await router.choose_replicas(
candidate_replicas=replicas_a,
pending_request=_make_request(sess),
)
ranks_b = await other.choose_replicas(
candidate_replicas=replicas_b,
pending_request=_make_request(sess),
)
assert ranks_a[0][0].replica_id == ranks_b[0][0].replica_id, (
f"Session {sess} diverged: {ranks_a[0][0].replica_id} vs "
f"{ranks_b[0][0].replica_id}"
)
if __name__ == "__main__":
sys.exit(pytest.main(["-v", "-s", __file__]))