use std::time::{Duration, Instant};
#[cfg(loom)]
use loom::sync::atomic::{AtomicUsize, Ordering};
#[cfg(not(loom))]
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(Debug, Default)]
pub struct InflightBudget {
bytes: AtomicUsize,
}
impl InflightBudget {
#[must_use]
pub fn new() -> Self {
Self {
bytes: AtomicUsize::new(0),
}
}
pub fn add(&self, bytes: usize) {
let _ = self
.bytes
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some(v.saturating_add(bytes))
});
}
pub fn sub(&self, bytes: usize) {
let _ = self
.bytes
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
Some(v.saturating_sub(bytes))
});
}
#[must_use]
pub fn usage(&self) -> usize {
self.bytes.load(Ordering::Relaxed)
}
}
pub trait Clock {
fn now(&self) -> Instant;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct MonotonicClock;
impl Clock for MonotonicClock {
#[inline]
fn now(&self) -> Instant {
Instant::now()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BackpressureParams {
pub high_bytes: usize,
pub low_bytes: usize,
pub min_pause: Duration,
}
impl BackpressureParams {
#[must_use]
pub fn from_budget(
max_inflight_bytes: usize,
high_ratio: f64,
low_ratio: f64,
min_pause: Duration,
) -> Self {
assert!(
max_inflight_bytes > 0,
"backpressure budget must be non-zero"
);
assert!(
0.0 < low_ratio && low_ratio <= high_ratio && high_ratio <= 1.0,
"backpressure ratios must satisfy 0 < low ({low_ratio}) <= high ({high_ratio}) <= 1"
);
#[allow(
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_truncation
)]
let scale = |ratio: f64| (max_inflight_bytes as f64 * ratio) as usize;
Self {
high_bytes: scale(high_ratio).max(1),
low_bytes: scale(low_ratio),
min_pause,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Transition {
Pause,
Resume,
}
#[derive(Clone, Copy, Debug)]
enum State {
Normal,
Paused { since: Instant },
}
#[derive(Debug)]
pub struct WatermarkController<C: Clock = MonotonicClock> {
params: BackpressureParams,
state: State,
rejected: bool,
clock: C,
}
impl WatermarkController<MonotonicClock> {
#[must_use]
pub fn new(params: BackpressureParams) -> Self {
Self::with_clock(params, MonotonicClock)
}
}
impl<C: Clock> WatermarkController<C> {
#[must_use]
pub fn with_clock(params: BackpressureParams, clock: C) -> Self {
Self {
params,
state: State::Normal,
rejected: false,
clock,
}
}
pub fn on_send_rejected(&mut self) {
self.rejected = true;
if let State::Paused { since } = &mut self.state {
*since = self.clock.now();
}
}
pub fn tick(&mut self, budget: &InflightBudget, queues_below_low: bool) -> Option<Transition> {
match self.state {
State::Normal => {
if self.rejected || budget.usage() >= self.params.high_bytes {
self.rejected = false;
self.state = State::Paused {
since: self.clock.now(),
};
Some(Transition::Pause)
} else {
None
}
}
State::Paused { since } => {
if self.rejected {
self.rejected = false;
return None;
}
let drained = budget.usage() <= self.params.low_bytes && queues_below_low;
if drained && self.clock.now().duration_since(since) >= self.params.min_pause {
self.state = State::Normal;
Some(Transition::Resume)
} else {
None
}
}
}
}
#[must_use]
pub fn is_paused(&self) -> bool {
matches!(self.state, State::Paused { .. })
}
#[must_use]
pub fn params(&self) -> &BackpressureParams {
&self.params
}
}
#[cfg(all(test, not(loom)))]
mod tests {
use super::*;
use std::cell::Cell;
struct TestClock {
base: Instant,
offset: Cell<Duration>,
}
impl TestClock {
fn new() -> Self {
Self {
base: Instant::now(),
offset: Cell::new(Duration::ZERO),
}
}
fn advance(&self, d: Duration) {
self.offset.set(self.offset.get() + d);
}
}
impl Clock for &TestClock {
fn now(&self) -> Instant {
self.base + self.offset.get()
}
}
const MIN_PAUSE: Duration = Duration::from_millis(500);
fn params() -> BackpressureParams {
BackpressureParams {
high_bytes: 800,
low_bytes: 500,
min_pause: MIN_PAUSE,
}
}
fn setup(clock: &TestClock) -> (WatermarkController<&TestClock>, InflightBudget) {
(
WatermarkController::with_clock(params(), clock),
InflightBudget::new(),
)
}
#[test]
fn budget_saturates_both_directions() {
let b = InflightBudget::new();
b.sub(100);
assert_eq!(b.usage(), 0, "sub never underflows");
b.add(usize::MAX);
b.add(100);
assert_eq!(b.usage(), usize::MAX, "add saturates");
b.sub(usize::MAX);
assert_eq!(b.usage(), 0);
}
#[test]
fn rejection_pauses_on_next_tick() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
assert_eq!(ctl.tick(&budget, true), None);
ctl.on_send_rejected();
assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
assert!(ctl.is_paused());
}
#[test]
fn high_watermark_pauses_without_rejection() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
budget.add(800);
assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
}
#[test]
fn no_resume_before_min_pause() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
ctl.on_send_rejected();
ctl.tick(&budget, true);
clock.advance(MIN_PAUSE - Duration::from_millis(1));
assert_eq!(ctl.tick(&budget, true), None, "drained but too early");
}
#[test]
fn no_resume_above_low_watermark() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
budget.add(900);
ctl.tick(&budget, true);
clock.advance(MIN_PAUSE * 2);
budget.sub(300); assert_eq!(ctl.tick(&budget, true), None);
budget.sub(200); assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
}
#[test]
fn no_resume_while_queues_are_full() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
ctl.on_send_rejected();
ctl.tick(&budget, true);
clock.advance(MIN_PAUSE * 2);
assert_eq!(ctl.tick(&budget, false), None);
assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
}
#[test]
fn rejection_while_paused_restarts_the_timer() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
ctl.on_send_rejected();
ctl.tick(&budget, true);
clock.advance(MIN_PAUSE - Duration::from_millis(1));
ctl.on_send_rejected(); clock.advance(Duration::from_millis(2)); assert_eq!(ctl.tick(&budget, true), None);
clock.advance(MIN_PAUSE);
assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
}
#[test]
fn transitions_strictly_alternate_and_cycles_respect_min_pause() {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
let mut transitions = Vec::new();
let step = Duration::from_millis(50);
let total = MIN_PAUSE * 10; let mut elapsed = Duration::ZERO;
while elapsed < total {
if !ctl.is_paused() {
ctl.on_send_rejected();
}
if let Some(t) = ctl.tick(&budget, true) {
transitions.push(t);
}
clock.advance(step);
elapsed += step;
}
for pair in transitions.chunks(2) {
assert_eq!(pair[0], Transition::Pause);
if let Some(second) = pair.get(1) {
assert_eq!(*second, Transition::Resume);
}
}
let cycles = usize::try_from(total.as_millis() / MIN_PAUSE.as_millis()).unwrap();
assert!(
transitions.len() <= 2 * (cycles + 1),
"flapping: {} transitions in {} min_pause windows",
transitions.len(),
cycles
);
assert!(transitions.len() >= 2, "controller wedged");
}
#[test]
fn from_budget_computes_thresholds() {
let p = BackpressureParams::from_budget(1000, 0.8, 0.5, MIN_PAUSE);
assert_eq!(p.high_bytes, 800);
assert_eq!(p.low_bytes, 500);
}
#[test]
#[should_panic(expected = "backpressure ratios")]
fn from_budget_rejects_inverted_ratios() {
let _ = BackpressureParams::from_budget(1000, 0.5, 0.8, MIN_PAUSE);
}
#[test]
#[should_panic(expected = "non-zero")]
fn from_budget_rejects_zero_budget() {
let _ = BackpressureParams::from_budget(0, 0.8, 0.5, MIN_PAUSE);
}
mod properties {
use super::*;
use proptest::prelude::*;
#[derive(Clone, Copy, Debug)]
enum Op {
Add(usize),
Sub(usize),
Reject,
Advance(u64),
Tick,
}
fn op_strategy() -> impl Strategy<Value = Op> {
prop_oneof![
(0usize..2000).prop_map(Op::Add),
(0usize..2000).prop_map(Op::Sub),
Just(Op::Reject),
(1u64..400).prop_map(Op::Advance),
Just(Op::Tick),
]
}
proptest! {
#[test]
fn model_equivalence(ops in proptest::collection::vec(op_strategy(), 1..200)) {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
let mut model: usize = 0;
let mut transitions = Vec::new();
for op in ops {
match op {
Op::Add(n) => { budget.add(n); model = model.saturating_add(n); }
Op::Sub(n) => { budget.sub(n); model = model.saturating_sub(n); }
Op::Reject => ctl.on_send_rejected(),
Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
Op::Tick => {
if let Some(t) = ctl.tick(&budget, true) {
transitions.push(t);
}
}
}
prop_assert_eq!(budget.usage(), model);
}
for (i, t) in transitions.iter().enumerate() {
let expected = if i % 2 == 0 { Transition::Pause } else { Transition::Resume };
prop_assert_eq!(*t, expected);
}
}
#[test]
fn eventually_resumes_after_drain(ops in proptest::collection::vec(op_strategy(), 1..200)) {
let clock = TestClock::new();
let (mut ctl, budget) = setup(&clock);
for op in ops {
match op {
Op::Add(n) => budget.add(n),
Op::Sub(n) => budget.sub(n),
Op::Reject => ctl.on_send_rejected(),
Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
Op::Tick => { let _ = ctl.tick(&budget, true); }
}
}
budget.sub(budget.usage());
let _ = ctl.tick(&budget, true); clock.advance(MIN_PAUSE * 2);
let _ = ctl.tick(&budget, true);
prop_assert!(!ctl.is_paused(), "controller wedged in Paused");
}
}
}
}
#[cfg(all(test, loom))]
mod loom_tests {
use super::InflightBudget;
use loom::sync::Arc;
use loom::thread;
#[test]
fn balanced_ops_converge_to_zero() {
loom::model(|| {
let budget = Arc::new(InflightBudget::new());
let handles: Vec<_> = [10usize, 25]
.into_iter()
.map(|n| {
let b = Arc::clone(&budget);
thread::spawn(move || {
b.add(n);
let _ = b.usage(); b.sub(n);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(budget.usage(), 0);
});
}
#[test]
fn premature_sub_saturates() {
loom::model(|| {
let budget = Arc::new(InflightBudget::new());
let b = Arc::clone(&budget);
let t = thread::spawn(move || b.sub(40));
budget.add(15);
t.join().unwrap();
assert!(budget.usage() <= 15, "usage bounded by what was added");
});
}
}