1use std::time::{Duration, Instant};
51
52#[cfg(loom)]
53use loom::sync::atomic::{AtomicUsize, Ordering};
54#[cfg(not(loom))]
55use std::sync::atomic::{AtomicUsize, Ordering};
56
57#[derive(Debug, Default)]
71pub struct InflightBudget {
72 bytes: AtomicUsize,
73}
74
75impl InflightBudget {
76 #[must_use]
78 pub fn new() -> Self {
79 Self {
80 bytes: AtomicUsize::new(0),
81 }
82 }
83
84 pub fn add(&self, bytes: usize) {
86 let _ = self
88 .bytes
89 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
90 Some(v.saturating_add(bytes))
91 });
92 }
93
94 pub fn sub(&self, bytes: usize) {
96 let _ = self
97 .bytes
98 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
99 Some(v.saturating_sub(bytes))
100 });
101 }
102
103 #[must_use]
105 pub fn usage(&self) -> usize {
106 self.bytes.load(Ordering::Relaxed)
107 }
108}
109
110pub trait Clock {
113 fn now(&self) -> Instant;
115}
116
117#[derive(Clone, Copy, Debug, Default)]
119pub struct MonotonicClock;
120
121impl Clock for MonotonicClock {
122 #[inline]
123 fn now(&self) -> Instant {
124 Instant::now()
125 }
126}
127
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub struct BackpressureParams {
135 pub high_bytes: usize,
137 pub low_bytes: usize,
139 pub min_pause: Duration,
144}
145
146impl BackpressureParams {
147 #[must_use]
155 pub fn from_budget(
156 max_inflight_bytes: usize,
157 high_ratio: f64,
158 low_ratio: f64,
159 min_pause: Duration,
160 ) -> Self {
161 assert!(
162 max_inflight_bytes > 0,
163 "backpressure budget must be non-zero"
164 );
165 assert!(
166 0.0 < low_ratio && low_ratio <= high_ratio && high_ratio <= 1.0,
167 "backpressure ratios must satisfy 0 < low ({low_ratio}) <= high ({high_ratio}) <= 1"
168 );
169 #[allow(
170 clippy::cast_precision_loss,
171 clippy::cast_sign_loss,
172 clippy::cast_possible_truncation
173 )]
174 let scale = |ratio: f64| (max_inflight_bytes as f64 * ratio) as usize;
175 Self {
176 high_bytes: scale(high_ratio).max(1),
177 low_bytes: scale(low_ratio),
178 min_pause,
179 }
180 }
181}
182
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub enum Transition {
187 Pause,
189 Resume,
191}
192
193#[derive(Clone, Copy, Debug)]
194enum State {
195 Normal,
196 Paused { since: Instant },
197}
198
199#[derive(Debug)]
207pub struct WatermarkController<C: Clock = MonotonicClock> {
208 params: BackpressureParams,
209 state: State,
210 rejected: bool,
212 clock: C,
213}
214
215impl WatermarkController<MonotonicClock> {
216 #[must_use]
218 pub fn new(params: BackpressureParams) -> Self {
219 Self::with_clock(params, MonotonicClock)
220 }
221}
222
223impl<C: Clock> WatermarkController<C> {
224 #[must_use]
226 pub fn with_clock(params: BackpressureParams, clock: C) -> Self {
227 Self {
228 params,
229 state: State::Normal,
230 rejected: false,
231 clock,
232 }
233 }
234
235 pub fn on_send_rejected(&mut self) {
240 self.rejected = true;
241 if let State::Paused { since } = &mut self.state {
242 *since = self.clock.now();
243 }
244 }
245
246 pub fn tick(&mut self, budget: &InflightBudget, queues_below_low: bool) -> Option<Transition> {
252 match self.state {
253 State::Normal => {
254 if self.rejected || budget.usage() >= self.params.high_bytes {
255 self.rejected = false;
256 self.state = State::Paused {
257 since: self.clock.now(),
258 };
259 Some(Transition::Pause)
260 } else {
261 None
262 }
263 }
264 State::Paused { since } => {
265 if self.rejected {
266 self.rejected = false;
270 return None;
271 }
272 let drained = budget.usage() <= self.params.low_bytes && queues_below_low;
273 if drained && self.clock.now().duration_since(since) >= self.params.min_pause {
274 self.state = State::Normal;
275 Some(Transition::Resume)
276 } else {
277 None
278 }
279 }
280 }
281 }
282
283 #[must_use]
285 pub fn is_paused(&self) -> bool {
286 matches!(self.state, State::Paused { .. })
287 }
288
289 #[must_use]
291 pub fn params(&self) -> &BackpressureParams {
292 &self.params
293 }
294}
295
296#[cfg(all(test, not(loom)))]
297mod tests {
298 use super::*;
299 use std::cell::Cell;
300
301 struct TestClock {
303 base: Instant,
304 offset: Cell<Duration>,
305 }
306
307 impl TestClock {
308 fn new() -> Self {
309 Self {
310 base: Instant::now(),
311 offset: Cell::new(Duration::ZERO),
312 }
313 }
314
315 fn advance(&self, d: Duration) {
316 self.offset.set(self.offset.get() + d);
317 }
318 }
319
320 impl Clock for &TestClock {
321 fn now(&self) -> Instant {
322 self.base + self.offset.get()
323 }
324 }
325
326 const MIN_PAUSE: Duration = Duration::from_millis(500);
327
328 fn params() -> BackpressureParams {
329 BackpressureParams {
330 high_bytes: 800,
331 low_bytes: 500,
332 min_pause: MIN_PAUSE,
333 }
334 }
335
336 fn setup(clock: &TestClock) -> (WatermarkController<&TestClock>, InflightBudget) {
337 (
338 WatermarkController::with_clock(params(), clock),
339 InflightBudget::new(),
340 )
341 }
342
343 #[test]
344 fn budget_saturates_both_directions() {
345 let b = InflightBudget::new();
346 b.sub(100);
347 assert_eq!(b.usage(), 0, "sub never underflows");
348 b.add(usize::MAX);
349 b.add(100);
350 assert_eq!(b.usage(), usize::MAX, "add saturates");
351 b.sub(usize::MAX);
352 assert_eq!(b.usage(), 0);
353 }
354
355 #[test]
356 fn rejection_pauses_on_next_tick() {
357 let clock = TestClock::new();
358 let (mut ctl, budget) = setup(&clock);
359 assert_eq!(ctl.tick(&budget, true), None);
360 ctl.on_send_rejected();
361 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
362 assert!(ctl.is_paused());
363 }
364
365 #[test]
366 fn high_watermark_pauses_without_rejection() {
367 let clock = TestClock::new();
368 let (mut ctl, budget) = setup(&clock);
369 budget.add(800);
370 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
371 }
372
373 #[test]
374 fn no_resume_before_min_pause() {
375 let clock = TestClock::new();
376 let (mut ctl, budget) = setup(&clock);
377 ctl.on_send_rejected();
378 ctl.tick(&budget, true);
379 clock.advance(MIN_PAUSE - Duration::from_millis(1));
380 assert_eq!(ctl.tick(&budget, true), None, "drained but too early");
381 }
382
383 #[test]
384 fn no_resume_above_low_watermark() {
385 let clock = TestClock::new();
386 let (mut ctl, budget) = setup(&clock);
387 budget.add(900);
388 ctl.tick(&budget, true);
389 clock.advance(MIN_PAUSE * 2);
390 budget.sub(300); assert_eq!(ctl.tick(&budget, true), None);
392 budget.sub(200); assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
394 }
395
396 #[test]
397 fn no_resume_while_queues_are_full() {
398 let clock = TestClock::new();
399 let (mut ctl, budget) = setup(&clock);
400 ctl.on_send_rejected();
401 ctl.tick(&budget, true);
402 clock.advance(MIN_PAUSE * 2);
403 assert_eq!(ctl.tick(&budget, false), None);
404 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
405 }
406
407 #[test]
408 fn rejection_while_paused_restarts_the_timer() {
409 let clock = TestClock::new();
410 let (mut ctl, budget) = setup(&clock);
411 ctl.on_send_rejected();
412 ctl.tick(&budget, true);
413 clock.advance(MIN_PAUSE - Duration::from_millis(1));
414 ctl.on_send_rejected(); clock.advance(Duration::from_millis(2)); assert_eq!(ctl.tick(&budget, true), None);
417 clock.advance(MIN_PAUSE);
418 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
419 }
420
421 #[test]
422 fn transitions_strictly_alternate_and_cycles_respect_min_pause() {
423 let clock = TestClock::new();
427 let (mut ctl, budget) = setup(&clock);
428 let mut transitions = Vec::new();
429 let step = Duration::from_millis(50);
430 let total = MIN_PAUSE * 10; let mut elapsed = Duration::ZERO;
432 while elapsed < total {
433 if !ctl.is_paused() {
434 ctl.on_send_rejected();
435 }
436 if let Some(t) = ctl.tick(&budget, true) {
437 transitions.push(t);
438 }
439 clock.advance(step);
440 elapsed += step;
441 }
442 for pair in transitions.chunks(2) {
443 assert_eq!(pair[0], Transition::Pause);
444 if let Some(second) = pair.get(1) {
445 assert_eq!(*second, Transition::Resume);
446 }
447 }
448 let cycles = usize::try_from(total.as_millis() / MIN_PAUSE.as_millis()).unwrap();
449 assert!(
450 transitions.len() <= 2 * (cycles + 1),
451 "flapping: {} transitions in {} min_pause windows",
452 transitions.len(),
453 cycles
454 );
455 assert!(transitions.len() >= 2, "controller wedged");
456 }
457
458 #[test]
459 fn from_budget_computes_thresholds() {
460 let p = BackpressureParams::from_budget(1000, 0.8, 0.5, MIN_PAUSE);
461 assert_eq!(p.high_bytes, 800);
462 assert_eq!(p.low_bytes, 500);
463 }
464
465 #[test]
466 #[should_panic(expected = "backpressure ratios")]
467 fn from_budget_rejects_inverted_ratios() {
468 let _ = BackpressureParams::from_budget(1000, 0.5, 0.8, MIN_PAUSE);
469 }
470
471 #[test]
472 #[should_panic(expected = "non-zero")]
473 fn from_budget_rejects_zero_budget() {
474 let _ = BackpressureParams::from_budget(0, 0.8, 0.5, MIN_PAUSE);
475 }
476
477 mod properties {
478 use super::*;
479 use proptest::prelude::*;
480
481 #[derive(Clone, Copy, Debug)]
482 enum Op {
483 Add(usize),
484 Sub(usize),
485 Reject,
486 Advance(u64),
487 Tick,
488 }
489
490 fn op_strategy() -> impl Strategy<Value = Op> {
491 prop_oneof![
492 (0usize..2000).prop_map(Op::Add),
493 (0usize..2000).prop_map(Op::Sub),
494 Just(Op::Reject),
495 (1u64..400).prop_map(Op::Advance),
496 Just(Op::Tick),
497 ]
498 }
499
500 proptest! {
501 #[test]
504 fn model_equivalence(ops in proptest::collection::vec(op_strategy(), 1..200)) {
505 let clock = TestClock::new();
506 let (mut ctl, budget) = setup(&clock);
507 let mut model: usize = 0;
508 let mut transitions = Vec::new();
509 for op in ops {
510 match op {
511 Op::Add(n) => { budget.add(n); model = model.saturating_add(n); }
512 Op::Sub(n) => { budget.sub(n); model = model.saturating_sub(n); }
513 Op::Reject => ctl.on_send_rejected(),
514 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
515 Op::Tick => {
516 if let Some(t) = ctl.tick(&budget, true) {
517 transitions.push(t);
518 }
519 }
520 }
521 prop_assert_eq!(budget.usage(), model);
522 }
523 for (i, t) in transitions.iter().enumerate() {
524 let expected = if i % 2 == 0 { Transition::Pause } else { Transition::Resume };
525 prop_assert_eq!(*t, expected);
526 }
527 }
528
529 #[test]
532 fn eventually_resumes_after_drain(ops in proptest::collection::vec(op_strategy(), 1..200)) {
533 let clock = TestClock::new();
534 let (mut ctl, budget) = setup(&clock);
535 for op in ops {
536 match op {
537 Op::Add(n) => budget.add(n),
538 Op::Sub(n) => budget.sub(n),
539 Op::Reject => ctl.on_send_rejected(),
540 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
541 Op::Tick => { let _ = ctl.tick(&budget, true); }
542 }
543 }
544 budget.sub(budget.usage());
546 let _ = ctl.tick(&budget, true); clock.advance(MIN_PAUSE * 2);
548 let _ = ctl.tick(&budget, true);
549 prop_assert!(!ctl.is_paused(), "controller wedged in Paused");
550 }
551 }
552 }
553}
554
555#[cfg(all(test, loom))]
556mod loom_tests {
557 use super::InflightBudget;
558 use loom::sync::Arc;
559 use loom::thread;
560
561 #[test]
565 fn balanced_ops_converge_to_zero() {
566 loom::model(|| {
567 let budget = Arc::new(InflightBudget::new());
568 let handles: Vec<_> = [10usize, 25]
569 .into_iter()
570 .map(|n| {
571 let b = Arc::clone(&budget);
572 thread::spawn(move || {
573 b.add(n);
574 let _ = b.usage(); b.sub(n);
576 })
577 })
578 .collect();
579 for h in handles {
580 h.join().unwrap();
581 }
582 assert_eq!(budget.usage(), 0);
583 });
584 }
585
586 #[test]
589 fn premature_sub_saturates() {
590 loom::model(|| {
591 let budget = Arc::new(InflightBudget::new());
592 let b = Arc::clone(&budget);
593 let t = thread::spawn(move || b.sub(40));
594 budget.add(15);
595 t.join().unwrap();
596 assert!(budget.usage() <= 15, "usage bounded by what was added");
597 });
598 }
599}