use std::fmt;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RequestBudget {
started_at: Instant,
wall_clock_limit: Option<Duration>,
tokens_limit: Option<u64>,
memory_limit_bytes: Option<u64>,
io_limit_bytes: Option<u64>,
tokens_used: u64,
memory_used_bytes: u64,
io_used_bytes: u64,
}
impl RequestBudget {
#[must_use]
pub fn unbounded() -> Self {
Self::unbounded_at(Instant::now())
}
#[must_use]
pub const fn unbounded_at(anchor: Instant) -> Self {
Self {
started_at: anchor,
wall_clock_limit: None,
tokens_limit: None,
memory_limit_bytes: None,
io_limit_bytes: None,
tokens_used: 0,
memory_used_bytes: 0,
io_used_bytes: 0,
}
}
#[must_use]
pub fn with_wall_clock(mut self, budget: Duration) -> Self {
self.wall_clock_limit = Some(budget);
self
}
#[must_use]
pub const fn with_tokens(mut self, limit: u64) -> Self {
self.tokens_limit = Some(limit);
self
}
#[must_use]
pub const fn with_memory_bytes(mut self, bytes: u64) -> Self {
self.memory_limit_bytes = Some(bytes);
self
}
#[must_use]
pub const fn with_io_bytes(mut self, bytes: u64) -> Self {
self.io_limit_bytes = Some(bytes);
self
}
#[must_use]
pub fn elapsed(&self) -> Duration {
self.elapsed_at(Instant::now())
}
#[must_use]
pub fn elapsed_at(&self, now: Instant) -> Duration {
now.checked_duration_since(self.started_at)
.unwrap_or_default()
}
#[must_use]
pub fn remaining_wall_clock(&self) -> Option<Duration> {
self.remaining_wall_clock_at(Instant::now())
}
#[must_use]
pub fn remaining_wall_clock_at(&self, now: Instant) -> Option<Duration> {
self.wall_clock_limit
.map(|limit| limit.checked_sub(self.elapsed_at(now)).unwrap_or_default())
}
#[must_use]
pub const fn tokens_used(&self) -> u64 {
self.tokens_used
}
#[must_use]
pub const fn memory_used_bytes(&self) -> u64 {
self.memory_used_bytes
}
#[must_use]
pub const fn io_used_bytes(&self) -> u64 {
self.io_used_bytes
}
pub fn record_tokens(&mut self, n: u64) {
self.tokens_used = self.tokens_used.saturating_add(n);
}
pub fn record_memory_bytes(&mut self, bytes: u64) {
self.memory_used_bytes = self.memory_used_bytes.saturating_add(bytes);
}
pub fn record_io_bytes(&mut self, bytes: u64) {
self.io_used_bytes = self.io_used_bytes.saturating_add(bytes);
}
#[must_use]
pub fn snapshot(&self, dimension: BudgetDimension) -> Option<BudgetSnapshot> {
match dimension {
BudgetDimension::WallClock => self.wall_clock_limit.map(|limit| {
let elapsed = self.elapsed();
BudgetSnapshot {
dimension,
limit: duration_to_millis(limit),
used: duration_to_millis(elapsed),
}
}),
BudgetDimension::Tokens => self.tokens_limit.map(|limit| BudgetSnapshot {
dimension,
limit: u128::from(limit),
used: u128::from(self.tokens_used),
}),
BudgetDimension::Memory => self.memory_limit_bytes.map(|limit| BudgetSnapshot {
dimension,
limit: u128::from(limit),
used: u128::from(self.memory_used_bytes),
}),
BudgetDimension::Io => self.io_limit_bytes.map(|limit| BudgetSnapshot {
dimension,
limit: u128::from(limit),
used: u128::from(self.io_used_bytes),
}),
}
}
pub fn check(&self) -> Result<(), BudgetExceeded> {
self.check_at(Instant::now())
}
pub fn check_at(&self, now: Instant) -> Result<(), BudgetExceeded> {
for dimension in DIMENSION_ORDER {
let breach = match dimension {
BudgetDimension::WallClock => self.wall_clock_breach_at(now),
_ => self
.snapshot_at(dimension, now)
.filter(|snapshot| snapshot.is_exceeded()),
};
if let Some(snapshot) = breach {
return Err(BudgetExceeded::from(snapshot));
}
}
Ok(())
}
fn wall_clock_breach_at(&self, now: Instant) -> Option<BudgetSnapshot> {
let limit = self.wall_clock_limit?;
let elapsed = self.elapsed_at(now);
if elapsed <= limit {
return None;
}
let limit_ms = duration_to_millis(limit);
Some(BudgetSnapshot {
dimension: BudgetDimension::WallClock,
limit: limit_ms,
used: duration_to_millis(elapsed).max(limit_ms.saturating_add(1)),
})
}
fn snapshot_at(&self, dimension: BudgetDimension, now: Instant) -> Option<BudgetSnapshot> {
match dimension {
BudgetDimension::WallClock => self.wall_clock_limit.map(|limit| {
let elapsed = self.elapsed_at(now);
BudgetSnapshot {
dimension,
limit: duration_to_millis(limit),
used: duration_to_millis(elapsed),
}
}),
BudgetDimension::Tokens | BudgetDimension::Memory | BudgetDimension::Io => {
self.snapshot(dimension)
}
}
}
}
const DIMENSION_ORDER: [BudgetDimension; 4] = [
BudgetDimension::WallClock,
BudgetDimension::Tokens,
BudgetDimension::Memory,
BudgetDimension::Io,
];
fn duration_to_millis(d: Duration) -> u128 {
d.as_millis()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BudgetDimension {
WallClock,
Tokens,
Memory,
Io,
}
impl BudgetDimension {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::WallClock => "wall_clock",
Self::Tokens => "tokens",
Self::Memory => "memory",
Self::Io => "io",
}
}
}
impl fmt::Display for BudgetDimension {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BudgetSnapshot {
pub dimension: BudgetDimension,
pub limit: u128,
pub used: u128,
}
impl BudgetSnapshot {
#[must_use]
pub const fn is_exceeded(&self) -> bool {
self.used > self.limit
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BudgetExceeded {
pub dimension: BudgetDimension,
pub limit: u128,
pub used: u128,
}
impl From<BudgetSnapshot> for BudgetExceeded {
fn from(snapshot: BudgetSnapshot) -> Self {
Self {
dimension: snapshot.dimension,
limit: snapshot.limit,
used: snapshot.used,
}
}
}
impl fmt::Display for BudgetExceeded {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"request budget exceeded: dimension={} limit={} used={}",
self.dimension, self.limit, self.used
)
}
}
impl std::error::Error for BudgetExceeded {}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{BudgetDimension, BudgetExceeded, BudgetSnapshot, RequestBudget};
fn anchor() -> Instant {
Instant::now()
}
fn require_err<T>(
result: std::result::Result<T, BudgetExceeded>,
message: &'static str,
) -> std::result::Result<BudgetExceeded, &'static str> {
result.err().ok_or(message)
}
fn require_some<T>(
value: Option<T>,
message: &'static str,
) -> std::result::Result<T, &'static str> {
value.ok_or(message)
}
#[test]
fn unbounded_budget_never_exceeds() {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now);
b.record_tokens(u64::MAX);
b.record_memory_bytes(u64::MAX);
b.record_io_bytes(u64::MAX);
let later = now + Duration::from_secs(60 * 60 * 24);
assert!(b.check_at(later).is_ok());
}
#[test]
fn wall_clock_breach_is_detected_at_or_after_deadline() -> std::result::Result<(), &'static str>
{
let now = anchor();
let b = RequestBudget::unbounded_at(now).with_wall_clock(Duration::from_millis(100));
let before = now + Duration::from_millis(50);
assert!(b.check_at(before).is_ok());
let at_deadline = now + Duration::from_millis(100);
assert!(b.check_at(at_deadline).is_ok());
let past_deadline = now + Duration::from_millis(101);
let err = require_err(b.check_at(past_deadline), "past deadline must fail")?;
assert_eq!(err.dimension, BudgetDimension::WallClock);
assert_eq!(err.limit, 100);
assert_eq!(err.used, 101);
Ok(())
}
#[test]
fn tokens_breach_is_detected_after_recording() -> std::result::Result<(), &'static str> {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now).with_tokens(10);
b.record_tokens(7);
assert!(b.check_at(now).is_ok());
b.record_tokens(4);
let err = require_err(b.check_at(now), "11 tokens past 10 must fail")?;
assert_eq!(err.dimension, BudgetDimension::Tokens);
assert_eq!(err.limit, 10);
assert_eq!(err.used, 11);
Ok(())
}
#[test]
fn memory_breach_is_detected_after_recording() -> std::result::Result<(), &'static str> {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now).with_memory_bytes(1024);
b.record_memory_bytes(2048);
let err = require_err(b.check_at(now), "memory must breach")?;
assert_eq!(err.dimension, BudgetDimension::Memory);
assert_eq!(err.limit, 1024);
assert_eq!(err.used, 2048);
Ok(())
}
#[test]
fn io_breach_is_detected_after_recording() -> std::result::Result<(), &'static str> {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now).with_io_bytes(1);
b.record_io_bytes(2);
let err = require_err(b.check_at(now), "io must breach")?;
assert_eq!(err.dimension, BudgetDimension::Io);
assert_eq!(err.limit, 1);
assert_eq!(err.used, 2);
Ok(())
}
#[test]
fn simultaneous_breaches_report_wall_clock_first() -> std::result::Result<(), &'static str> {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now)
.with_wall_clock(Duration::from_millis(10))
.with_tokens(1)
.with_memory_bytes(1)
.with_io_bytes(1);
b.record_tokens(2);
b.record_memory_bytes(2);
b.record_io_bytes(2);
let past = now + Duration::from_millis(11);
let err = require_err(b.check_at(past), "multi-axis breach must fail")?;
assert_eq!(err.dimension, BudgetDimension::WallClock);
Ok(())
}
#[test]
fn ordering_after_wall_clock_is_tokens_then_memory_then_io()
-> std::result::Result<(), &'static str> {
let now = anchor();
let mut b = RequestBudget::unbounded_at(now)
.with_tokens(1)
.with_memory_bytes(1)
.with_io_bytes(1);
b.record_tokens(2);
b.record_memory_bytes(2);
b.record_io_bytes(2);
let err = require_err(b.check_at(now), "multi-axis breach must fail")?;
assert_eq!(err.dimension, BudgetDimension::Tokens);
let mut b2 = RequestBudget::unbounded_at(now)
.with_memory_bytes(1)
.with_io_bytes(1);
b2.record_memory_bytes(2);
b2.record_io_bytes(2);
let err2 = require_err(b2.check_at(now), "memory-then-io breach must fail")?;
assert_eq!(err2.dimension, BudgetDimension::Memory);
Ok(())
}
#[test]
fn record_tokens_is_saturating_at_u64_max() {
let mut b = RequestBudget::unbounded_at(anchor());
b.record_tokens(u64::MAX);
b.record_tokens(1);
assert_eq!(b.tokens_used(), u64::MAX);
}
#[test]
fn record_memory_is_saturating_at_u64_max() {
let mut b = RequestBudget::unbounded_at(anchor());
b.record_memory_bytes(u64::MAX);
b.record_memory_bytes(1);
assert_eq!(b.memory_used_bytes(), u64::MAX);
}
#[test]
fn record_io_is_saturating_at_u64_max() {
let mut b = RequestBudget::unbounded_at(anchor());
b.record_io_bytes(u64::MAX);
b.record_io_bytes(1);
assert_eq!(b.io_used_bytes(), u64::MAX);
}
#[test]
fn elapsed_at_pre_anchor_reports_zero() {
let now = anchor();
let b = RequestBudget::unbounded_at(now);
assert_eq!(b.elapsed_at(now), Duration::ZERO);
}
#[test]
fn remaining_wall_clock_is_none_when_unbounded() {
let b = RequestBudget::unbounded_at(anchor());
assert!(b.remaining_wall_clock().is_none());
}
#[test]
fn remaining_wall_clock_is_some_zero_after_deadline() {
let now = anchor();
let b = RequestBudget::unbounded_at(now).with_wall_clock(Duration::from_millis(50));
let past = now + Duration::from_millis(75);
assert_eq!(b.remaining_wall_clock_at(past), Some(Duration::ZERO));
}
#[test]
fn snapshot_returns_none_for_unbounded_dimension() {
let b = RequestBudget::unbounded_at(anchor());
for dim in [
BudgetDimension::WallClock,
BudgetDimension::Tokens,
BudgetDimension::Memory,
BudgetDimension::Io,
] {
assert!(b.snapshot(dim).is_none(), "{dim:?} must be unbounded");
}
}
#[test]
fn snapshot_reports_used_and_limit_for_bounded_dimension()
-> std::result::Result<(), &'static str> {
let mut b = RequestBudget::unbounded_at(anchor())
.with_tokens(100)
.with_memory_bytes(2_048)
.with_io_bytes(4_096);
b.record_tokens(40);
b.record_memory_bytes(2_048);
b.record_io_bytes(1);
let tokens = require_some(
b.snapshot(BudgetDimension::Tokens),
"tokens dimension is bounded",
)?;
assert_eq!(tokens.limit, 100);
assert_eq!(tokens.used, 40);
assert!(!tokens.is_exceeded());
let memory = require_some(
b.snapshot(BudgetDimension::Memory),
"memory dimension is bounded",
)?;
assert_eq!(memory.limit, 2_048);
assert_eq!(memory.used, 2_048);
assert!(!memory.is_exceeded());
let io = require_some(b.snapshot(BudgetDimension::Io), "io dimension is bounded")?;
assert_eq!(io.limit, 4_096);
assert_eq!(io.used, 1);
assert!(!io.is_exceeded());
Ok(())
}
#[test]
fn budget_dimension_strings_are_stable() {
assert_eq!(BudgetDimension::WallClock.as_str(), "wall_clock");
assert_eq!(BudgetDimension::Tokens.as_str(), "tokens");
assert_eq!(BudgetDimension::Memory.as_str(), "memory");
assert_eq!(BudgetDimension::Io.as_str(), "io");
}
#[test]
fn budget_exceeded_display_includes_dimension_limit_and_used() {
let err = BudgetExceeded::from(BudgetSnapshot {
dimension: BudgetDimension::Tokens,
limit: 100,
used: 200,
});
let rendered = format!("{err}");
assert!(rendered.contains("dimension=tokens"));
assert!(rendered.contains("limit=100"));
assert!(rendered.contains("used=200"));
}
#[test]
fn zero_wall_clock_budget_breaches_immediately_after_anchor() {
let now = anchor();
let b = RequestBudget::unbounded_at(now).with_wall_clock(Duration::ZERO);
assert!(b.check_at(now).is_ok());
let past = now + Duration::from_millis(1);
assert!(b.check_at(past).is_err());
}
#[test]
fn wall_clock_check_detects_sub_ms_overrun_bd_34599() {
let now = anchor();
let zero = RequestBudget::unbounded_at(now).with_wall_clock(Duration::ZERO);
let err = zero
.check_at(now + Duration::from_nanos(1))
.expect_err("zero wall-clock budget breaches on any positive elapsed");
assert_eq!(err.dimension, BudgetDimension::WallClock);
let tiny = RequestBudget::unbounded_at(now).with_wall_clock(Duration::from_micros(1500));
assert!(
tiny.check_at(now + Duration::from_micros(1900)).is_err(),
"sub-millisecond overrun must breach"
);
assert!(
tiny.check_at(now + Duration::from_micros(1500)).is_ok(),
"exactly at the limit is not a breach"
);
assert!(
tiny.check_at(now + Duration::from_micros(1400)).is_ok(),
"under the limit is not a breach"
);
}
#[test]
fn oversized_wall_clock_budget_remains_bounded() -> std::result::Result<(), &'static str> {
let now = anchor();
let b = RequestBudget::unbounded_at(now).with_wall_clock(Duration::MAX);
assert_eq!(b.remaining_wall_clock_at(now), Some(Duration::MAX));
let snapshot = require_some(
b.snapshot(BudgetDimension::WallClock),
"oversized wall-clock budget must remain bounded",
)?;
assert_eq!(snapshot.limit, Duration::MAX.as_millis());
assert!(b.check_at(now).is_ok());
Ok(())
}
}