use std::sync::atomic::{AtomicU32, Ordering};
use time::{Duration, OffsetDateTime};
#[derive(Debug)]
pub struct ScheduledBudget {
deadline: Option<OffsetDateTime>,
subrequests: Option<u32>,
spent: AtomicU32,
}
impl ScheduledBudget {
#[must_use]
pub const fn unbounded() -> Self {
Self {
deadline: None,
subrequests: None,
spent: AtomicU32::new(0),
}
}
#[must_use]
pub const fn new(deadline: Option<OffsetDateTime>, subrequests: Option<u32>) -> Self {
Self {
deadline,
subrequests,
spent: AtomicU32::new(0),
}
}
#[must_use]
pub fn deadline(&self) -> Option<OffsetDateTime> {
self.deadline
}
#[must_use]
pub fn remaining_subrequests(&self) -> Option<u32> {
self.subrequests.map(|cap| cap.saturating_sub(self.spent()))
}
#[must_use]
pub fn spent(&self) -> u32 {
self.spent.load(Ordering::Relaxed)
}
pub fn try_spend(&self, n: u32) -> bool {
let Some(cap) = self.subrequests else {
self.spent.fetch_add(n, Ordering::Relaxed);
return true;
};
let mut seen = self.spent.load(Ordering::Relaxed);
loop {
let Some(next) = seen.checked_add(n) else {
return false;
};
if next > cap {
return false;
}
match self
.spent
.compare_exchange_weak(seen, next, Ordering::Relaxed, Ordering::Relaxed)
{
Ok(_) => return true,
Err(now_seen) => seen = now_seen,
}
}
}
#[must_use]
pub fn expired(&self, now: OffsetDateTime) -> bool {
self.deadline.is_some_and(|deadline| now >= deadline)
}
#[must_use]
pub fn exhausted(&self, now: OffsetDateTime) -> bool {
self.expired(now) || self.remaining_subrequests() == Some(0)
}
}
impl Default for ScheduledBudget {
fn default() -> Self {
Self::unbounded()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScheduledLimits {
pub wall: Option<Duration>,
pub subrequests: Option<u32>,
}
impl ScheduledLimits {
pub const UNBOUNDED: Self = Self {
wall: None,
subrequests: None,
};
}
#[derive(Debug)]
pub struct ScheduledSplit {
end: Option<OffsetDateTime>,
subrequests_left: Option<u32>,
modules_left: usize,
}
impl ScheduledSplit {
#[must_use]
pub fn new(limits: ScheduledLimits, now: OffsetDateTime, modules: usize) -> Self {
Self {
end: limits.wall.map(|wall| now.saturating_add(wall)),
subrequests_left: limits.subrequests,
modules_left: modules,
}
}
#[must_use]
pub fn next(&mut self, now: OffsetDateTime) -> ScheduledBudget {
if self.modules_left == 0 {
return ScheduledBudget::new(Some(now), Some(0));
}
let deadline = self.end.map(|end| {
let remaining = (end - now).max(Duration::ZERO);
let share = i32::try_from(self.modules_left).unwrap_or(i32::MAX);
now.saturating_add(remaining / share)
});
let subrequests = self.subrequests_left.map(|left| {
let modules = u32::try_from(self.modules_left).unwrap_or(u32::MAX);
left.div_ceil(modules)
});
ScheduledBudget::new(deadline, subrequests)
}
pub fn settle(&mut self, budget: &ScheduledBudget) {
self.modules_left = self.modules_left.saturating_sub(1);
if let Some(left) = self.subrequests_left {
self.subrequests_left = Some(left.saturating_sub(budget.spent()));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(epoch_secs: i64) -> OffsetDateTime {
OffsetDateTime::from_unix_timestamp(epoch_secs).expect("in range")
}
#[test]
fn try_spend_spends_only_what_fits() {
let budget = ScheduledBudget::new(None, Some(3));
assert!(budget.try_spend(2));
assert_eq!(budget.spent(), 2);
assert!(!budget.try_spend(2), "a spend past the cap spends nothing");
assert_eq!(budget.spent(), 2, "a refused spend leaves no trace");
assert!(budget.try_spend(1));
assert_eq!(budget.remaining_subrequests(), Some(0));
assert!(!budget.try_spend(1), "an empty budget refuses");
}
#[test]
fn an_unbounded_budget_never_refuses_or_exhausts() {
let budget = ScheduledBudget::unbounded();
assert!(budget.try_spend(10_000));
assert_eq!(budget.remaining_subrequests(), None);
assert!(!budget.expired(at(1_000_000_000)));
assert!(!budget.exhausted(at(1_000_000_000)));
}
#[test]
fn expiry_is_the_deadline_and_exhaustion_is_either_bound() {
let timed = ScheduledBudget::new(Some(at(100)), Some(10));
assert!(!timed.expired(at(99)));
assert!(timed.expired(at(100)), "the deadline itself is past it");
assert!(!timed.exhausted(at(99)));
assert!(timed.exhausted(at(100)));
let counted = ScheduledBudget::new(None, Some(1));
assert!(!counted.exhausted(at(0)));
assert!(counted.try_spend(1));
assert!(
counted.exhausted(at(0)),
"zero subrequests left is exhausted without a deadline"
);
}
#[test]
fn an_unspent_share_rolls_forward_to_the_later_modules() {
let start = at(0);
let mut split = ScheduledSplit::new(
ScheduledLimits {
wall: None,
subrequests: Some(7),
},
start,
3,
);
let first = split.next(start);
assert_eq!(
first.remaining_subrequests(),
Some(3),
"a third, rounded up"
);
split.settle(&first); let second = split.next(start);
assert_eq!(
second.remaining_subrequests(),
Some(4),
"the share that rolled forward, over two modules"
);
split.settle(&second); let third = split.next(start);
assert_eq!(
third.remaining_subrequests(),
Some(7),
"the last module gets everything left"
);
}
#[test]
fn settled_spending_shrinks_the_later_shares() {
let start = at(0);
let mut split = ScheduledSplit::new(
ScheduledLimits {
wall: None,
subrequests: Some(4),
},
start,
2,
);
let first = split.next(start);
assert_eq!(first.remaining_subrequests(), Some(2));
assert!(
!first.try_spend(3),
"a module cannot spend past its own share"
);
assert!(first.try_spend(2));
split.settle(&first);
let second = split.next(start);
assert_eq!(
second.remaining_subrequests(),
Some(2),
"what the first module left"
);
}
#[test]
fn the_wall_split_never_schedules_past_the_end() {
let start = at(0);
let mut split = ScheduledSplit::new(
ScheduledLimits {
wall: Some(Duration::seconds(30)),
subrequests: None,
},
start,
3,
);
let first = split.next(start);
assert_eq!(first.deadline(), Some(at(10)));
split.settle(&first);
let second = split.next(at(9));
assert_eq!(
second.deadline(),
Some(start + Duration::milliseconds(19_500))
);
split.settle(&second);
let third = split.next(at(19));
assert_eq!(
third.deadline(),
Some(at(30)),
"the last module runs to the end"
);
}
#[test]
fn unbounded_limits_give_unbounded_budgets() {
let start = at(0);
let mut split = ScheduledSplit::new(ScheduledLimits::UNBOUNDED, start, 2);
let budget = split.next(start);
assert_eq!(budget.deadline(), None);
assert_eq!(budget.remaining_subrequests(), None);
assert!(!budget.exhausted(at(1_000_000)));
split.settle(&budget);
}
}