tokio_prompt_orchestrator/priority_queue.rs
1//! # Multi-Level Priority Queue with Anti-Starvation Aging
2//!
3//! Provides a five-level priority queue where low-priority items are
4//! automatically promoted ("aged") when they have waited longer than the
5//! configured threshold. This prevents starvation of [`Priority::Background`]
6//! items under sustained [`Priority::Critical`] load.
7//!
8//! ## Example
9//!
10//! ```rust
11//! use tokio_prompt_orchestrator::priority_queue::{
12//! AgingConfig, MultiLevelQueue, Priority,
13//! };
14//!
15//! let config = AgingConfig::default();
16//! let queue: MultiLevelQueue<String> = MultiLevelQueue::new(config);
17//!
18//! queue.push("critical task".into(), Priority::Critical);
19//! queue.push("background task".into(), Priority::Background);
20//!
21//! // Critical pops first.
22//! assert_eq!(queue.pop(), Some("critical task".into()));
23//! assert_eq!(queue.pop(), Some("background task".into()));
24//! assert_eq!(queue.pop(), None);
25//! ```
26
27use std::{
28 collections::VecDeque,
29 sync::{
30 atomic::{AtomicU8, Ordering},
31 Arc, Mutex,
32 },
33 time::Instant,
34};
35
36// ---------------------------------------------------------------------------
37// Priority
38// ---------------------------------------------------------------------------
39
40/// Five discrete priority levels. Lower numeric value = higher urgency.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42pub enum Priority {
43 /// Highest urgency — always dequeued first.
44 Critical = 0,
45 /// High urgency.
46 High = 1,
47 /// Default urgency.
48 Normal = 2,
49 /// Low urgency.
50 Low = 3,
51 /// Lowest urgency — most susceptible to starvation without aging.
52 Background = 4,
53}
54
55impl Priority {
56 /// Total number of priority levels.
57 pub const COUNT: usize = 5;
58
59 /// Convert a raw `u8` index (0–4) into a [`Priority`]. Returns `None`
60 /// for values outside that range.
61 pub fn from_index(idx: u8) -> Option<Self> {
62 match idx {
63 0 => Some(Self::Critical),
64 1 => Some(Self::High),
65 2 => Some(Self::Normal),
66 3 => Some(Self::Low),
67 4 => Some(Self::Background),
68 _ => None,
69 }
70 }
71
72 /// The numeric index of this priority (same as the discriminant).
73 #[inline]
74 pub fn index(self) -> usize {
75 self as usize
76 }
77}
78
79// ---------------------------------------------------------------------------
80// PriorityItem
81// ---------------------------------------------------------------------------
82
83/// A wrapped item with priority metadata.
84///
85/// `effective_priority` starts equal to the assigned priority level and is
86/// decremented (promoted) by the aging pass when the item waits too long.
87pub struct PriorityItem<T> {
88 /// The payload.
89 pub item: T,
90 /// The priority assigned at enqueue time.
91 pub priority: Priority,
92 /// Wall-clock instant when the item was enqueued.
93 pub enqueued_at: Instant,
94 /// Current effective priority, decremented on each aging promotion.
95 /// Stored as a raw index (0 = Critical … 4 = Background).
96 pub effective_priority: AtomicU8,
97}
98
99impl<T> PriorityItem<T> {
100 fn new(item: T, priority: Priority) -> Self {
101 Self {
102 item,
103 priority,
104 enqueued_at: Instant::now(),
105 effective_priority: AtomicU8::new(priority as u8),
106 }
107 }
108
109 /// Milliseconds elapsed since this item was enqueued.
110 pub fn wait_ms(&self) -> u64 {
111 self.enqueued_at.elapsed().as_millis() as u64
112 }
113}
114
115// ---------------------------------------------------------------------------
116// AgingConfig
117// ---------------------------------------------------------------------------
118
119/// Per-level aging thresholds and promotion caps.
120///
121/// Index mapping:
122/// - `[0]` = threshold for Background → Low
123/// - `[1]` = threshold for Low → Normal
124/// - `[2]` = threshold for Normal → High
125/// - `[3]` = threshold for High → Critical
126#[derive(Debug, Clone)]
127pub struct AgingConfig {
128 /// Milliseconds an item must wait at each level before being promoted.
129 /// Four entries (one per "promotable" level: Background, Low, Normal, High).
130 pub age_threshold_ms: [u64; 4],
131 /// Maximum number of promotion steps an item may receive during its lifetime.
132 pub max_age_promotions: u8,
133}
134
135impl Default for AgingConfig {
136 fn default() -> Self {
137 Self {
138 // Background waits 5 s, Low 10 s, Normal 30 s, High 60 s.
139 age_threshold_ms: [5_000, 10_000, 30_000, 60_000],
140 max_age_promotions: 4,
141 }
142 }
143}
144
145// ---------------------------------------------------------------------------
146// QueueStats
147// ---------------------------------------------------------------------------
148
149/// Aggregate statistics for a [`MultiLevelQueue`].
150#[derive(Debug, Clone, Default)]
151pub struct QueueStats {
152 /// Total items ever enqueued (all priorities).
153 pub total_enqueued: u64,
154 /// Total items ever dequeued.
155 pub total_dequeued: u64,
156 /// Total aging promotions applied across all items.
157 pub total_promotions: u64,
158 /// Average wait time in milliseconds per priority level (index = level).
159 pub avg_wait_ms: [f64; Priority::COUNT],
160}
161
162// ---------------------------------------------------------------------------
163// Inner state (held behind Mutex)
164// ---------------------------------------------------------------------------
165
166struct Inner<T> {
167 /// Five buckets, one per priority level.
168 buckets: [VecDeque<Arc<PriorityItem<T>>>; Priority::COUNT],
169 config: AgingConfig,
170 // Stats accumulators.
171 total_enqueued: u64,
172 total_dequeued: u64,
173 total_promotions: u64,
174 /// Sum of wait_ms per level for items that have been dequeued.
175 wait_sum_ms: [f64; Priority::COUNT],
176 /// Count of dequeued items per level (for average calculation).
177 dequeue_count: [u64; Priority::COUNT],
178}
179
180impl<T> Inner<T> {
181 fn new(config: AgingConfig) -> Self {
182 Self {
183 buckets: [
184 VecDeque::new(),
185 VecDeque::new(),
186 VecDeque::new(),
187 VecDeque::new(),
188 VecDeque::new(),
189 ],
190 config,
191 total_enqueued: 0,
192 total_dequeued: 0,
193 total_promotions: 0,
194 wait_sum_ms: [0.0; Priority::COUNT],
195 dequeue_count: [0; Priority::COUNT],
196 }
197 }
198
199 /// Run the aging pass: scan each "promotable" bucket and move items whose
200 /// wait exceeds the threshold into the next-higher-priority bucket.
201 ///
202 /// Buckets are processed from lowest (Background=4) to highest (High=1).
203 /// Critical items can never be promoted further.
204 fn age(&mut self) {
205 // Levels 4 → 1 are promotable (Background, Low, Normal, High).
206 for src_level in (1..=4usize).rev() {
207 let threshold_idx = 4 - src_level; // maps level 4→[0], 3→[1], 2→[2], 1→[3]
208 let threshold_ms = self.config.age_threshold_ms[threshold_idx];
209 let dst_level = src_level - 1;
210
211 // We need to drain items from src bucket that qualify.
212 // Collect indices to promote (in FIFO order) without holding two mut refs.
213 let mut to_promote: Vec<Arc<PriorityItem<T>>> = Vec::new();
214 let mut remaining: VecDeque<Arc<PriorityItem<T>>> = VecDeque::new();
215
216 while let Some(arc_item) = self.buckets[src_level].pop_front() {
217 let current_eff = arc_item.effective_priority.load(Ordering::Relaxed);
218 let promotions_taken = arc_item.priority as u8 - current_eff; // how many times already promoted
219 if arc_item.wait_ms() >= threshold_ms
220 && promotions_taken < self.config.max_age_promotions
221 {
222 to_promote.push(arc_item);
223 } else {
224 remaining.push_back(arc_item);
225 }
226 }
227 self.buckets[src_level] = remaining;
228
229 for arc_item in to_promote {
230 arc_item
231 .effective_priority
232 .store(dst_level as u8, Ordering::Relaxed);
233 // Keep the destination bucket ordered by enqueue time: a promoted
234 // item has waited longer than anything enqueued after it, so it
235 // goes ahead of those items rather than to the back.
236 let dst = &mut self.buckets[dst_level];
237 let pos = dst
238 .iter()
239 .position(|other| other.enqueued_at > arc_item.enqueued_at)
240 .unwrap_or(dst.len());
241 dst.insert(pos, arc_item);
242 self.total_promotions += 1;
243 }
244 }
245 }
246
247 /// Dequeue from the highest-priority non-empty bucket.
248 fn pop(&mut self) -> Option<(T, usize)> {
249 self.age();
250 for level in 0..Priority::COUNT {
251 if let Some(arc_item) = self.buckets[level].pop_front() {
252 let wait_ms = arc_item.wait_ms();
253 let original_level = arc_item.priority as usize;
254 // Unwrap the Arc — we own the only reference at this point.
255 // A second reference would be a bug in the aging pass, so fail loudly.
256 #[allow(clippy::expect_used)]
257 let item = Arc::try_unwrap(arc_item)
258 .ok()
259 .map(|pi| pi.item)
260 .expect("priority_queue: extra Arc reference detected — bug in aging pass");
261 self.total_dequeued += 1;
262 self.wait_sum_ms[original_level] += wait_ms as f64;
263 self.dequeue_count[original_level] += 1;
264 return Some((item, original_level));
265 }
266 }
267 None
268 }
269
270 fn len(&self) -> usize {
271 self.buckets.iter().map(|b| b.len()).sum()
272 }
273
274 fn len_by_priority(&self) -> [usize; Priority::COUNT] {
275 [
276 self.buckets[0].len(),
277 self.buckets[1].len(),
278 self.buckets[2].len(),
279 self.buckets[3].len(),
280 self.buckets[4].len(),
281 ]
282 }
283
284 fn stats(&self) -> QueueStats {
285 let mut avg_wait_ms = [0.0f64; Priority::COUNT];
286 for (i, avg) in avg_wait_ms.iter_mut().enumerate() {
287 if self.dequeue_count[i] > 0 {
288 *avg = self.wait_sum_ms[i] / self.dequeue_count[i] as f64;
289 }
290 }
291 QueueStats {
292 total_enqueued: self.total_enqueued,
293 total_dequeued: self.total_dequeued,
294 total_promotions: self.total_promotions,
295 avg_wait_ms,
296 }
297 }
298}
299
300// ---------------------------------------------------------------------------
301// MultiLevelQueue
302// ---------------------------------------------------------------------------
303
304/// Thread-safe multi-level priority queue with anti-starvation aging.
305///
306/// Items are held in five internal [`VecDeque`] buckets — one per
307/// [`Priority`] level. Before every [`pop`](MultiLevelQueue::pop) call, an
308/// aging pass scans lower-priority buckets and promotes items whose wait time
309/// exceeds the configured threshold.
310pub struct MultiLevelQueue<T> {
311 inner: Mutex<Inner<T>>,
312}
313
314impl<T> MultiLevelQueue<T> {
315 /// Create a new queue with the given [`AgingConfig`].
316 pub fn new(config: AgingConfig) -> Self {
317 Self {
318 inner: Mutex::new(Inner::new(config)),
319 }
320 }
321
322 /// Enqueue `item` at the given `priority`.
323 ///
324 /// This is a non-async, non-blocking call (only acquires the internal
325 /// `Mutex`).
326 pub fn push(&self, item: T, priority: Priority) {
327 let arc = Arc::new(PriorityItem::new(item, priority));
328 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
329 guard.buckets[priority.index()].push_back(arc);
330 guard.total_enqueued += 1;
331 }
332
333 /// Dequeue the highest-priority available item.
334 ///
335 /// Runs an aging pass before selecting the item, so a previously
336 /// low-priority item that has waited long enough may be returned ahead of
337 /// a recently-added same-level item.
338 ///
339 /// Returns `None` when all buckets are empty.
340 pub fn pop(&self) -> Option<T> {
341 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
342 guard.pop().map(|(item, _level)| item)
343 }
344
345 /// Total number of items across all buckets.
346 pub fn len(&self) -> usize {
347 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
348 guard.len()
349 }
350
351 /// Returns `true` when all buckets are empty.
352 pub fn is_empty(&self) -> bool {
353 self.len() == 0
354 }
355
356 /// Per-priority item counts. Index `i` corresponds to [`Priority`] level `i`.
357 pub fn len_by_priority(&self) -> [usize; Priority::COUNT] {
358 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
359 guard.len_by_priority()
360 }
361
362 /// Snapshot of queue statistics.
363 pub fn stats(&self) -> QueueStats {
364 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
365 guard.stats()
366 }
367}
368
369// ---------------------------------------------------------------------------
370// Tests
371// ---------------------------------------------------------------------------
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use std::thread;
377 use std::time::Duration;
378
379 fn default_queue() -> MultiLevelQueue<&'static str> {
380 MultiLevelQueue::new(AgingConfig::default())
381 }
382
383 #[test]
384 fn empty_pop_returns_none() {
385 let q: MultiLevelQueue<u32> = MultiLevelQueue::new(AgingConfig::default());
386 assert_eq!(q.pop(), None);
387 }
388
389 #[test]
390 fn critical_before_background() {
391 let q = default_queue();
392 q.push("bg", Priority::Background);
393 q.push("crit", Priority::Critical);
394 q.push("normal", Priority::Normal);
395
396 // Critical must come first.
397 assert_eq!(q.pop(), Some("crit"));
398 assert_eq!(q.pop(), Some("normal"));
399 assert_eq!(q.pop(), Some("bg"));
400 assert_eq!(q.pop(), None);
401 }
402
403 #[test]
404 fn ordering_all_levels() {
405 let q = default_queue();
406 q.push("bg", Priority::Background);
407 q.push("low", Priority::Low);
408 q.push("normal", Priority::Normal);
409 q.push("high", Priority::High);
410 q.push("crit", Priority::Critical);
411
412 assert_eq!(q.pop(), Some("crit"));
413 assert_eq!(q.pop(), Some("high"));
414 assert_eq!(q.pop(), Some("normal"));
415 assert_eq!(q.pop(), Some("low"));
416 assert_eq!(q.pop(), Some("bg"));
417 }
418
419 #[test]
420 fn mixed_priorities_drain_correctly() {
421 let q: MultiLevelQueue<u32> = MultiLevelQueue::new(AgingConfig::default());
422 for i in 0u32..5 {
423 q.push(i, Priority::Normal);
424 }
425 q.push(99, Priority::Critical);
426 q.push(100, Priority::High);
427
428 assert_eq!(q.pop(), Some(99)); // Critical first
429 assert_eq!(q.pop(), Some(100)); // High second
430 // Then five Normal items in FIFO order.
431 for i in 0u32..5 {
432 assert_eq!(q.pop(), Some(i));
433 }
434 assert_eq!(q.pop(), None);
435 }
436
437 #[test]
438 fn len_and_len_by_priority() {
439 let q: MultiLevelQueue<i32> = MultiLevelQueue::new(AgingConfig::default());
440 assert_eq!(q.len(), 0);
441
442 q.push(1, Priority::Critical);
443 q.push(2, Priority::Normal);
444 q.push(3, Priority::Normal);
445 q.push(4, Priority::Background);
446
447 assert_eq!(q.len(), 4);
448 let by_level = q.len_by_priority();
449 assert_eq!(by_level[0], 1); // Critical
450 assert_eq!(by_level[1], 0); // High
451 assert_eq!(by_level[2], 2); // Normal
452 assert_eq!(by_level[3], 0); // Low
453 assert_eq!(by_level[4], 1); // Background
454 }
455
456 #[test]
457 fn aging_promotes_background_to_low() {
458 // Use a very short threshold (10 ms) so the test doesn't have to wait.
459 let config = AgingConfig {
460 age_threshold_ms: [10, 200, 1_000, 5_000],
461 max_age_promotions: 4,
462 };
463 let q: MultiLevelQueue<&str> = MultiLevelQueue::new(config);
464
465 q.push("bg", Priority::Background);
466 // Wait 20 ms — long enough to trigger Background → Low promotion.
467 thread::sleep(Duration::from_millis(20));
468
469 // Push a Low-priority item *after* sleeping so the Background item
470 // has been waiting longer than the threshold.
471 q.push("low-new", Priority::Low);
472
473 // pop() will age first; "bg" should have been promoted to Low and be
474 // ahead of "low-new" (FIFO within a level, but it was appended to the
475 // Low bucket during aging, before "low-new" is enqueued).
476 let first = q.pop().expect("should have item");
477 let second = q.pop().expect("should have second item");
478 // Both items are now at Low priority level; "bg" was promoted first.
479 assert_eq!(first, "bg");
480 assert_eq!(second, "low-new");
481 }
482
483 #[test]
484 fn stats_track_enqueue_and_dequeue() {
485 let q: MultiLevelQueue<u8> = MultiLevelQueue::new(AgingConfig::default());
486 q.push(1, Priority::Critical);
487 q.push(2, Priority::Normal);
488 q.pop();
489 q.pop();
490
491 let stats = q.stats();
492 assert_eq!(stats.total_enqueued, 2);
493 assert_eq!(stats.total_dequeued, 2);
494 }
495
496 #[test]
497 fn max_promotions_respected() {
498 // Only one promotion allowed.
499 let config = AgingConfig {
500 age_threshold_ms: [1, 1, 1, 1],
501 max_age_promotions: 1,
502 };
503 let q: MultiLevelQueue<u8> = MultiLevelQueue::new(config);
504 q.push(42, Priority::Background);
505
506 // Sleep enough that all thresholds are exceeded.
507 thread::sleep(Duration::from_millis(5));
508 // pop triggers aging; with max_age_promotions=1 the item can only move
509 // one level (Background → Low), not all the way to Critical.
510 let _ = q.pop();
511 let stats = q.stats();
512 // At most 1 promotion should have been recorded.
513 assert!(stats.total_promotions <= 1);
514 }
515}