Skip to main content

oxios_kernel/resilience/
budget.rs

1//! Attempt budget — bounds total directive executions across the recovery
2//! ladder (RFC-029 §3.4, D7).
3//!
4//! A single budget caps how many times a directive may be (re-)executed,
5//! shared between the resilience ladder and (in P5) the orchestrator's
6//! quality-retry (`verify_or_retry`). When exhausted, the ladder
7//! short-circuits to terminal failure instead of burning cost/latency.
8//!
9//! P2: the budget is local to a single `RecoveryCoordinator::execute`
10//! call. P5 will lift it to a shared counter so quality-retry and
11//! resilience-retry draw from the same pool.
12
13use std::sync::atomic::{AtomicU32, Ordering};
14
15/// Bounded attempt counter. Thread-safe via a single atomic.
16///
17/// Construct with [`AttemptBudget::new`] giving the max number of
18/// attempts. [`AttemptBudget::try_consume`] atomically decrements and
19/// returns `false` once the limit is hit. A budget of `0` means
20/// unlimited (always returns `true`).
21pub struct AttemptBudget {
22    remaining: AtomicU32,
23    /// `0` = unlimited. Stored so `try_consume` is infallible when 0.
24    unlimited: bool,
25}
26
27impl AttemptBudget {
28    /// Create a budget allowing `max` total attempts. `max == 0` means
29    /// unlimited (no cap).
30    pub fn new(max: u32) -> Self {
31        Self {
32            remaining: AtomicU32::new(max),
33            unlimited: max == 0,
34        }
35    }
36
37    /// Try to consume one attempt. Returns `false` if no attempts remain.
38    pub fn try_consume(&self) -> bool {
39        if self.unlimited {
40            return true;
41        }
42        // fetch_update avoids underflow past 0 and is CAS-clean.
43        self.remaining
44            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| {
45                if v > 0 { Some(v - 1) } else { None }
46            })
47            .is_ok()
48    }
49
50    /// Remaining attempts, or `u32::MAX` when unlimited.
51    pub fn remaining(&self) -> u32 {
52        if self.unlimited {
53            u32::MAX
54        } else {
55            self.remaining.load(Ordering::SeqCst)
56        }
57    }
58}
59
60impl std::fmt::Debug for AttemptBudget {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("AttemptBudget")
63            .field("remaining", &self.remaining())
64            .field("unlimited", &self.unlimited)
65            .finish()
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn consumes_until_exhausted() {
75        let b = AttemptBudget::new(3);
76        assert!(b.try_consume());
77        assert!(b.try_consume());
78        assert!(b.try_consume());
79        assert!(!b.try_consume()); // exhausted
80        assert_eq!(b.remaining(), 0);
81    }
82
83    #[test]
84    fn zero_means_unlimited() {
85        let b = AttemptBudget::new(0);
86        for _ in 0..1000 {
87            assert!(b.try_consume());
88        }
89    }
90
91    #[test]
92    fn remaining_tracks_consumption() {
93        let b = AttemptBudget::new(2);
94        assert_eq!(b.remaining(), 2);
95        b.try_consume();
96        assert_eq!(b.remaining(), 1);
97        b.try_consume();
98        assert_eq!(b.remaining(), 0);
99    }
100}