Repository navigation
feat(router): add uncached-token per-worker queue limit #15059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -201,17 +201,21 @@ every eligible worker is busy for that class, but a new arrival cannot bypass | |
| an existing backlog in the same class. | ||
|
|
||
| Queue limits are configured per discovered worker endpoint with | ||
| `request_queue_limit_per_worker`, `raw_isl_token_queue_limit_per_worker`, and | ||
| `cached_token_queue_limit_per_worker`. The effective class-local limit is the | ||
| configured value multiplied by the current number of discovered endpoints. | ||
| `request_queue_limit_per_worker`, `raw_isl_token_queue_limit_per_worker`, | ||
| `cached_token_queue_limit_per_worker`, and | ||
| `uncached_token_queue_limit_per_worker`. The effective class-local limit is | ||
| each configured value multiplied by the current number of discovered endpoints. | ||
| Limits are checked against current usage before adding the incoming request, | ||
| so the request that crosses a limit is accepted and the next queued request is | ||
| rejected with HTTP 529 and the effective total. Worker removal does not evict | ||
| rejected with HTTP 503 and the effective total. Worker removal does not evict | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This states queue-limit rejections return HTTP 503, but the OpenAI frontend maps every 🤖 AI FixDocument the default HTTP 529 queue-rejection status and note that |
||
| queued requests; new arrivals reject until usage drains or capacity returns. | ||
| DRR charges the uncached-token snapshot captured at enqueue, while raw, cached, | ||
| and uncached snapshots remain unchanged for limits, WSPT, counters, and later | ||
| dispatch. For the ring cursor, deficit charging, weighted bursts, and bounded | ||
| bulk-credit behavior, see | ||
| The uncached-token limit counts only the queued ISL portion that still needs | ||
| prefill compute (raw ISL minus the best cached tokens across eligible | ||
| workers), so cache-heavy requests do not consume the queue budget that | ||
| protects prefill capacity. DRR charges the uncached-token snapshot captured | ||
| at enqueue, while raw, cached, and uncached snapshots remain unchanged for | ||
| limits, WSPT, counters, and later dispatch. For the ring cursor, deficit | ||
|
Comment on lines
+215
to
+217
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '195,225p' docs/fern/pages/developer-guide/knowledge-base/modular-components/router/configuration-and-tuning.md
rg -n -C 3 'scheduling_cost_tokens|max\(1.*uncached|uncached_tokens.*max|WSPT|DRR' lib/kv-router/src/schedulingRepository: ai-dynamo/dynamo Length of output: 13967 Document the clamped scheduling cost.
🤖 Prompt for AI Agents |
||
| charging, weighted bursts, and bounded bulk-credit behavior, see | ||
| [Deficit Round Robin Queue Scheduling](deficit-round-robin.md). | ||
|
|
||
| Every matrix class must identify both `policy_family` and `cache_bucket`; a | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -41,6 +41,7 @@ pub enum QueueLimitKind { | |
| Requests, | ||
| RawIslTokens, | ||
| CachedTokens, | ||
| UncachedTokens, | ||
| } | ||
|
|
||
| impl std::fmt::Display for QueueLimitKind { | ||
|
|
@@ -49,6 +50,7 @@ impl std::fmt::Display for QueueLimitKind { | |
| Self::Requests => formatter.write_str("requests"), | ||
| Self::RawIslTokens => formatter.write_str("raw_isl_tokens"), | ||
| Self::CachedTokens => formatter.write_str("cached_tokens"), | ||
| Self::UncachedTokens => formatter.write_str("uncached_tokens"), | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -70,6 +72,7 @@ pub struct PolicyQueueStats { | |
| pub requests: usize, | ||
| pub raw_isl_tokens: usize, | ||
| pub cached_tokens: usize, | ||
| pub uncached_tokens: usize, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
|
|
@@ -799,6 +802,11 @@ fn queue_rejection<T>(class: &PolicyClassQueue<T>, worker_count: usize) -> Optio | |
| class.stats.cached_tokens, | ||
| class.config.cached_token_queue_limit_per_worker, | ||
| ), | ||
| ( | ||
| QueueLimitKind::UncachedTokens, | ||
| class.stats.uncached_tokens, | ||
| class.config.uncached_token_queue_limit_per_worker, | ||
| ), | ||
| ] { | ||
| let limit = limit_per_worker.map(|limit| limit.saturating_mul(worker_count)); | ||
| if limit.is_some_and(|limit| current >= limit) { | ||
|
|
@@ -818,12 +826,18 @@ fn add_stats(stats: &mut PolicyQueueStats, snapshot: QueueSnapshot) { | |
| stats.requests += 1; | ||
| stats.raw_isl_tokens = stats.raw_isl_tokens.saturating_add(snapshot.raw_isl_tokens); | ||
| stats.cached_tokens = stats.cached_tokens.saturating_add(snapshot.cached_tokens); | ||
| stats.uncached_tokens = stats | ||
| .uncached_tokens | ||
| .saturating_add(snapshot.uncached_tokens); | ||
| } | ||
|
|
||
| fn subtract_stats(stats: &mut PolicyQueueStats, snapshot: QueueSnapshot) { | ||
| stats.requests = stats.requests.saturating_sub(1); | ||
| stats.raw_isl_tokens = stats.raw_isl_tokens.saturating_sub(snapshot.raw_isl_tokens); | ||
| stats.cached_tokens = stats.cached_tokens.saturating_sub(snapshot.cached_tokens); | ||
| stats.uncached_tokens = stats | ||
| .uncached_tokens | ||
| .saturating_sub(snapshot.uncached_tokens); | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
|
|
@@ -1350,6 +1364,228 @@ policy_classes: | |
| assert_eq!(no_workers_rejection.limit, 0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn uncached_token_cap_charges_uncached_work_only() { | ||
| let mut queue = PolicyQueue::new(profile( | ||
| r#" | ||
| default_policy_family: capped | ||
| uncached_isl_buckets: | ||
| - min_tokens: 0 | ||
| bucket: all | ||
| policy_classes: | ||
| - name: capped | ||
| policy_family: capped | ||
| cache_bucket: all | ||
| quantum: 10 | ||
| uncached_token_queue_limit_per_worker: 3 | ||
| "#, | ||
| )); | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(10, 8), | ||
| 0.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "uncached-queued", | ||
| ) | ||
| .unwrap(); | ||
| // Cache-rich work consumes none of the uncached budget. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This restates the immediately following 🤖 AI FixRemove this redundant test narration. |
||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(100, 100), | ||
| 1.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "cache-rich", | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 2); | ||
|
|
||
| // Pre-add: the entry crossing the cap is admitted... | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(4, 0), | ||
| 2.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "crossing", | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 6); | ||
|
|
||
| // ...and the next arrival is rejected against existing usage only. | ||
| let (rejection, payload) = queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(1, 0), | ||
| 3.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "rejected", | ||
| ) | ||
| .unwrap_err(); | ||
| assert_eq!(payload, "rejected"); | ||
| assert_eq!(rejection.limit_kind, QueueLimitKind::UncachedTokens); | ||
| assert_eq!(rejection.current, 6); | ||
| assert_eq!(rejection.limit, 6); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 6); | ||
|
|
||
| // Draining the earliest entry returns its 2 uncached tokens, so the | ||
| // same request shape is admitted again. | ||
| assert_eq!( | ||
| queue.pop_next(|_, _, _| true).unwrap().into_payload(), | ||
| "uncached-queued" | ||
| ); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 4); | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(1, 0), | ||
| 4.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "readmitted", | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 5); | ||
| } | ||
|
|
||
| #[test] | ||
| fn uncached_token_cap_scales_with_worker_count() { | ||
| let mut queue = PolicyQueue::new(profile( | ||
| r#" | ||
| default_policy_family: capped | ||
| uncached_isl_buckets: | ||
| - min_tokens: 0 | ||
| bucket: all | ||
| policy_classes: | ||
| - name: capped | ||
| policy_family: capped | ||
| cache_bucket: all | ||
| quantum: 10 | ||
| uncached_token_queue_limit_per_worker: 5 | ||
| "#, | ||
| )); | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 2, | ||
| QueueSnapshot::new(10, 0), | ||
| 0.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "at-cap", | ||
| ) | ||
| .unwrap(); | ||
| let (shrunk, _) = queue | ||
| .enqueue( | ||
| 0, | ||
| 1, | ||
| QueueSnapshot::new(1, 0), | ||
| 1.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "shrunk", | ||
| ) | ||
| .unwrap_err(); | ||
| assert_eq!(shrunk.limit_kind, QueueLimitKind::UncachedTokens); | ||
| assert_eq!(shrunk.current, 10); | ||
| assert_eq!(shrunk.limit, 5); | ||
|
|
||
| // One more discovered worker grows the cap without evicting, and the | ||
| // crossing entry is still admitted under the pre-add comparison. | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 3, | ||
| QueueSnapshot::new(4, 0), | ||
| 2.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "grown-cap", | ||
| ) | ||
| .unwrap(); | ||
| queue | ||
| .enqueue( | ||
| 0, | ||
| 3, | ||
| QueueSnapshot::new(1, 0), | ||
| 3.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "crossing-grown", | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(queue.class_stats(0).uncached_tokens, 15); | ||
| let (at_grown, _) = queue | ||
| .enqueue( | ||
| 0, | ||
| 3, | ||
| QueueSnapshot::new(1, 0), | ||
| 4.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "at-grown", | ||
| ) | ||
| .unwrap_err(); | ||
| assert_eq!(at_grown.limit_kind, QueueLimitKind::UncachedTokens); | ||
| assert_eq!(at_grown.current, 15); | ||
| assert_eq!(at_grown.limit, 15); | ||
| } | ||
|
|
||
| #[test] | ||
| fn uncached_token_zero_limit_sheds_immediately() { | ||
| let mut queue = PolicyQueue::new(profile( | ||
| r#" | ||
| default_policy_family: shed | ||
| uncached_isl_buckets: | ||
| - min_tokens: 0 | ||
| bucket: all | ||
| policy_classes: | ||
| - name: shed | ||
| policy_family: shed | ||
| cache_bucket: all | ||
| quantum: 10 | ||
| uncached_token_queue_limit_per_worker: 0 | ||
| "#, | ||
| )); | ||
| let (rejection, payload) = queue | ||
| .enqueue( | ||
| 0, | ||
| 4, | ||
| QueueSnapshot::new(1, 0), | ||
| 0.0, | ||
| 0.0, | ||
| 0, | ||
| WorkerPlacement::Any, | ||
| "shed", | ||
| ) | ||
| .unwrap_err(); | ||
| assert_eq!(payload, "shed"); | ||
| assert_eq!(rejection.limit_kind, QueueLimitKind::UncachedTokens); | ||
| assert_eq!(rejection.current, 0); | ||
| assert_eq!(rejection.limit, 0); | ||
| assert_eq!(queue.pending_count(), 0); | ||
| } | ||
|
|
||
| #[test] | ||
| fn per_worker_limit_multiplication_saturates() { | ||
| let mut queue = PolicyQueue::new(profile(&format!( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The frontend does not return a fixed HTTP 503 for this rejection.
ErrorMessage::from_anyhowrecognizesQueueRejectionand emitsoverload_status_code(), whose default is 529 and which operators may override withDYN_HTTP_OVERLOAD_STATUS_CODE. Documenting 503 makes clients configured as shown here handle the wrong status.🤖 AI Fix
Document queue-limit rejections as using the configured overload status code (default 529), or change the frontend error mapping consistently if 503 is the intended contract.