Skip to main content

proxima_core/ring/
bounded.rs

1//! `BoundedQueue<R>` — a bounded MPMC queue over a [`RingStorage`] with a
2//! [`FailMode`] overflow policy and a drop counter, in every tier: the no-alloc
3//! [`StaticBoundedQueue`] (inline [`StaticRing`]) and the alloc
4//! [`HeapBoundedQueue`] (heap [`Ring`]) share ONE overflow algorithm, so the
5//! drop-oldest / drop-newest / fail-closed semantics cannot drift between tiers.
6//!
7//! `enqueue` applies the policy (and counts drops); `try_enqueue` bypasses it,
8//! handing a rejected item back so a lossless producer-assist loop can make room
9//! and retry instead of losing it.
10
11use core::sync::atomic::{AtomicUsize, Ordering};
12
13use super::StaticRing;
14#[cfg(feature = "alloc")]
15use super::{Drainer, Ring};
16
17/// The storage a [`BoundedQueue`] drives: a lock-free MPMC ring, in either the
18/// inline [`StaticRing`] (no-alloc) or heap [`Ring`] (alloc) form. `push` hands
19/// the item back on a full ring (never blocks); `dequeue` pops FIFO. The item
20/// type is associated so a `BoundedQueue<R>` names a single storage rather than
21/// threading a redundant element parameter.
22pub trait RingStorage {
23    /// The queued element.
24    type Item;
25
26    /// Push one item; on a full ring the item is handed back via `Err`.
27    fn push(&self, item: Self::Item) -> Result<(), Self::Item>;
28    /// Pop the oldest item, or `None` when empty.
29    fn dequeue(&self) -> Option<Self::Item>;
30    /// Snapshot of the number of queued items (not linearizable).
31    fn len(&self) -> usize;
32    /// True when the ring holds nothing (snapshot).
33    fn is_empty(&self) -> bool {
34        self.len() == 0
35    }
36    /// The ring's capacity (a power of two).
37    fn capacity(&self) -> usize;
38    /// Pop up to `out.len()` items FIFO into `out`, returning the count filled.
39    /// Default is a `dequeue` loop; the heap [`Ring`] overrides it with its
40    /// prefetching batch `Drainer`.
41    fn drain_into(&self, out: &mut [Self::Item]) -> usize {
42        let mut count = 0;
43        while count < out.len() {
44            match self.dequeue() {
45                Some(item) => {
46                    out[count] = item;
47                    count += 1;
48                }
49                None => break,
50            }
51        }
52        count
53    }
54}
55
56impl<T, const N: usize> RingStorage for StaticRing<T, N> {
57    type Item = T;
58
59    fn push(&self, item: T) -> Result<(), T> {
60        StaticRing::push(self, item)
61    }
62    fn dequeue(&self) -> Option<T> {
63        StaticRing::dequeue(self)
64    }
65    fn len(&self) -> usize {
66        StaticRing::len(self)
67    }
68    fn capacity(&self) -> usize {
69        StaticRing::capacity(self)
70    }
71}
72
73#[cfg(feature = "alloc")]
74impl<T> RingStorage for Ring<T> {
75    type Item = T;
76
77    #[inline]
78    fn push(&self, item: T) -> Result<(), T> {
79        Ring::push(self, item)
80    }
81    #[inline]
82    fn dequeue(&self) -> Option<T> {
83        Ring::dequeue(self)
84    }
85    fn len(&self) -> usize {
86        Ring::len(self)
87    }
88    fn capacity(&self) -> usize {
89        Ring::cap(self)
90    }
91    fn drain_into(&self, out: &mut [T]) -> usize {
92        Drainer::new(self).drain_into(out)
93    }
94}
95
96/// What a full queue does with a new item.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum FailMode {
99    /// Evict the oldest queued item to make room (the new item is kept).
100    DropOldest,
101    /// Reject the new item, keep the queue as-is.
102    DropNewest,
103    /// Reject the new item and surface the overflow to the caller.
104    FailClosed,
105}
106
107/// The result of a [`BoundedQueue::enqueue`].
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum EnqueueOutcome {
110    /// Item queued, nothing dropped.
111    Enqueued,
112    /// Queue was full; the oldest item was evicted, the new one queued.
113    DroppedOldest,
114    /// Queue was full; the new item was dropped.
115    DroppedNewest,
116    /// Queue was full under `FailClosed`; the new item was refused.
117    Refused,
118}
119
120impl EnqueueOutcome {
121    /// Whether this outcome dropped (or refused) an item.
122    #[must_use]
123    pub fn is_drop(self) -> bool {
124        !matches!(self, Self::Enqueued)
125    }
126}
127
128/// A bounded queue applying a [`FailMode`] on overflow, over any [`RingStorage`].
129/// Construct it through a tier alias: [`StaticBoundedQueue`] (no-alloc) or
130/// [`HeapBoundedQueue`] (alloc).
131pub struct BoundedQueue<R: RingStorage> {
132    queue: R,
133    fail_mode: FailMode,
134    drops: AtomicUsize,
135}
136
137impl<R: RingStorage> BoundedQueue<R> {
138    /// Wrap an already-constructed storage with an overflow policy.
139    pub const fn from_storage(queue: R, fail_mode: FailMode) -> Self {
140        Self {
141            queue,
142            fail_mode,
143            drops: AtomicUsize::new(0),
144        }
145    }
146
147    #[must_use]
148    pub fn capacity(&self) -> usize {
149        self.queue.capacity()
150    }
151
152    #[must_use]
153    pub fn len(&self) -> usize {
154        self.queue.len()
155    }
156
157    #[must_use]
158    pub fn is_empty(&self) -> bool {
159        self.queue.is_empty()
160    }
161
162    /// Total items dropped or refused on overflow since construction.
163    #[must_use]
164    pub fn dropped(&self) -> u64 {
165        // usize keeps the atomic portable to targets without 64-bit atomics
166        // (cortex-m); a drop counter never needs more than usize range there.
167        self.drops.load(Ordering::Relaxed) as u64
168    }
169
170    /// Count one externally-decided drop. For a caller that applies its OWN
171    /// overflow routing on top of [`try_enqueue`](Self::try_enqueue) (a dynamic
172    /// per-item policy the fixed [`FailMode`] can't express) and discards the
173    /// handed-back item itself — this folds that drop into the same counter
174    /// [`dropped`](Self::dropped) reports, so the queue stays the single drop
175    /// total.
176    pub fn note_drop(&self) {
177        self.drops.fetch_add(1, Ordering::Relaxed);
178    }
179
180    /// Enqueue under the configured [`FailMode`], counting any drop.
181    pub fn enqueue(&self, item: R::Item) -> EnqueueOutcome {
182        let outcome = match self.queue.push(item) {
183            Ok(()) => EnqueueOutcome::Enqueued,
184            Err(rejected) => match self.fail_mode {
185                FailMode::DropOldest => {
186                    // no atomic evict-oldest in the Vyukov ring: free a slot by
187                    // dropping the oldest, then retry. a raced refill drops the
188                    // newest instead — either way exactly one item is lost.
189                    let _oldest = self.queue.dequeue();
190                    match self.queue.push(rejected) {
191                        Ok(()) => EnqueueOutcome::DroppedOldest,
192                        Err(_lost) => EnqueueOutcome::DroppedNewest,
193                    }
194                }
195                FailMode::DropNewest => EnqueueOutcome::DroppedNewest,
196                FailMode::FailClosed => EnqueueOutcome::Refused,
197            },
198        };
199        if outcome.is_drop() {
200            self.drops.fetch_add(1, Ordering::Relaxed);
201        }
202        outcome
203    }
204
205    /// Enqueue WITHOUT the fail policy: on a full ring the item is handed back
206    /// via `Err` so a lossless producer-assist loop can make room and retry it
207    /// rather than dropping it. Nothing is counted as dropped. `#[inline]` — this
208    /// is an emit hot path (the telemetry recorder pushes through it per record),
209    /// so it must collapse to the bare storage `push`.
210    #[inline]
211    pub fn try_enqueue(&self, item: R::Item) -> Result<(), R::Item> {
212        self.queue.push(item)
213    }
214
215    /// Enqueue, looping through a make-room step on a full ring until the item
216    /// lands. `on_full` is the producer's make-room action (e.g. drain + export a
217    /// batch to free a slot); it returns `true` to keep trying, `false` to give
218    /// up — in which case the un-enqueued item is handed back via `Err`. This is
219    /// the shared producer-assist loop: a strictly-lossless caller passes an
220    /// `on_full` that never gives up (and yields on a no-progress round); a
221    /// bounded caller gives up and applies its own overflow policy to the `Err`.
222    /// The loop itself neither counts nor blocks — any yield/park lives in
223    /// `on_full`, so this stays no_std.
224    pub fn enqueue_assisting<F>(&self, mut item: R::Item, mut on_full: F) -> Result<(), R::Item>
225    where
226        F: FnMut() -> bool,
227    {
228        loop {
229            match self.queue.push(item) {
230                Ok(()) => return Ok(()),
231                Err(returned) => {
232                    item = returned;
233                    if !on_full() {
234                        return Err(item);
235                    }
236                }
237            }
238        }
239    }
240
241    /// Pop the oldest queued item, if any.
242    #[must_use]
243    pub fn dequeue(&self) -> Option<R::Item> {
244        self.queue.dequeue()
245    }
246
247    /// Batch-pop up to `out.len()` items FIFO, returning the count filled. Uses
248    /// the storage's batch drain (the heap [`Ring`]'s prefetching `Drainer`).
249    pub fn drain_into(&self, out: &mut [R::Item]) -> usize {
250        self.queue.drain_into(out)
251    }
252}
253
254/// The no-alloc tier: a bounded queue over an inline [`StaticRing`] (`[Cell; N]`,
255/// `N` a power of two ≥ 2). Builds bare-metal (no heap).
256pub type StaticBoundedQueue<T, const N: usize> = BoundedQueue<StaticRing<T, N>>;
257
258impl<T, const N: usize> BoundedQueue<StaticRing<T, N>> {
259    /// A no-alloc bounded queue of capacity `N` (a power of two ≥ 2 — the
260    /// [`StaticRing`] requirement). `const`, so the whole sink stack can live in
261    /// a `static` on bare metal (see [`StaticRing::new`]); the loom build drops
262    /// the `const` because loom's atomics are not const-constructible.
263    #[must_use]
264    #[cfg(not(feature = "loom"))]
265    pub const fn new(fail_mode: FailMode) -> Self {
266        Self::from_storage(StaticRing::new(), fail_mode)
267    }
268
269    #[must_use]
270    #[cfg(feature = "loom")]
271    pub fn new(fail_mode: FailMode) -> Self {
272        Self::from_storage(StaticRing::new(), fail_mode)
273    }
274}
275
276/// The alloc tier: a bounded queue over a heap [`Ring`], runtime capacity.
277#[cfg(feature = "alloc")]
278pub type HeapBoundedQueue<T> = BoundedQueue<Ring<T>>;
279
280#[cfg(feature = "alloc")]
281impl<T> BoundedQueue<Ring<T>> {
282    /// A heap-backed bounded queue holding at most `capacity` items (rounded up
283    /// to a power of two, minimum 2).
284    #[must_use]
285    pub fn new(capacity: usize, fail_mode: FailMode) -> Self {
286        Self::from_storage(Ring::with_capacity(capacity), fail_mode)
287    }
288}
289
290// composes StaticRing/Ring from super, whose atomics/UnsafeCell are
291// cfg-swapped to loom under `--features loom` (see ring/mpsc.rs) — those
292// only work inside an actual loom::model(...) closure, which these plain
293// #[test] functions don't provide.
294#[cfg(all(test, not(feature = "loom")))]
295#[allow(clippy::unwrap_used, clippy::expect_used)]
296mod tests {
297    use super::*;
298
299    // One assertion body run through BOTH tiers proves the overflow algorithm
300    // does not drift between the inline and heap storages.
301    fn drop_oldest_keeps_newest<R: RingStorage<Item = u32>>(queue: BoundedQueue<R>) {
302        assert_eq!(queue.enqueue(1), EnqueueOutcome::Enqueued);
303        assert_eq!(queue.enqueue(2), EnqueueOutcome::Enqueued);
304        assert_eq!(
305            queue.enqueue(3),
306            EnqueueOutcome::DroppedOldest,
307            "evicts the oldest"
308        );
309        assert_eq!(queue.dropped(), 1);
310        assert_eq!(
311            queue.dequeue(),
312            Some(2),
313            "oldest (1) evicted; 2 and 3 remain"
314        );
315        assert_eq!(queue.dequeue(), Some(3));
316        assert_eq!(queue.dequeue(), None);
317    }
318
319    fn fail_closed_refuses<R: RingStorage<Item = u32>>(queue: BoundedQueue<R>) {
320        assert_eq!(queue.enqueue(1), EnqueueOutcome::Enqueued);
321        assert_eq!(queue.enqueue(2), EnqueueOutcome::Enqueued);
322        assert_eq!(queue.enqueue(3), EnqueueOutcome::Refused);
323        assert_eq!(queue.dropped(), 1);
324    }
325
326    fn try_enqueue_hands_back_without_dropping<R: RingStorage<Item = u32>>(queue: BoundedQueue<R>) {
327        assert!(queue.try_enqueue(1).is_ok());
328        assert!(queue.try_enqueue(2).is_ok());
329        assert_eq!(queue.try_enqueue(3), Err(3), "handed back, not dropped");
330        assert_eq!(queue.dropped(), 0, "try_enqueue never counts a drop");
331    }
332
333    #[test]
334    fn static_tier_matches() {
335        drop_oldest_keeps_newest(StaticBoundedQueue::<u32, 2>::new(FailMode::DropOldest));
336        fail_closed_refuses(StaticBoundedQueue::<u32, 2>::new(FailMode::FailClosed));
337        try_enqueue_hands_back_without_dropping(StaticBoundedQueue::<u32, 2>::new(
338            FailMode::FailClosed,
339        ));
340    }
341
342    #[cfg(feature = "alloc")]
343    #[test]
344    fn heap_tier_matches() {
345        drop_oldest_keeps_newest(HeapBoundedQueue::<u32>::new(2, FailMode::DropOldest));
346        fail_closed_refuses(HeapBoundedQueue::<u32>::new(2, FailMode::FailClosed));
347        try_enqueue_hands_back_without_dropping(HeapBoundedQueue::<u32>::new(
348            2,
349            FailMode::FailClosed,
350        ));
351    }
352
353    #[cfg(feature = "alloc")]
354    #[test]
355    fn heap_capacity_rounds_up_to_power_of_two() {
356        assert_eq!(
357            HeapBoundedQueue::<u32>::new(3, FailMode::DropNewest).capacity(),
358            4
359        );
360        assert_eq!(
361            HeapBoundedQueue::<u32>::new(1, FailMode::DropNewest).capacity(),
362            2
363        );
364    }
365
366    #[test]
367    fn enqueue_assisting_makes_room_and_lands() {
368        let queue = StaticBoundedQueue::<u32, 2>::new(FailMode::FailClosed);
369        queue.enqueue(1);
370        queue.enqueue(2);
371        // on_full frees a slot (drops the oldest here) then retries — 3 lands.
372        let landed = queue.enqueue_assisting(3, || queue.dequeue().is_some());
373        assert!(landed.is_ok());
374        assert_eq!(queue.dequeue(), Some(2), "1 was freed to make room");
375        assert_eq!(queue.dequeue(), Some(3));
376    }
377
378    #[test]
379    fn enqueue_assisting_hands_back_on_give_up() {
380        let queue = StaticBoundedQueue::<u32, 2>::new(FailMode::FailClosed);
381        queue.enqueue(1);
382        queue.enqueue(2);
383        assert_eq!(
384            queue.enqueue_assisting(3, || false),
385            Err(3),
386            "gave up, item handed back"
387        );
388    }
389
390    #[test]
391    fn drain_into_batch_pops_fifo_both_tiers() {
392        let queue = StaticBoundedQueue::<u32, 4>::new(FailMode::DropNewest);
393        for value in 1..=3 {
394            queue.enqueue(value);
395        }
396        let mut out = [0u32; 8];
397        assert_eq!(
398            queue.drain_into(&mut out),
399            3,
400            "drains exactly what is queued"
401        );
402        assert_eq!(&out[..3], &[1, 2, 3]);
403        assert!(queue.dequeue().is_none());
404    }
405
406    #[test]
407    fn drop_newest_leaves_queue_untouched() {
408        let queue = StaticBoundedQueue::<u32, 2>::new(FailMode::DropNewest);
409        queue.enqueue(1);
410        queue.enqueue(2);
411        assert_eq!(queue.enqueue(3), EnqueueOutcome::DroppedNewest);
412        assert_eq!(queue.dropped(), 1);
413        assert_eq!(queue.dequeue(), Some(1), "queue untouched");
414        assert_eq!(queue.dequeue(), Some(2));
415    }
416}