Skip to content

feat(router): enforce busy-reject thresholds in the KV router queue - #16619

Merged
ishandhanani merged 1 commit into
idhanani/adm-3-decode-limitfrom
idhanani/adm-4-reject-on-rows
Oct 29, 2026
Merged

ishandhanani merged 1 commit into
idhanani/adm-3-decode-limitfrom
idhanani/adm-4-reject-on-rows

Conversation

@ishandhanani

@ishandhanani ishandhanani commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Part of #16543 (PR 4 of 5)

Summary

In KV mode, busy-worker rejection ran on a separate path: router load snapshots went to the worker monitor, the monitor compared them against the busy thresholds, and its overloaded set fed back into selection. This PR has the KV router's queue apply the thresholds itself, on the same rows and check as everything else.

  • Routing the thresholds does not mute the worker monitor. The queue judges the load this router booked; the monitor keeps the decode threshold and applies it only to worker-published load (kv_used_blocks, or active_decode_blocks when a worker reports it), the signals the queue cannot see (generated tokens, requests from other frontends, prefill workers that track no active blocks); scheduler snapshots of booked load neither feed that state nor move its latch once routed. Selection skips a worker that either check marks busy.
  • The reject thresholds are judged in the pass that already projects each rank's load for the request: the registry keeps each rank's reported capacity beside its load (ActiveSequencesMultiWorker::set_worker_capacities, called when LocalScheduler reconciles worker configs), the projection pass hands every rank's projection and capacity to a ProjectedLoadObserver, and the queue's observer counts ranks past the thresholds per worker (BusyThresholds::has_room, the check classifiers and the class-queue gate use). RoutingEligibility consults only that count, so a worker whose ranks are all past the thresholds is skipped, no catalog is scanned, no set is merged with the host's overloaded set, and a pinned request judges one worker. While no rank is full the count is empty and selection does no extra work. If no eligible worker remains, the request gets AllEligibleWorkersOverloaded (529), as before.
  • Thresholds stay live. POST /busy_threshold updates the same handle, and the queue reads it at each selection.
  • Once a KV router takes over the thresholds, the queue owns the booked-load checks and the worker monitor keeps only the decode check on worker-published load; both feed selection, which skips a worker either marks busy. Request-path overload leases (worker-reported overload) still feed the client's overloaded set the same way. Non-KV routing is unchanged.
  • Rejection is host-owned eligibility, not a WorkerFilter in the selection policy: a custom policy replaces the filter list, and "all filtered" maps to a different error than overload.
  • Request classifiers can read both threshold sets that apply to a request and run the same has_room the router runs: queue_thresholds() (the class the request resolves to now, including an override), queue_thresholds_for(name) (another family or class, resolved with this request's uncached tokens), and reject_thresholds() (router-wide, read when classification started). The two sets stay separate because they mean different things: the queue waits on class thresholds and selection rejects on router-wide ones. Selection plugins do not get them yet; that waits for a consumer such as a placement filter.

Behavior differences in KV mode: the check runs synchronously at selection on fresh booked load, where the monitor applied it asynchronously after each load snapshot. The monitor's decode latch still clears only once every signal it judges is under the threshold; once routed it judges worker-published load alone, so a stale booked snapshot can no longer hold it.

Validation

  • reject_thresholds_shed_full_workers_and_reject_when_none_remain: a worker holding 6 of 8 KV blocks is skipped, and the request is rejected once both are full. Fails with the check disabled.
  • classifiers_read_class_and_reject_thresholds: a classifier reads its class's thresholds, another family's for this request's bucket, None for an unknown class, the router-wide ones, and the new class's thresholds after set_policy_class.
  • llm: routed_busy_thresholds_stay_live_and_hand_booked_load_to_the_queue (routing marks the monitor routed; a /busy_threshold update reaches the routed handle; an encode context does not route), routed_monitor_keeps_only_the_engine_decode_check, engine_only_ignores_scheduler_snapshots_and_keeps_worker_published_load, and engine_only_latch_is_held_and_released_by_worker_published_load_alone.
  • Mocker e2e (tests/router/test_router_e2e_with_mockers.py): 7 passed: test_mocker_kv_router_overload_529 (decode-blocks, prefill-tokens), test_mocker_disagg_router_overload_529 (both), threshold_none_disables_rejection, busy_threshold_endpoint (both planes). With the queue-side check disabled, all 4 overload-529 cases fail.
  • cargo test -p dynamo-kv-router --features standalone-indexer --lib: 1181 passed, including reject_thresholds_follow_the_live_provider_on_prefill_tokens (prefill thresholds, provider flipped at runtime) and the shed test now pinning to the full worker. cargo test -p dynamo-llm --lib -- kv_router:: discovery:: busy_threshold: 696 passed, including load_threshold_handle_merges_updates_and_shares_state and busy_thresholds_from_load_threshold_config. cargo clippy --workspace --all-targets -- -D warnings: clean.
  • Review round: the reject-threshold provider is a constructor argument of LocalScheduler::new / SchedulerQueue::new (no OnceLock, no hidden setter); one RoutableThresholds owns the live handle and the routed switch, built before the sender and the monitor; impl From<LoadThresholdConfig> for BusyThresholds names the conversion; the profile is shared through one Arc; uncached_tokens comes from QueueSnapshot; LoadThresholdHandle is an ArcSwap, so the per-request reads on worker threads are wait-free and a /busy_threshold write never stalls them.

Stack

  1. feat(router): expose worker inputs to request classifiers #16616 feat(router): expose worker inputs to request classifiers
  2. refactor(router): judge queue capacity on the shared LOAD row #16617 refactor(router): judge queue capacity on the shared LOAD row
  3. feat(router): add a decode busy threshold to policy-class queues #16618 feat(router): add a decode busy threshold to policy-class queues
  4. feat(router): enforce busy-reject thresholds in the KV router queue #16619 feat(router): enforce busy-reject thresholds in the KV router queue (this PR)
  5. refactor(router): unify classifier inputs; deprecate absolute busy thresholds #16620 refactor(router): rename classifier contexts; deprecate absolute busy thresholds

Summary by CodeRabbit

  • New Features

    • In KV router mode, worker availability is checked against current thresholds when selecting a worker. Workers whose ranks are all busy are skipped, and requests are rejected if no eligible worker remains.
    • Custom rejection thresholds can now guide worker selection alongside existing overload checks.
  • Documentation

    • Clarified when busy-threshold updates take effect and how queue and rejection thresholds apply to requests and workers.
  • Reject-threshold cost on the selection path (selection_core, no_hints_reject_off/on, bench-only constructor installing a provider that sheds nothing; six alternating rounds pinned to one core, medians, per select-and-reserve; A = reject check evaluated per worker in RoutingEligibility, B = judged in the load projection pass): 512 workers 68.1/76.9 us -> 68.1/72.4 us (off/on; configured-threshold cost +12.9% -> +6.2%), 4096 workers 474.6/583.7 us -> 476.4/527.8 us (+23.0% -> +10.8%); thresholds-on alone -5.8% and -9.6%. With no thresholds configured the two are equal within noise. The remaining cost of configured thresholds is the has_room arithmetic per rank inside the pass; it no longer re-reads each rank's load.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

@ishandhanani
ishandhanani added this pull request to stack #16622 October 16, 2026 03:03
@devin-ai-integration

devin-ai-integration Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

✅ Dynamo PR CI passed — run 40457088709 (attempt 1) on 7ae4f08499

Gate checks: ✅ backend-status-check · ✅ deploy-status-check · ✅ dynamo-status-check

Posted automatically by Devin for run 40457088709. Updated on every full-CI run of this PR.

@ishandhanani ishandhanani left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The direction is right: the queue is the only place with fresh booked load, and exposing both threshold sets on the plugin contexts closes the DEP's "plugins cannot see either version" gap. The mocker e2e coverage of the 529 cases is the right gate.

The main inline is the per-selection cost of the reject scan when thresholds are configured, measured with the selection_core bench. The rest is consolidation: the same two-field struct defined twice, a field set after construction, and small duplications.

Comment thread lib/kv-router/src/scheduling/queue.rs Resolved
Comment thread lib/llm/src/discovery/worker_monitor.rs Resolved
Comment thread lib/llm/src/kv_router/routing_load.rs Resolved
Comment thread lib/llm/src/kv_router.rs Resolved
Comment thread lib/kv-router/src/scheduling/types.rs Resolved
Comment thread lib/kv-router/src/scheduling/queue.rs Resolved
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from 8308240 to f5d8d5c Compare October 19, 2026 01:55

@jthomson04 jthomson04 left a comment

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.

One blocking concern: routing the thresholds drops the engine-reported kv_used_blocks decode signal. One simplification on the provider wiring.

Comment thread lib/llm/src/discovery/worker_monitor.rs Resolved
Comment thread lib/kv-router/src/scheduling/local.rs Resolved
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from f5d8d5c to 88d192d Compare October 19, 2026 05:46

@jthomson04 jthomson04 left a comment

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.

One note on the shared lock this adds to the per-request path.

Comment thread lib/kv-router/src/scheduling/queue.rs Resolved
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from 88d192d to b341663 Compare October 20, 2026 07:08
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from b341663 to 1ce4f07 Compare October 20, 2026 08:03
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch 3 times, most recently from 128c382 to a3e14b1 Compare October 22, 2026 14:26
@ishandhanani
ishandhanani requested a review from a team as a code owner October 22, 2026 14:26
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from a3e14b1 to aa9fe41 Compare October 22, 2026 15:45
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch from aa9fe41 to 2f09bb3 Compare October 23, 2026 02:13

@jthomson04 jthomson04 left a comment

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.

Stale docs after the kv_used_blocks split, plus an indent nit.

Comment thread lib/llm/src/kv_router/routing_load.rs Resolved
Comment thread lib/llm/src/discovery/worker_monitor.rs Resolved
@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch 4 times, most recently from 3ec3377 to ab65570 Compare October 23, 2026 10:56

@jthomson04 jthomson04 left a comment

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.

Lazy reject check and routed monitor verified on ab65570.

@ishandhanani
ishandhanani force-pushed the idhanani/adm-4-reject-on-rows branch 3 times, most recently from 1f3f2e8 to 12eba5c Compare October 25, 2026 05:47
In KV mode, busy-worker rejection ran on a separate path: router load
snapshots went to the worker monitor, the monitor compared them against
the busy thresholds, and its overloaded set fed back into selection.

The KV router's queue now applies the live thresholds itself when it
selects a worker. It builds each rank's `LOAD` row with
`worker_input_row`, the builder worker selection and classifiers use,
and checks it with `BusyThresholds::has_room`. A worker whose ranks are
all past the thresholds is skipped; when none remain the request gets
AllEligibleWorkersOverloaded (529), as before. POST /busy_threshold
updates the same handle and takes effect at the next selection.

Once a KV router takes the thresholds over, the worker monitor stops
deriving an overloaded set from them. Request-path overload leases
still feed the client's overloaded set, which is unioned with the
queue's result. Non-KV routing is unchanged.

Request classifiers can read both threshold sets that apply to a
request and run the same check: `queue_thresholds()`,
`queue_thresholds_for(class)`, and `reject_thresholds()` on the
classification request.

Routing the thresholds no longer mutes the worker monitor. The queue
judges the load this router booked; the monitor keeps the decode
threshold and applies it only to worker-published load (`kv_used_blocks`,
or `active_decode_blocks` when a worker reports it), the signals the
queue cannot see: generated tokens, requests from other frontends, and
prefill workers that track no active blocks. Scheduler snapshots of
booked load neither feed that state nor move its latch once routed.
Selection skips a worker that either check marks busy.

The thresholds are judged in the pass that projects each rank's load for
the request. The load registry keeps each rank's reported capacity
beside its load (`set_worker_capacities`, called when the scheduler
reconciles worker configs), and the queue hands that pass a
`FullRankCounts` that runs `BusyThresholds::has_room` on every rank as
its projection is written and counts the ranks without room per worker.
`RoutingEligibility` consults only that count, so a worker is skipped
when every rank is full, nothing is scanned before selection, no set is
merged with the host's overloaded set, and a pinned request rules on one
worker. While no rank is full the count is empty and selection does no
extra work. A bench-only constructor lets `selection_core` install a
reject-threshold provider on a standalone core; those cores run without
KV events so `cargo test` does not hold thousands of listener sockets.

Plumbing after review: the queue takes its reject-threshold provider as a
constructor argument like the other providers, `PolicyClassConfig`'s
profile is shared through one `Arc`, and the classifier's uncached-token
count comes from `QueueSnapshot`. On the frontend side one
`RoutableThresholds` owns the live handle and the routed switch,
`BusyThresholds: From<LoadThresholdConfig>` names the vocabulary
conversion, and `LoadThresholdHandle` is an `ArcSwap`, so the per-request
reads on worker threads are wait-free and a `/busy_threshold` write never
stalls them.

Part of #16543.

Signed-off-by: Ishan Dhanani <ishandhanani@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation feat router Relates to routing, KV-aware routing, etc. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants