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__]))