use std::collections::HashMap;
use std::sync::Mutex;
fn day_of(ts: jiff::Timestamp) -> i64 {
ts.as_second().div_euclid(86_400)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Skip {
NotSampled,
BudgetExhausted,
}
#[derive(Debug, Default)]
pub struct ShadowLedger {
inner: Mutex<HashMap<(String, usize), (i64, f64)>>, }
impl ShadowLedger {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn may_spend(
&self,
tenant: &str,
route_ix: usize,
max_usd_per_day: f64,
now: jiff::Timestamp,
) -> bool {
if max_usd_per_day <= 0.0 {
return false;
}
let today = day_of(now);
let mut g = match self.inner.lock() {
Ok(g) => g,
Err(_) => return false,
};
let e = g
.entry((tenant.to_owned(), route_ix))
.or_insert((today, 0.0));
if e.0 != today {
*e = (today, 0.0); }
e.1 < max_usd_per_day
}
pub fn debit(&self, tenant: &str, route_ix: usize, usd: f64, now: jiff::Timestamp) {
if !usd.is_finite() || usd <= 0.0 {
return;
}
let today = day_of(now);
if let Ok(mut g) = self.inner.lock() {
let e = g
.entry((tenant.to_owned(), route_ix))
.or_insert((today, 0.0));
if e.0 != today {
*e = (today, 0.0);
}
e.1 += usd;
}
}
#[must_use]
pub fn spent_today(&self, tenant: &str, route_ix: usize, now: jiff::Timestamp) -> f64 {
let today = day_of(now);
self.inner
.lock()
.ok()
.and_then(|g| g.get(&(tenant.to_owned(), route_ix)).copied())
.filter(|(d, _)| *d == today)
.map_or(0.0, |(_, spent)| spent)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ts(secs: i64) -> jiff::Timestamp {
jiff::Timestamp::from_second(secs).unwrap()
}
#[test]
fn spending_stops_at_the_ceiling() {
let l = ShadowLedger::new();
let now = ts(1_000_000);
assert!(
l.may_spend("t", 0, 1.00, now),
"fresh budget must allow the first call"
);
l.debit("t", 0, 0.60, now);
assert!(
l.may_spend("t", 0, 1.00, now),
"0.60 of 1.00 spent — still under"
);
l.debit("t", 0, 0.50, now);
assert!(
!l.may_spend("t", 0, 1.00, now),
"1.10 of 1.00 spent — must refuse, or the ceiling is decoration"
);
}
#[test]
fn budget_resets_on_a_new_utc_day() {
let l = ShadowLedger::new();
let day1 = ts(86_400 * 100);
l.debit("t", 0, 5.0, day1);
assert!(!l.may_spend("t", 0, 1.0, day1));
let day2 = ts(86_400 * 101);
assert!(
l.may_spend("t", 0, 1.0, day2),
"new UTC day must restore the budget"
);
assert!((l.spent_today("t", 0, day2) - 0.0).abs() < f64::EPSILON);
}
#[test]
fn budgets_are_isolated_per_tenant_and_route() {
let l = ShadowLedger::new();
let now = ts(1_000_000);
l.debit("noisy", 0, 99.0, now);
assert!(!l.may_spend("noisy", 0, 1.0, now));
assert!(
l.may_spend("quiet", 0, 1.0, now),
"another tenant's budget was consumed"
);
assert!(
l.may_spend("noisy", 1, 1.0, now),
"another route's budget was consumed"
);
}
#[test]
fn non_positive_ceiling_never_spends() {
let l = ShadowLedger::new();
let now = ts(1_000_000);
assert!(!l.may_spend("t", 0, 0.0, now));
assert!(!l.may_spend("t", 0, -5.0, now));
}
#[test]
fn nonsense_debits_are_ignored() {
let l = ShadowLedger::new();
let now = ts(1_000_000);
for bad in [f64::NAN, f64::INFINITY, -1.0, 0.0] {
l.debit("t", 0, bad, now);
}
assert!((l.spent_today("t", 0, now) - 0.0).abs() < f64::EPSILON);
assert!(l.may_spend("t", 0, 1.0, now));
}
}