use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
pub const DEFAULT_RULE_TIMEOUT: Duration = Duration::from_secs(1);
pub const DEFAULT_GLOBAL_TIMEOUT: Duration = Duration::from_secs(15);
pub const DEFAULT_MEMORY_BYTES: usize = 64 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub rule_timeout: Duration,
pub global_timeout: Duration,
pub memory_bytes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
rule_timeout: DEFAULT_RULE_TIMEOUT,
global_timeout: DEFAULT_GLOBAL_TIMEOUT,
memory_bytes: DEFAULT_MEMORY_BYTES,
}
}
}
impl Limits {
#[must_use]
pub const fn with_rule_timeout(mut self, timeout: Duration) -> Self {
self.rule_timeout = timeout;
self
}
#[must_use]
pub const fn with_global_timeout(mut self, timeout: Duration) -> Self {
self.global_timeout = timeout;
self
}
#[must_use]
pub const fn with_memory_bytes(mut self, bytes: usize) -> Self {
self.memory_bytes = bytes;
self
}
}
#[derive(Debug)]
pub struct RunClock {
start: Instant,
global_timeout: Duration,
}
impl RunClock {
#[must_use]
pub fn start(global_timeout: Duration) -> Arc<Self> {
Arc::new(Self {
start: Instant::now(),
global_timeout,
})
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.start.elapsed()
}
#[must_use]
pub const fn global_timeout(&self) -> Duration {
self.global_timeout
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.elapsed() >= self.global_timeout
}
fn elapsed_nanos(&self) -> u64 {
u64::try_from(self.start.elapsed().as_nanos()).unwrap_or(u64::MAX)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Trip {
Rule,
Run,
}
const TRIP_NONE: u64 = 0;
const TRIP_RULE: u64 = 1;
const TRIP_RUN: u64 = 2;
#[derive(Debug)]
pub(crate) struct Budget {
clock: Arc<RunClock>,
global_nanos: u64,
invocation_deadline_nanos: AtomicU64,
tripped: AtomicU64,
}
impl Budget {
pub(crate) fn new(clock: Arc<RunClock>) -> Arc<Self> {
let global_nanos = u64::try_from(clock.global_timeout.as_nanos()).unwrap_or(u64::MAX);
Arc::new(Self {
clock,
global_nanos,
invocation_deadline_nanos: AtomicU64::new(0),
tripped: AtomicU64::new(TRIP_NONE),
})
}
pub(crate) fn arm(&self, rule_timeout: Duration) {
let now = self.clock.elapsed_nanos();
let budget = u64::try_from(rule_timeout.as_nanos()).unwrap_or(u64::MAX);
self.invocation_deadline_nanos
.store(now.saturating_add(budget).max(1), Ordering::Relaxed);
self.tripped.store(TRIP_NONE, Ordering::Relaxed);
}
pub(crate) fn disarm(&self) {
self.invocation_deadline_nanos.store(0, Ordering::Relaxed);
}
pub(crate) fn should_interrupt(&self) -> bool {
let elapsed = self.clock.elapsed_nanos();
if elapsed >= self.global_nanos {
self.tripped.store(TRIP_RUN, Ordering::Relaxed);
return true;
}
let deadline = self.invocation_deadline_nanos.load(Ordering::Relaxed);
if deadline != 0 && elapsed >= deadline {
self.tripped.store(TRIP_RULE, Ordering::Relaxed);
return true;
}
false
}
pub(crate) fn take_trip(&self) -> Option<Trip> {
match self.tripped.swap(TRIP_NONE, Ordering::Relaxed) {
TRIP_RULE => Some(Trip::Rule),
TRIP_RUN => Some(Trip::Run),
_ => None,
}
}
pub(crate) fn clock(&self) -> &RunClock {
&self.clock
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_the_documented_budgets() {
let limits = Limits::default();
assert_eq!(limits.rule_timeout, Duration::from_secs(1));
assert_eq!(limits.global_timeout, Duration::from_secs(15));
assert_eq!(limits.memory_bytes, 64 * 1024 * 1024);
}
#[test]
fn the_rule_budget_is_well_under_the_global_one() {
let limits = Limits::default();
assert!(
limits.rule_timeout * 5 < limits.global_timeout,
"the per-invocation budget must leave room for the global limit to be a backstop"
);
}
#[test]
fn a_rule_cannot_raise_the_global_budget() {
let limits = Limits::default().with_rule_timeout(Duration::from_secs(60));
assert_eq!(limits.rule_timeout, Duration::from_secs(60));
assert_eq!(
limits.global_timeout, DEFAULT_GLOBAL_TIMEOUT,
"raising a rule's own budget must not extend the run"
);
}
#[test]
fn an_unarmed_budget_never_interrupts() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
assert!(!budget.should_interrupt());
assert_eq!(budget.take_trip(), None);
}
#[test]
fn an_expired_invocation_budget_interrupts_and_records_why() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Rule));
}
#[test]
fn an_expired_run_budget_interrupts_and_records_why() {
let budget = Budget::new(RunClock::start(Duration::ZERO));
budget.arm(Duration::from_secs(3600));
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn the_run_budget_wins_when_both_are_spent() {
let budget = Budget::new(RunClock::start(Duration::ZERO));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Run));
}
#[test]
fn disarming_stops_invocation_enforcement() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
budget.arm(Duration::ZERO);
budget.disarm();
assert!(!budget.should_interrupt(), "no invocation is in flight");
}
#[test]
fn taking_the_trip_clears_it() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
assert_eq!(budget.take_trip(), Some(Trip::Rule));
assert_eq!(
budget.take_trip(),
None,
"a trip must not be reported twice"
);
}
#[test]
fn arming_clears_a_previous_trip() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
budget.arm(Duration::ZERO);
assert!(budget.should_interrupt());
budget.arm(Duration::from_secs(3600));
assert!(!budget.should_interrupt());
assert_eq!(budget.take_trip(), None);
}
#[test]
fn an_overflowing_budget_does_not_wrap_into_disarmed() {
let budget = Budget::new(RunClock::start(Duration::from_secs(3600)));
budget.arm(Duration::MAX);
assert_ne!(
budget.invocation_deadline_nanos.load(Ordering::Relaxed),
0,
"an overflowing budget must not read as disarmed"
);
}
#[test]
fn the_clock_measures_from_one_origin() {
let clock = RunClock::start(Duration::from_secs(3600));
let a = Arc::clone(&clock);
let b = Arc::clone(&clock);
assert!(!a.is_expired());
assert!(!b.is_expired());
assert_eq!(a.global_timeout(), Duration::from_secs(3600));
}
#[test]
fn a_zero_global_budget_is_immediately_expired() {
assert!(RunClock::start(Duration::ZERO).is_expired());
}
}