Skip to main content

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}