use std::time::{Duration, Instant};
use super::bandwidth::BandwidthClass;
pub const BACKGROUND_STARVE_WINDOW_DEFAULT: Duration = Duration::from_secs(60);
pub const BACKGROUND_HATCH_MAX_GRANT_FRACTION: f64 = 0.10;
pub const REALTIME_MAX_GRANT_FRACTION: f64 = 0.50;
#[derive(Debug, Clone)]
pub struct BandwidthBudget {
available_bytes: f64,
refill_bps: f64,
capacity_bytes: f64,
last_refill: Instant,
last_background_admission: Option<Instant>,
background_starve_window: Duration,
realtime_debt: f64,
}
impl BandwidthBudget {
pub fn new(fraction: f32, nic_peak_bps: u64, now: Instant) -> Self {
let clamped = if !fraction.is_finite() || fraction <= 0.0 {
f32::EPSILON
} else if fraction > 1.0 {
1.0
} else {
fraction
};
let refill_bps = nic_peak_bps as f64 * clamped as f64;
let capacity_bytes = refill_bps;
Self {
available_bytes: capacity_bytes,
refill_bps,
capacity_bytes,
last_refill: now,
last_background_admission: Some(now),
background_starve_window: BACKGROUND_STARVE_WINDOW_DEFAULT,
realtime_debt: 0.0,
}
}
pub fn with_background_starve_window(mut self, window: Duration) -> Self {
self.background_starve_window = window;
self
}
pub fn refill(&mut self, now: Instant) {
let elapsed = now.saturating_duration_since(self.last_refill);
if elapsed.is_zero() {
return;
}
let added = self.refill_bps * elapsed.as_secs_f64();
self.available_bytes = (self.available_bytes + added).min(self.capacity_bytes);
self.last_refill = now;
}
pub fn try_consume(&mut self, bytes: u64, now: Instant) -> bool {
self.try_consume_with_class(bytes, BandwidthClass::Foreground, now, 0.0)
}
pub fn try_consume_with_class(
&mut self,
bytes: u64,
class: BandwidthClass,
now: Instant,
background_fraction: f32,
) -> bool {
if bytes == 0 {
return true;
}
self.refill(now);
let cost = bytes as f64;
match class {
BandwidthClass::Realtime => {
let realtime_cap = REALTIME_MAX_GRANT_FRACTION * self.capacity_bytes;
if cost > realtime_cap {
return false;
}
self.available_bytes = (self.available_bytes - cost).max(0.0);
self.realtime_debt += cost;
true
}
BandwidthClass::Foreground => {
if self.available_bytes >= cost {
self.available_bytes -= cost;
return true;
}
if bytes > self.capacity_bytes as u64
&& self.available_bytes >= self.capacity_bytes - f64::EPSILON
{
self.available_bytes = 0.0;
return true;
}
false
}
BandwidthClass::Background => {
let reserve = (1.0 - background_fraction as f64).max(0.0) * self.capacity_bytes;
let gated_ok =
self.available_bytes >= cost && self.available_bytes - cost >= reserve;
if gated_ok {
self.available_bytes -= cost;
self.last_background_admission = Some(now);
return true;
}
let starved = self
.last_background_admission
.map(|t| now.saturating_duration_since(t) > self.background_starve_window)
.unwrap_or(true);
if starved {
let hatch_cap = BACKGROUND_HATCH_MAX_GRANT_FRACTION * self.capacity_bytes;
if cost > hatch_cap {
return false;
}
self.available_bytes = (self.available_bytes - cost).max(0.0);
self.last_background_admission = Some(now);
return true;
}
false
}
}
}
pub fn refund(&mut self, bytes: u64) {
if bytes == 0 {
return;
}
let mut credit = bytes as f64;
if self.realtime_debt > 0.0 {
let repay = credit.min(self.realtime_debt);
self.realtime_debt -= repay;
credit -= repay;
}
if credit <= 0.0 {
return;
}
let floored = self.available_bytes.max(0.0).floor();
self.available_bytes = (floored + credit).min(self.capacity_bytes);
}
pub fn available_bytes(&self) -> u64 {
self.available_bytes.max(0.0).floor() as u64
}
pub fn refill_bps(&self) -> u64 {
self.refill_bps as u64
}
pub fn capacity_bytes(&self) -> u64 {
self.capacity_bytes as u64
}
pub fn set_nic_peak(&mut self, nic_peak_bps: u64, fraction: f32, now: Instant) {
self.refill(now);
let clamped = if !fraction.is_finite() || fraction <= 0.0 {
f32::EPSILON
} else if fraction > 1.0 {
1.0
} else {
fraction
};
let new_refill = nic_peak_bps as f64 * clamped as f64;
let prev_proportion = if self.capacity_bytes > 0.0 {
self.available_bytes / self.capacity_bytes
} else {
1.0
};
self.refill_bps = new_refill;
self.capacity_bytes = new_refill;
self.available_bytes = (new_refill * prev_proportion).min(new_refill);
self.last_background_admission = Some(now);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn t0() -> Instant {
Instant::now()
}
fn at(base: Instant, ms: u64) -> Instant {
base + Duration::from_millis(ms)
}
#[test]
fn bucket_starts_full_at_capacity() {
let base = t0();
let bb = BandwidthBudget::new(0.5, 2 * 1024 * 1024, base);
assert_eq!(bb.refill_bps(), 1024 * 1024);
assert_eq!(bb.capacity_bytes(), 1024 * 1024);
assert_eq!(bb.available_bytes(), 1024 * 1024);
}
#[test]
fn try_consume_succeeds_within_capacity() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000_000, base);
assert!(bb.try_consume(500_000, base));
assert!(bb.available_bytes() >= 499_999);
assert!(bb.available_bytes() <= 500_001);
}
#[test]
fn try_consume_fails_when_empty() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 100, base);
assert!(bb.try_consume(100, base));
assert!(!bb.try_consume(1, base));
}
#[test]
fn refill_restores_tokens_over_time() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base); bb.try_consume(1_000, base); assert_eq!(bb.available_bytes(), 0);
bb.refill(at(base, 500));
let avail = bb.available_bytes();
assert!(
(499..=500).contains(&avail),
"expected ~500 bytes refilled, got {avail}",
);
}
#[test]
fn refill_caps_at_capacity_not_unbounded() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
bb.refill(at(base, 10_000));
assert_eq!(bb.available_bytes(), 1_000);
}
#[test]
fn zero_byte_consume_always_succeeds() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
bb.try_consume(1_000, base); assert!(bb.try_consume(0, base));
assert_eq!(bb.available_bytes(), 0); }
#[test]
fn fraction_above_one_clamped() {
let base = t0();
let bb = BandwidthBudget::new(2.0, 1_000_000, base);
assert_eq!(bb.refill_bps(), 1_000_000);
}
#[test]
fn fraction_zero_falls_back_to_epsilon() {
let base = t0();
let bb = BandwidthBudget::new(0.0, 1_000_000_000, base);
assert!(bb.refill_bps() > 0);
}
#[test]
fn fraction_nan_falls_back_to_epsilon() {
let base = t0();
let bb = BandwidthBudget::new(f32::NAN, 1_000_000_000, base);
assert!(bb.refill_bps() > 0);
}
#[test]
fn fraction_neg_inf_falls_back_to_epsilon() {
let base = t0();
let bb = BandwidthBudget::new(f32::NEG_INFINITY, 1_000_000_000, base);
assert!(bb.refill_bps() > 0);
}
#[test]
fn partial_consume_then_refill_then_consume() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
bb.try_consume(600, base); bb.refill(at(base, 500)); let avail = bb.available_bytes();
assert!((899..=900).contains(&avail), "got {avail}");
assert!(bb.try_consume(900, at(base, 500)));
}
#[test]
fn try_consume_oversize_with_full_bucket_admits_as_one_off() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
assert!(bb.try_consume(2_000, base));
assert!(!bb.try_consume(1, base));
}
#[test]
fn try_consume_oversize_with_partial_bucket_fails() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
assert!(bb.try_consume(500, base));
assert!(!bb.try_consume(2_000, base));
let remaining = bb.available_bytes();
assert!((499..=501).contains(&remaining));
}
#[test]
fn set_nic_peak_preserves_fill_proportion() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
bb.try_consume(500, base); let before = bb.available_bytes();
assert!((499..=501).contains(&before));
bb.set_nic_peak(2_000, 1.0, base);
assert_eq!(bb.capacity_bytes(), 2_000);
let after = bb.available_bytes();
assert!(
(999..=1_000).contains(&after),
"expected ~half of 2_000 = 1_000; got {after}",
);
}
#[test]
fn set_nic_peak_to_smaller_caps_at_new_capacity() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 10_000, base);
bb.set_nic_peak(1_000, 1.0, base);
assert_eq!(bb.capacity_bytes(), 1_000);
assert!(bb.available_bytes() <= 1_000);
}
#[test]
fn refill_with_zero_elapsed_is_noop() {
let base = t0();
let mut bb = BandwidthBudget::new(1.0, 1_000, base);
bb.try_consume(500, base);
let before = bb.available_bytes();
bb.refill(base); assert_eq!(bb.available_bytes(), before);
}
}
#[cfg(test)]
mod class_tests {
use super::*;
fn at(base: Instant, millis: u64) -> Instant {
base + Duration::from_millis(millis)
}
fn fresh(capacity_bps: u64) -> (BandwidthBudget, Instant) {
let base = Instant::now();
(BandwidthBudget::new(1.0, capacity_bps, base), base)
}
#[test]
fn foreground_admits_up_to_capacity_then_rejects() {
let (mut bb, base) = fresh(1000);
assert!(bb.try_consume_with_class(600, BandwidthClass::Foreground, base, 0.3,));
assert!(bb.try_consume_with_class(400, BandwidthClass::Foreground, base, 0.3,));
assert!(!bb.try_consume_with_class(1, BandwidthClass::Foreground, base, 0.3,));
}
#[test]
fn background_admits_only_when_above_reserve() {
let (mut bb, base) = fresh(1000);
assert!(bb.try_consume_with_class(200, BandwidthClass::Background, base, 0.3,));
assert!(!bb.try_consume_with_class(200, BandwidthClass::Background, at(base, 1), 0.3,));
}
#[test]
fn background_with_full_fraction_acts_like_foreground() {
let (mut bb, base) = fresh(1000);
assert!(bb.try_consume_with_class(900, BandwidthClass::Background, base, 1.0,));
assert!(bb.try_consume_with_class(100, BandwidthClass::Background, base, 1.0,));
}
#[test]
fn background_with_zero_fraction_denied_on_any_credit() {
let (mut bb, base) = fresh(1000);
assert!(!bb.try_consume_with_class(1, BandwidthClass::Background, base, 0.0,));
}
#[test]
fn realtime_bypasses_failure_path() {
let (mut bb, base) = fresh(1000);
assert!(bb.try_consume_with_class(1000, BandwidthClass::Foreground, base, 0.3,));
assert!(!bb.try_consume_with_class(1, BandwidthClass::Foreground, base, 0.3,));
assert!(bb.try_consume_with_class(500, BandwidthClass::Realtime, base, 0.3,));
}
#[test]
fn set_nic_peak_resets_d4_starve_timer() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 10_000, base)
.with_background_starve_window(Duration::from_millis(100));
assert!(bb.try_consume_with_class(10_000, BandwidthClass::Foreground, base, 0.3));
let post_window = at(base, 200);
bb.set_nic_peak(20_000, 1.0, post_window);
let small_request = 100u64; assert!(
!bb.try_consume_with_class(
small_request,
BandwidthClass::Background,
at(post_window, 1),
0.3,
),
"Background must be denied within fresh window after set_nic_peak",
);
}
#[test]
fn refund_repays_realtime_debt_before_crediting_general_pool() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 1000, base);
assert!(bb.try_consume_with_class(400, BandwidthClass::Realtime, base, 0.3));
let after_rt = bb.available_bytes();
assert!((599..=600).contains(&after_rt));
assert!(bb.try_consume_with_class(100, BandwidthClass::Foreground, base, 0.3));
bb.refund(100);
let after_refund = bb.available_bytes();
assert!(
(499..=501).contains(&after_refund),
"refund must pay debt FIRST; expected ~500, got {after_refund}",
);
bb.refund(500);
let after_big_refund = bb.available_bytes();
assert!(
(699..=701).contains(&after_big_refund),
"second refund must credit overflow after debt; expected ~700, got {after_big_refund}",
);
}
#[test]
fn realtime_denies_oversized_request_to_prevent_drain_in_one_shot() {
let (mut bb, base) = fresh(1000);
assert!(
!bb.try_consume_with_class(750, BandwidthClass::Realtime, base, 0.3),
"oversized Realtime request must be denied",
);
assert_eq!(bb.available_bytes(), 1000);
assert!(bb.try_consume_with_class(500, BandwidthClass::Realtime, base, 0.3));
}
#[test]
fn d4_background_starve_hatch_one_shot_admit() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 10_000, base)
.with_background_starve_window(Duration::from_millis(100));
assert!(bb.try_consume_with_class(10_000, BandwidthClass::Foreground, base, 0.3,));
assert!(!bb.try_consume_with_class(500, BandwidthClass::Background, at(base, 50), 0.3,));
assert!(bb.try_consume_with_class(500, BandwidthClass::Background, at(base, 200), 0.3,));
assert!(!bb.try_consume_with_class(500, BandwidthClass::Background, at(base, 201), 0.3,));
}
#[test]
fn d4_starve_timer_resets_on_successful_gated_admission() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 1000, base)
.with_background_starve_window(Duration::from_millis(100));
assert!(bb.try_consume_with_class(200, BandwidthClass::Background, base, 0.3,));
assert!(bb.try_consume_with_class(800, BandwidthClass::Foreground, at(base, 1), 0.3,));
assert!(!bb.try_consume_with_class(200, BandwidthClass::Background, at(base, 50), 0.3,));
}
#[test]
fn legacy_try_consume_unchanged_behavior() {
let (mut bb, base) = fresh(1000);
assert!(bb.try_consume(500, base));
assert!(bb.try_consume(500, base));
assert!(!bb.try_consume(1, base));
}
#[test]
fn d4_hatch_denies_oversized_background_request() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 10_000, base)
.with_background_starve_window(Duration::from_millis(50));
assert!(bb.try_consume_with_class(10_000, BandwidthClass::Foreground, base, 0.3));
let later = at(base, 100);
assert!(
!bb.try_consume_with_class(5_000, BandwidthClass::Background, later, 0.3),
"oversized Background hatch attempt (5 KB > 10% × 10 KB) must be denied"
);
assert!(
bb.try_consume_with_class(500, BandwidthClass::Background, later, 0.3),
"right-sized Background request under hatch cap must be admitted"
);
}
#[test]
fn d4_hatch_admits_within_cap() {
let base = Instant::now();
let mut bb = BandwidthBudget::new(1.0, 10_000, base)
.with_background_starve_window(Duration::from_millis(50));
assert!(bb.try_consume_with_class(10_000, BandwidthClass::Foreground, base, 0.3));
let later = at(base, 100);
assert!(bb.try_consume_with_class(50, BandwidthClass::Background, later, 0.3));
assert!(bb.available_bytes() <= 10_000);
}
}