use std::time::{Duration, Instant};
const BYTES_PER_MB: u64 = 1024 * 1024;
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(100);
const DEFAULT_MAX_WAIT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmitOutcome {
Disabled,
Unavailable,
Clear,
Throttled,
WaitedOut,
}
pub struct FootprintGate<S = fn() -> Option<u64>>
where
S: Fn() -> Option<u64>,
{
limit_bytes: u64,
sampler: S,
poll_interval: Duration,
max_wait: Duration,
}
impl FootprintGate {
pub fn new(max_footprint_mb: usize) -> Self {
FootprintGate::with_sampler(max_footprint_mb, crate::sysres::phys_footprint)
}
}
impl<S> FootprintGate<S>
where
S: Fn() -> Option<u64>,
{
pub fn with_sampler(max_footprint_mb: usize, sampler: S) -> Self {
Self {
limit_bytes: (max_footprint_mb as u64).saturating_mul(BYTES_PER_MB),
sampler,
poll_interval: DEFAULT_POLL_INTERVAL,
max_wait: DEFAULT_MAX_WAIT,
}
}
#[cfg(test)]
pub fn with_timing(mut self, poll_interval: Duration, max_wait: Duration) -> Self {
self.poll_interval = poll_interval;
self.max_wait = max_wait;
self
}
pub fn admit(&self) -> AdmitOutcome {
if self.limit_bytes == 0 {
return AdmitOutcome::Disabled;
}
let start = Instant::now();
let mut parked = false;
loop {
match (self.sampler)() {
None => return AdmitOutcome::Unavailable,
Some(footprint) if footprint <= self.limit_bytes => {
return if parked {
AdmitOutcome::Throttled
} else {
AdmitOutcome::Clear
};
}
Some(_) => {
let elapsed = start.elapsed();
if elapsed >= self.max_wait {
tracing::warn!(
limit_mb = self.limit_bytes / BYTES_PER_MB,
waited_ms = elapsed.as_millis() as u64,
"footprint gate over ceiling for max_wait; admitting to guarantee progress"
);
return AdmitOutcome::WaitedOut;
}
parked = true;
std::thread::sleep(self.poll_interval);
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
const MB: u64 = 1024 * 1024;
#[test]
fn disabled_gate_admits_without_sampling() {
let polled = AtomicUsize::new(0);
let gate = FootprintGate::with_sampler(0, || {
polled.fetch_add(1, Ordering::SeqCst);
Some(u64::MAX)
});
assert_eq!(gate.admit(), AdmitOutcome::Disabled);
assert_eq!(polled.load(Ordering::SeqCst), 0, "disabled gate must not sample");
}
#[test]
fn unavailable_sample_admits_without_throttling() {
let gate = FootprintGate::with_sampler(100, || None);
assert_eq!(gate.admit(), AdmitOutcome::Unavailable);
}
#[test]
fn under_ceiling_admits_without_waiting() {
let gate = FootprintGate::with_sampler(100, || Some(10 * MB));
assert_eq!(gate.admit(), AdmitOutcome::Clear);
}
#[test]
fn over_then_under_parks_until_clear() {
let calls = AtomicUsize::new(0);
let gate = FootprintGate::with_sampler(200, || {
let n = calls.fetch_add(1, Ordering::SeqCst);
if n < 2 { Some(500 * MB) } else { Some(50 * MB) }
})
.with_timing(Duration::from_millis(1), Duration::from_secs(5));
assert_eq!(gate.admit(), AdmitOutcome::Throttled);
assert!(
calls.load(Ordering::SeqCst) >= 3,
"gate must re-sample until the footprint falls under the ceiling"
);
}
#[test]
fn persistent_over_waits_out_then_admits() {
let gate = FootprintGate::with_sampler(100, || Some(u64::MAX))
.with_timing(Duration::from_millis(1), Duration::from_millis(20));
let start = Instant::now();
assert_eq!(gate.admit(), AdmitOutcome::WaitedOut);
assert!(
start.elapsed() >= Duration::from_millis(20),
"gate must park the full max_wait before giving up"
);
}
}