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}