Repository navigation
feat(router): Cold Pool (#12803) as a classifier plugin on the dispatch gate - #205
Draft
ishandhanani wants to merge 30 commits into
Draft
ishandhanani wants to merge 30 commits into
ishandhanani wants to merge 30 commits into
Conversation
A request classifier can now return a DispatchGate. The scheduler actor asks the gate about each queue head before its own capacity check: Go dispatches with an optional soft worker target, Hold keeps the head queued until a capacity change, a recheck from the gate, or the time the gate names. The gate's dispatched callback runs once, when the head leaves the queue. Queue lanes generalize from per-worker to a packed LaneKey: worker lanes as before, plus session lanes assigned while a gate is installed, so a held session head blocks only its own session instead of the class's shared lane. Session lanes are rechecked on any capacity change and on gate rechecks. Adds test-only constructors (ClassifyRequest::for_test, WorkerCandidates::empty) under the bench feature so plugin crates can test classify directly. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The classifier no longer holds requests in a private queue: classify records the turn and returns, the gate answers per queue head from the program ledger, and lifecycle events settle the ledger and wake the queue. The scheduler tick, per-session waiting lists, arrival order, wakeup handles, and cancel-on-drop are gone; the router queue owns the waiting turns in per-session lanes. Rules kept: one turn in flight per session, continuing turns always go, paused and new programs need room (resume ceiling for paused, full capacity for new), forced dispatch after resume_timeout_seconds measured from the turn's registration, pause smallest acting programs over the threshold down to the target, greedy resume into headroom, hard-pin room check, retention expiry, and the program limit with idle eviction. scheduler_interval_seconds is removed from the configuration. Adds an end-to-end test that drives the real plugin through LocalScheduler. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
classify now returns Result<ClassifyRequest, _> instead of a 'static future. A classifier reads the request and worker rows, writes its overrides, and returns under the classifier lock; it can no longer park a request before the queue. Policies that must wait return a DispatchGate, so the router queue owns every wait: order, class budget, expiry, lanes, and wakeups. Removes ClassifyFuture, the future-polling and cancellation paths in the classifier runtime, and the tests that depended on a suspended classification. The routing-host pause test now holds through the gate with the queue threshold enabled, which also covers gate installation through the real router. Docs follow: the admission guide describes queue-or-reject classification plus the gate, and the examples are synchronous. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Pinned requests no longer get a lane of their own. A class queue has one shared lane plus, while a dispatch gate is installed, one lane per session. A pinned head whose worker is full now holds the shared lane until that worker frees, by design: a request that already has a worker takes no lane that could look like a fast path, and the only lanes that remain exist so a gate hold on one session blocks only that session. WorkerPlacement is gone; enqueue takes Option<SessionLane>. Capacity updates recheck every blocked lane instead of the changed worker's lane, which drops the per-worker recheck path and the worker-identity bookkeeping around it. The three actor tests that relied on pinned lanes now run their pinned requests in session lanes behind an always-go gate, which keeps the lane semantics covered; the policy queue lane tests use session lanes throughout. Docs follow in the scheduling guide, the DRR page, and the tuning page. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…pacity The dispatch predicate returned true for a class without busy thresholds before it asked the gate, and the gate's recheck handle was a no-op when no class queued. With the documented ThunderAgent launch, which sets no queue threshold, the gate was never asked: no pause or resume hold, no one turn in flight per session, no room check. The predicates now ask the gate first and skip only the capacity check for such classes. The actor always listens on a wake channel, shared with capacity updates when a class queues and its own otherwise, so the gate's recheck wakes it in every configuration. A ThunderAgent test runs the documented config without a threshold and checks a session's second turn is held until the first completes. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The bench still imported WorkerPlacement, which the worker-lane removal deleted, so clippy --all-targets failed. Its per-lane queues now use session lanes. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
With worker lanes gone, a pinned request outside a session lane waits in its class's shared lane and holds it while its worker is full. The tuning page now says so in the queueing section and in the hard affinity mode row, and lists a dispatch gate hold as a reason a class queues. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A class with a zero queue limit never queues, so its requests are admitted without asking the gate, and ThunderAgent commits them at Sent. The install_dispatch_gate comment claimed every class was gated; it and the admission-control guide now state the exception. The selection-trace doc comment, which had been left above install_dispatch_gate, moves back to set_selection_trace. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Since the actor always listens for wakes, its capacity_updates was always Some outside tests. Drop the Option and the unreachable plain-recv and is_some branches in the run and refresh loops. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
drain_ready seeds its recheck cell with the actor's current recheck time, so gate_allows's own min-merge keeps the earliest one and note_gate_recheck goes away. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The OpenGate helpers were inserted between the comment and the function it describes. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A gate that returned Hold with a past until re-armed the actor's recheck timer, which fired at once, asked the gate again, and looped at full CPU. Ignore a deadline that is not in the future and say so on Hold. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The example kept returning Hold with an until in the past after max_hold, which arms no timer and holds the head forever. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
has_dispatchable_ready_head asked the gate with a throwaway recheck cell, so a Hold until for a lane root exposed by cancellation never armed the actor's timer. Write it back as drain_ready does. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…event Retention expiry ran only when a dispatch or lifecycle event settled the ledger, so in a quiet period an expired idle program kept its charge and a held session waited out its full forced-dispatch time. check now counts programs past retention as gone (still a read; the next settle removes them) and holds no later than the next retention deadline. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
… program sent returned true on every event, so each admitted turn reconciled the ledger and woke the queue a second time to rescan every held lane. The selector usually honors the gate's target, which changes nothing. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The parameter shipped in v1.6.1 and now fails startup as an unknown field; record the removal and its migration on the deprecations page. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The outer Option only recorded that the gate had said Go, and the one reader flattened it away. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
The handle's optional capacity channel is the actor's wake channel when a class queues on capacity; create one and share it instead of cloning an Option and filling the gap. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
schedule_retention_expiry repeated the idle-program check and deadline math that retention_deadline already does. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…resholds Capacity frees were recorded only when some class queues on capacity, so in a profile with no busy thresholds a gate head held for room was never asked again on a free, contrary to the Hold contract. Record them on the actor's wake channel once a gate is installed. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
A Sent for a request the gate never saw (a class that never queues) marked it in flight but created no program, so the session went uncharged, and an ungated final turn left its program until retention. Commit it through dispatched with the chosen worker, as a gated turn is. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
… gate Removing the worker lanes left a hard-pinned head on a full worker holding the class's shared heap, so one bound session stalled unpinned requests that had idle workers. The replay router's own queue did the same, which failed native_affinity_waits_for_dispatch_and_queues_only_on_its_bound_rank in CI. The lane a request waits in no longer depends on whether a dispatch gate is installed: every request with a session context gets its session's lane, in the live actor and in offline replay. A session bound to a busy rank, by hard affinity or by a gate hold, now delays only its own later turns. Requests without a session keep sharing the class lane, and a sessionless pin that waits there still holds that lane until its worker frees, as documented. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…talled update() returned early whenever no class queued on capacity, so the periodic tick and worker-config changes never rechecked heads a gate held in that profile. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Session identifiers come from clients, and FxHasher is unkeyed, so a client could pick an identifier that lands in another session's lane and share its order and holds. Hash with a RandomState fixed once per process, as the scheduling guide asks for externally controlled keys. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…recations classify now returns its result directly and ClassifyFuture is gone, so custom classifiers need code changes beyond the import paths; say so and point classifiers that waited at the dispatch gate. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Every session now gets its own lane, gate or not, and exact-worker lanes are gone; the DRR page and the scheduling guide's lane tree still said otherwise. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
…workers Two classify-time overrides for policies that hold or constrain some requests on the dispatch gate: - ClassifyRequest::set_lane: a request without a session waits in the lane its classifier named instead of the class's shared lane, so a head the gate holds, or whose workers are full, blocks only its own lane. Session lanes still take precedence. - ClassifyRequest::set_allowed_workers: narrows the request's allowed workers (intersected with the caller's); the queue's capacity check and worker selection both honor it. An empty intersection keeps the caller's set. Also exposes ClassifyRequest::cached_tokens, the best cached prefix a classifier needs for effective-prefill decisions. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Ports the Cold Pool policy proposed in ai-dynamo#12803 / ai-dynamo#12806 onto the request classifier and dispatch gate instead of the core queue: - Warm/Cold/unmanaged classification by effective prefill. - Stable rendezvous-hashed membership capped at a third of the workers. - Cold: allowed workers = the pool, its own lane, and the gate holds it until a member has no active Warm work, then targets that member; after the soft-drain timeout any member may take it. - Warm, while Cold is pending: allowed workers exclude the pool; while Cold only runs, they exclude just the workers running it. Configured as request_classifier type cold_pool; workers: 0 keeps the classification and lane without containment, for baselines. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
ishandhanani
had a problem deploying
to
external_collaborator
November 10, 2026 08:26 — with
GitHub Actions
Failure
steer_warm: false keeps Warm on every worker. With soft_drain_timeout_ms: 0 the policy reduces to pool-only: Cold is limited to the pool and waits in its own lane, and nothing else changes. Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
ishandhanani
had a problem deploying
to
external_collaborator
November 10, 2026 09:18 — with
GitHub Actions
Failure
ishandhanani
force-pushed
the
idhanani/dyn-queue-dispatch-gate
branch
from
November 11, 2026 05:05
b8d21bc to
9be1517
Compare
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
POC for ai-dynamo#12803: the Cold Pool policy from ai-dynamo#12806, rebuilt as a request-classifier plugin on top of the dispatch gate (ai-dynamo#18443) instead of ~700 lines of core queue changes. Stacked on ai-dynamo#18443's branch.
Summary
Core (generic, no Cold Pool knowledge; +180/-20):
ClassifyRequest::set_lane(name): a sessionless request waits in a classifier-named lane instead of the class's shared lane, so a held or constrained head blocks only its own lane. Session lanes take precedence.ClassifyRequest::set_allowed_workers(set): narrows the request's allowed workers (intersected with the caller's); the queue's capacity check and selection both honor it.ClassifyRequest::cached_tokens(): best cached prefix, for effective-prefill decisions.Plugin
request_classifier: cold_pool(lib/router-plugins/builtin/src/cold_pool.rs):Go { target: that member }; aftersoft_drain_timeout_ms, any member.workers: 0keeps classification and the lane without containment, for baselines.Validation
cargo test -p dynamo-kv-router --lib: 1241 passed, includinga_classifier_lane_keeps_a_held_head_out_of_the_shared_lane(negative control: without a lane, a held sessionless head blocks the shared lane).cargo test -p dynamo-router-policies: all suites, including 7 Cold Pool unit tests andtests/cold_pool_gate.rs(one policy class throughLocalScheduler: Cold held until a member drains while Warm avoids the pool; Cold takes a busy member after the timeout).cargo test -p dynamo-llm --lib -- routing_host::tests: 80 passed.cargo clippy -D warningson kv-router, router-policies, llm;cargo fmt.GPU pilot (earlier revision with a gate-side worker-set decision, same policy semantics)
8x H200, 8 SGLang TP1 workers of GLM-4.7-Flash, agg, KV router; Warm = agent-loadgen SWE-bench, 320 agents, 15 min; Cold = 160K-token random (cache-miss) prompt every 20 s from minute 2. Baseline = same classifier with
workers: 0.The Cold arrival rate exceeded the 2-worker pool's capacity (~2.4 workers of Cold prefill demand), so Cold queued without bound. Follow-up runs at sustainable Cold rates are pending.
Gaps found
stable_routing_id()exists and should be used (same issue in feat(kv-router): add cold pool routing ai-dynamo/dynamo#12806).Sent/Completedevents with a small lag.