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)]
206pub struct WatermarkController<C: Clock = MonotonicClock> {
207 params: BackpressureParams,
208 state: State,
209 rejected: bool,
211 clock: C,
212}
213
214impl WatermarkController<MonotonicClock> {
215 #[must_use]
217 pub fn new(params: BackpressureParams) -> Self {
218 Self::with_clock(params, MonotonicClock)
219 }
220}
221
222impl<C: Clock> WatermarkController<C> {
223 #[must_use]
225 pub fn with_clock(params: BackpressureParams, clock: C) -> Self {
226 Self {
227 params,
228 state: State::Normal,
229 rejected: false,
230 clock,
231 }
232 }
233
234 pub fn on_send_rejected(&mut self) {
238 self.rejected = true;
239 if let State::Paused { since } = &mut self.state {
240 *since = self.clock.now();
241 }
242 }
243
244 pub fn tick(&mut self, budget: &InflightBudget, queues_below_low: bool) -> Option<Transition> {
250 match self.state {
251 State::Normal => {
252 if self.rejected || budget.usage() >= self.params.high_bytes {
253 self.rejected = false;
254 self.state = State::Paused {
255 since: self.clock.now(),
256 };
257 Some(Transition::Pause)
258 } else {
259 None
260 }
261 }
262 State::Paused { since } => {
263 if self.rejected {
264 self.rejected = false;
268 return None;
269 }
270 let drained = budget.usage() <= self.params.low_bytes && queues_below_low;
271 if drained && self.clock.now().duration_since(since) >= self.params.min_pause {
272 self.state = State::Normal;
273 Some(Transition::Resume)
274 } else {
275 None
276 }
277 }
278 }
279 }
280
281 #[must_use]
283 pub fn is_paused(&self) -> bool {
284 matches!(self.state, State::Paused { .. })
285 }
286
287 #[must_use]
289 pub fn params(&self) -> &BackpressureParams {
290 &self.params
291 }
292}
293
294#[cfg(all(test, not(loom)))]
295mod tests {
296 use super::*;
297 use std::cell::Cell;
298
299 struct TestClock {
301 base: Instant,
302 offset: Cell<Duration>,
303 }
304
305 impl TestClock {
306 fn new() -> Self {
307 Self {
308 base: Instant::now(),
309 offset: Cell::new(Duration::ZERO),
310 }
311 }
312
313 fn advance(&self, d: Duration) {
314 self.offset.set(self.offset.get() + d);
315 }
316 }
317
318 impl Clock for &TestClock {
319 fn now(&self) -> Instant {
320 self.base + self.offset.get()
321 }
322 }
323
324 const MIN_PAUSE: Duration = Duration::from_millis(500);
325
326 fn params() -> BackpressureParams {
327 BackpressureParams {
328 high_bytes: 800,
329 low_bytes: 500,
330 min_pause: MIN_PAUSE,
331 }
332 }
333
334 fn setup(clock: &TestClock) -> (WatermarkController<&TestClock>, InflightBudget) {
335 (
336 WatermarkController::with_clock(params(), clock),
337 InflightBudget::new(),
338 )
339 }
340
341 #[test]
342 fn budget_saturates_both_directions() {
343 let b = InflightBudget::new();
344 b.sub(100);
345 assert_eq!(b.usage(), 0, "sub never underflows");
346 b.add(usize::MAX);
347 b.add(100);
348 assert_eq!(b.usage(), usize::MAX, "add saturates");
349 b.sub(usize::MAX);
350 assert_eq!(b.usage(), 0);
351 }
352
353 #[test]
354 fn rejection_pauses_on_next_tick() {
355 let clock = TestClock::new();
356 let (mut ctl, budget) = setup(&clock);
357 assert_eq!(ctl.tick(&budget, true), None);
358 ctl.on_send_rejected();
359 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
360 assert!(ctl.is_paused());
361 }
362
363 #[test]
364 fn high_watermark_pauses_without_rejection() {
365 let clock = TestClock::new();
366 let (mut ctl, budget) = setup(&clock);
367 budget.add(800);
368 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
369 }
370
371 #[test]
372 fn no_resume_before_min_pause() {
373 let clock = TestClock::new();
374 let (mut ctl, budget) = setup(&clock);
375 ctl.on_send_rejected();
376 ctl.tick(&budget, true);
377 clock.advance(MIN_PAUSE - Duration::from_millis(1));
378 assert_eq!(ctl.tick(&budget, true), None, "drained but too early");
379 }
380
381 #[test]
382 fn no_resume_above_low_watermark() {
383 let clock = TestClock::new();
384 let (mut ctl, budget) = setup(&clock);
385 budget.add(900);
386 ctl.tick(&budget, true);
387 clock.advance(MIN_PAUSE * 2);
388 budget.sub(300); assert_eq!(ctl.tick(&budget, true), None);
390 budget.sub(200); assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
392 }
393
394 #[test]
395 fn no_resume_while_queues_are_full() {
396 let clock = TestClock::new();
397 let (mut ctl, budget) = setup(&clock);
398 ctl.on_send_rejected();
399 ctl.tick(&budget, true);
400 clock.advance(MIN_PAUSE * 2);
401 assert_eq!(ctl.tick(&budget, false), None);
402 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
403 }
404
405 #[test]
406 fn rejection_while_paused_restarts_the_timer() {
407 let clock = TestClock::new();
408 let (mut ctl, budget) = setup(&clock);
409 ctl.on_send_rejected();
410 ctl.tick(&budget, true);
411 clock.advance(MIN_PAUSE - Duration::from_millis(1));
412 ctl.on_send_rejected(); clock.advance(Duration::from_millis(2)); assert_eq!(ctl.tick(&budget, true), None);
415 clock.advance(MIN_PAUSE);
416 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
417 }
418
419 #[test]
420 fn transitions_strictly_alternate_and_cycles_respect_min_pause() {
421 let clock = TestClock::new();
425 let (mut ctl, budget) = setup(&clock);
426 let mut transitions = Vec::new();
427 let step = Duration::from_millis(50);
428 let total = MIN_PAUSE * 10; let mut elapsed = Duration::ZERO;
430 while elapsed < total {
431 if !ctl.is_paused() {
432 ctl.on_send_rejected();
433 }
434 if let Some(t) = ctl.tick(&budget, true) {
435 transitions.push(t);
436 }
437 clock.advance(step);
438 elapsed += step;
439 }
440 for pair in transitions.chunks(2) {
441 assert_eq!(pair[0], Transition::Pause);
442 if let Some(second) = pair.get(1) {
443 assert_eq!(*second, Transition::Resume);
444 }
445 }
446 let cycles = usize::try_from(total.as_millis() / MIN_PAUSE.as_millis()).unwrap();
447 assert!(
448 transitions.len() <= 2 * (cycles + 1),
449 "flapping: {} transitions in {} min_pause windows",
450 transitions.len(),
451 cycles
452 );
453 assert!(transitions.len() >= 2, "controller wedged");
454 }
455
456 #[test]
457 fn from_budget_computes_thresholds() {
458 let p = BackpressureParams::from_budget(1000, 0.8, 0.5, MIN_PAUSE);
459 assert_eq!(p.high_bytes, 800);
460 assert_eq!(p.low_bytes, 500);
461 }
462
463 #[test]
464 #[should_panic(expected = "backpressure ratios")]
465 fn from_budget_rejects_inverted_ratios() {
466 let _ = BackpressureParams::from_budget(1000, 0.5, 0.8, MIN_PAUSE);
467 }
468
469 #[test]
470 #[should_panic(expected = "non-zero")]
471 fn from_budget_rejects_zero_budget() {
472 let _ = BackpressureParams::from_budget(0, 0.8, 0.5, MIN_PAUSE);
473 }
474
475 mod properties {
476 use super::*;
477 use proptest::prelude::*;
478
479 #[derive(Clone, Copy, Debug)]
480 enum Op {
481 Add(usize),
482 Sub(usize),
483 Reject,
484 Advance(u64),
485 Tick,
486 }
487
488 fn op_strategy() -> impl Strategy<Value = Op> {
489 prop_oneof![
490 (0usize..2000).prop_map(Op::Add),
491 (0usize..2000).prop_map(Op::Sub),
492 Just(Op::Reject),
493 (1u64..400).prop_map(Op::Advance),
494 Just(Op::Tick),
495 ]
496 }
497
498 proptest! {
499 #[test]
502 fn model_equivalence(ops in proptest::collection::vec(op_strategy(), 1..200)) {
503 let clock = TestClock::new();
504 let (mut ctl, budget) = setup(&clock);
505 let mut model: usize = 0;
506 let mut transitions = Vec::new();
507 for op in ops {
508 match op {
509 Op::Add(n) => { budget.add(n); model = model.saturating_add(n); }
510 Op::Sub(n) => { budget.sub(n); model = model.saturating_sub(n); }
511 Op::Reject => ctl.on_send_rejected(),
512 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
513 Op::Tick => {
514 if let Some(t) = ctl.tick(&budget, true) {
515 transitions.push(t);
516 }
517 }
518 }
519 prop_assert_eq!(budget.usage(), model);
520 }
521 for (i, t) in transitions.iter().enumerate() {
522 let expected = if i % 2 == 0 { Transition::Pause } else { Transition::Resume };
523 prop_assert_eq!(*t, expected);
524 }
525 }
526
527 #[test]
530 fn eventually_resumes_after_drain(ops in proptest::collection::vec(op_strategy(), 1..200)) {
531 let clock = TestClock::new();
532 let (mut ctl, budget) = setup(&clock);
533 for op in ops {
534 match op {
535 Op::Add(n) => budget.add(n),
536 Op::Sub(n) => budget.sub(n),
537 Op::Reject => ctl.on_send_rejected(),
538 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
539 Op::Tick => { let _ = ctl.tick(&budget, true); }
540 }
541 }
542 budget.sub(budget.usage());
544 let _ = ctl.tick(&budget, true); clock.advance(MIN_PAUSE * 2);
546 let _ = ctl.tick(&budget, true);
547 prop_assert!(!ctl.is_paused(), "controller wedged in Paused");
548 }
549 }
550 }
551}
552
553#[cfg(all(test, loom))]
554mod loom_tests {
555 use super::InflightBudget;
556 use loom::sync::Arc;
557 use loom::thread;
558
559 #[test]
563 fn balanced_ops_converge_to_zero() {
564 loom::model(|| {
565 let budget = Arc::new(InflightBudget::new());
566 let handles: Vec<_> = [10usize, 25]
567 .into_iter()
568 .map(|n| {
569 let b = Arc::clone(&budget);
570 thread::spawn(move || {
571 b.add(n);
572 let _ = b.usage(); b.sub(n);
574 })
575 })
576 .collect();
577 for h in handles {
578 h.join().unwrap();
579 }
580 assert_eq!(budget.usage(), 0);
581 });
582 }
583
584 #[test]
587 fn premature_sub_saturates() {
588 loom::model(|| {
589 let budget = Arc::new(InflightBudget::new());
590 let b = Arc::clone(&budget);
591 let t = thread::spawn(move || b.sub(40));
592 budget.add(15);
593 t.join().unwrap();
594 assert!(budget.usage() <= 15, "usage bounded by what was added");
595 });
596 }
597}