1use std::collections::HashMap;
16use std::sync::Mutex;
17
18fn day_of(ts: jiff::Timestamp) -> i64 {
20 ts.as_second().div_euclid(86_400)
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Skip {
26 NotSampled,
28 BudgetExhausted,
30}
31
32#[derive(Debug, Default)]
34pub struct ShadowLedger {
35 inner: Mutex<HashMap<(String, usize), (i64, f64)>>, }
37
38impl ShadowLedger {
39 #[must_use]
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 pub fn may_spend(
53 &self,
54 tenant: &str,
55 route_ix: usize,
56 max_usd_per_day: f64,
57 now: jiff::Timestamp,
58 ) -> bool {
59 if max_usd_per_day <= 0.0 {
60 return false;
61 }
62 let today = day_of(now);
63 let mut g = match self.inner.lock() {
64 Ok(g) => g,
65 Err(_) => return false,
68 };
69 let e = g
70 .entry((tenant.to_owned(), route_ix))
71 .or_insert((today, 0.0));
72 if e.0 != today {
73 *e = (today, 0.0); }
75 e.1 < max_usd_per_day
76 }
77
78 pub fn debit(&self, tenant: &str, route_ix: usize, usd: f64, now: jiff::Timestamp) {
80 if !usd.is_finite() || usd <= 0.0 {
81 return;
82 }
83 let today = day_of(now);
84 if let Ok(mut g) = self.inner.lock() {
85 let e = g
86 .entry((tenant.to_owned(), route_ix))
87 .or_insert((today, 0.0));
88 if e.0 != today {
89 *e = (today, 0.0);
90 }
91 e.1 += usd;
92 }
93 }
94
95 #[must_use]
97 pub fn spent_today(&self, tenant: &str, route_ix: usize, now: jiff::Timestamp) -> f64 {
98 let today = day_of(now);
99 self.inner
100 .lock()
101 .ok()
102 .and_then(|g| g.get(&(tenant.to_owned(), route_ix)).copied())
103 .filter(|(d, _)| *d == today)
104 .map_or(0.0, |(_, spent)| spent)
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 fn ts(secs: i64) -> jiff::Timestamp {
113 jiff::Timestamp::from_second(secs).unwrap()
114 }
115
116 #[test]
117 fn spending_stops_at_the_ceiling() {
118 let l = ShadowLedger::new();
119 let now = ts(1_000_000);
120 assert!(
121 l.may_spend("t", 0, 1.00, now),
122 "fresh budget must allow the first call"
123 );
124 l.debit("t", 0, 0.60, now);
125 assert!(
126 l.may_spend("t", 0, 1.00, now),
127 "0.60 of 1.00 spent — still under"
128 );
129 l.debit("t", 0, 0.50, now);
130 assert!(
131 !l.may_spend("t", 0, 1.00, now),
132 "1.10 of 1.00 spent — must refuse, or the ceiling is decoration"
133 );
134 }
135
136 #[test]
139 fn budget_resets_on_a_new_utc_day() {
140 let l = ShadowLedger::new();
141 let day1 = ts(86_400 * 100);
142 l.debit("t", 0, 5.0, day1);
143 assert!(!l.may_spend("t", 0, 1.0, day1));
144 let day2 = ts(86_400 * 101);
145 assert!(
146 l.may_spend("t", 0, 1.0, day2),
147 "new UTC day must restore the budget"
148 );
149 assert!((l.spent_today("t", 0, day2) - 0.0).abs() < f64::EPSILON);
150 }
151
152 #[test]
154 fn budgets_are_isolated_per_tenant_and_route() {
155 let l = ShadowLedger::new();
156 let now = ts(1_000_000);
157 l.debit("noisy", 0, 99.0, now);
158 assert!(!l.may_spend("noisy", 0, 1.0, now));
159 assert!(
160 l.may_spend("quiet", 0, 1.0, now),
161 "another tenant's budget was consumed"
162 );
163 assert!(
164 l.may_spend("noisy", 1, 1.0, now),
165 "another route's budget was consumed"
166 );
167 }
168
169 #[test]
172 fn non_positive_ceiling_never_spends() {
173 let l = ShadowLedger::new();
174 let now = ts(1_000_000);
175 assert!(!l.may_spend("t", 0, 0.0, now));
176 assert!(!l.may_spend("t", 0, -5.0, now));
177 }
178
179 #[test]
180 fn nonsense_debits_are_ignored() {
181 let l = ShadowLedger::new();
182 let now = ts(1_000_000);
183 for bad in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
184 l.debit("t", 0, bad, now);
185 }
186 assert!((l.spent_today("t", 0, now) - 0.0).abs() < f64::EPSILON);
187 assert!(l.may_spend("t", 0, 1.0, now));
188 }
189}