From ad434d116e3d3435de28afd1c10303c308be94fe Mon Sep 17 00:00:00 2001 From: rebel-jinmoo Date: Tue, 28 Jul 2026 17:45:06 +0900 Subject: [PATCH] fix(credit): wait for worker registration instead of raising in StickyCreditRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase-start credit dispatch can outrun worker registration, and when it does the whole run hangs forever with no request ever reaching the server. Sequence: 1. Workers announce readiness asynchronously over ZMQ. Measured skew between the first credit dispatch and the first `WorkerReady` is 0.4-0.9s, widening with concurrency (more workers to register). 2. `AgenticReplayTiming._execute_warmup` dispatches the phase-start burst with sequential `await`s. 3. `CreditIssuer._issue_credit_internal` calls `increment_sent()` *before* `send_credit()`, so a failed send is still counted as sent. 4. `StickyCreditRouter.send_credit` raises `RuntimeError("No workers available for routing")` when `_workers` is empty, which aborts the sequential loop — the remaining credits are never attempted. 5. The exception is swallowed at the task boundary and in-flight credits have no timeout, so the run stalls permanently. Observed symptom: `sent=1 returned=0 in_flight=1 errors=0` with zero server-side evidence (gateway/router counters 0, engine running 0, a fresh curl to the same endpoint returns 200). With the default 6h `--request-timeout-seconds` this is effectively a deadlock. Fix: when no worker has registered yet, wait for registration (60s cap, 50ms poll) instead of raising. The raise is kept as the timeout path. Verified on a 1P1D vLLM PD-disaggregated stack (MiniMax-M2.5, 145K context, `--scenario inferencex-agentx-mvp`, `--public-dataset semianalysis_cc_traces_weka_062126_256k`): | concurrency | stack | before | after | |-------------|-------------|-------------------------------|--------------------------------| | 8 | warm | hang, `sent=1 returned=0` 2/2 | pass, 82 warmup reqs, 0 errors | | 16 | warm | hang, `sent=1/13` | 19/19 credits routed | | 16 | fresh stack | - | pass, 110 routed, 90 warmup | | 8 | fresh stack | hang | pass | The race triggers on every fresh stack (logged wait 0.55-0.85s), so it is structural rather than incidental. --- src/aiperf/credit/sticky_router.py | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/aiperf/credit/sticky_router.py b/src/aiperf/credit/sticky_router.py index e16d0ac6c6..6b94bc8304 100644 --- a/src/aiperf/credit/sticky_router.py +++ b/src/aiperf/credit/sticky_router.py @@ -15,6 +15,7 @@ - StickyCreditRouter: Main router class """ +import asyncio import time from collections import defaultdict from collections.abc import Awaitable, Callable @@ -274,6 +275,36 @@ def set_first_token_callback( """Set callback for first token events (enables prefill concurrency release).""" self._on_first_token_callback = callback + async def _await_workers_registered( + self, timeout_s: float = 60.0, poll_s: float = 0.05 + ) -> None: + """Wait until at least one worker has registered. + + A phase-start burst dispatch can outrun worker registration: workers + announce themselves with ``WorkerReady`` over ZMQ *after* the timing + manager begins issuing credits (measured 0.4-0.9s of skew, widening + with concurrency because more workers must register). + + Raising from ``send_credit`` in that window is unrecoverable: the + caller (``AgenticReplayTiming._execute_warmup``) issues credits in a + *sequential* ``await`` loop, so the exception aborts the whole burst and + the remaining credits are never attempted. Worse, ``increment_sent`` + has already run for the failed credit, so it stays counted as in-flight + with no timeout -- the run hangs forever with no error surfaced + (server side sees no request at all). + + Waiting turns that race into a sub-second delay. + """ + waited = 0.0 + while not self._workers and waited < timeout_s: + await asyncio.sleep(poll_s) + waited += poll_s + if self._workers: + self.info( + f"Waited {waited:.2f}s for worker registration " + f"({len(self._workers)} workers) before routing credits" + ) + async def send_credit(self, credit: Credit) -> None: """Determine the worker based on sticky sessions or least-loaded and send the credit to the worker. @@ -282,6 +313,10 @@ async def send_credit(self, credit: Credit) -> None: - Updates the worker load and sticky sessions - Sends the credit to the worker """ + if not self._workers: + # Phase-start bursts can precede worker registration; wait instead + # of raising (see _await_workers_registered for why raising hangs). + await self._await_workers_registered() if not self._workers: raise RuntimeError("No workers available for routing")