Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion src/tests/test_prefixaware_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ async def test_route_falls_back_to_qps_when_match_below_threshold():
When the longest prefix match is shorter than prefix_min_match_length,
the request should NOT use the matched endpoint. It should fall back to
QPS-based routing and pick the engine with the lowest QPS.

The prompt must still be inserted into the trie, attributed to the
QPS-selected endpoint. Otherwise a router that starts with an empty trie
never seeds it (every request matches below the threshold), and prefix
affinity never activates.
"""

endpoints = [
Expand Down Expand Up @@ -66,7 +71,57 @@ async def test_route_falls_back_to_qps_when_match_below_threshold():

assert url == "http://engine2.com"

fake_hashtrie.insert.assert_not_awaited()
fake_hashtrie.insert.assert_awaited_once_with(
"some prompt text", "http://engine2.com"
)


@pytest.mark.asyncio
async def test_below_threshold_seeding_enables_later_pinning():
"""
Regression test for the trie never seeding when
prefix_min_match_length > 0.

Uses the real HashTrie. The first request has no prefix match, so it is
routed by QPS — but it must seed the trie. A follow-up request extending
the same prompt then matches above the threshold and must pin to the
endpoint that served the first request, even when QPS stats would prefer
the other endpoint.
"""

endpoints = [
EndpointInfo(url="http://engine1.com"),
EndpointInfo(url="http://engine2.com"),
]
request = Request(headers={})

# 128 = one HashTrie chunk; the first chunk of both prompts is identical.
router = PrefixAwareRouter(prefix_min_match_length=128)

first_prompt = "x" * 128 + "y" * 72
followup_prompt = first_prompt + "z" * 40

# First request: cold trie, match below threshold -> QPS picks engine1.
request_stats = {
"http://engine1.com": RequestStats(qps=5),
"http://engine2.com": RequestStats(qps=10),
}
url = await router.route_request(
endpoints, None, request_stats, request, {"prompt": first_prompt}
)
assert url == "http://engine1.com"

# Follow-up: shares the first 128-char chunk, so it matches at the
# threshold and must pin to engine1 even though engine2 now has the
# lower QPS.
request_stats = {
"http://engine1.com": RequestStats(qps=10),
"http://engine2.com": RequestStats(qps=5),
}
url = await router.route_request(
endpoints, None, request_stats, request, {"prompt": followup_prompt}
)
assert url == "http://engine1.com"


@pytest.mark.asyncio
Expand Down
10 changes: 9 additions & 1 deletion src/vllm_router/routers/routing_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,15 @@ async def route_request(
)

if match_length < self.prefix_min_match_length:
return self._qps_routing(endpoints, request_stats)
# Fall back to QPS routing, but still record the prompt in the
# trie. Without this, a router configured with
# prefix_min_match_length > 0 starts with an empty trie, every
# request matches below the threshold, nothing is ever inserted,
# and prefix affinity never activates.
selected_endpoint = self._qps_routing(endpoints, request_stats)
if selected_endpoint is not None:
await self.hashtrie.insert(prompt, selected_endpoint)
return selected_endpoint
Comment on lines +513 to +516

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If endpoints is empty, self._qps_routing returns None. Calling self.hashtrie.insert with None will pollute the trie with invalid endpoints, which can cause future routing requests to incorrectly resolve to None. We should guard against inserting None into the trie.

            selected_endpoint = self._qps_routing(endpoints, request_stats)\n            if selected_endpoint is not None:\n                await self.hashtrie.insert(prompt, selected_endpoint)\n            return selected_endpoint

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — guard added in b105ae8. _qps_routing indeed returns None on an empty endpoint list, so the fallback path now only inserts when an endpoint was actually selected.


selected_endpoint = random.choice(list(matched_endpoint))

Expand Down