1use crate::clock::{Clock, SystemClock};
51use std::time::{Duration, Instant};
52
53#[cfg(loom)]
54use loom::sync::atomic::{AtomicUsize, Ordering};
55#[cfg(not(loom))]
56use std::sync::atomic::{AtomicUsize, Ordering};
57
58#[derive(Debug, Default)]
72pub struct InflightBudget {
73 bytes: AtomicUsize,
74}
75
76impl InflightBudget {
77 #[must_use]
79 pub fn new() -> Self {
80 Self {
81 bytes: AtomicUsize::new(0),
82 }
83 }
84
85 pub fn add(&self, bytes: usize) {
87 self.update(|v| v.saturating_add(bytes));
88 }
89
90 pub fn sub(&self, bytes: usize) {
92 self.update(|v| v.saturating_sub(bytes));
93 }
94
95 #[cfg(not(loom))]
96 fn update(&self, f: impl FnMut(usize) -> usize) {
97 self.bytes.update(Ordering::Relaxed, Ordering::Relaxed, f);
98 }
99
100 #[cfg(loom)]
102 fn update(&self, mut f: impl FnMut(usize) -> usize) {
103 let _ = self
104 .bytes
105 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| Some(f(v)));
106 }
107
108 #[must_use]
110 pub fn usage(&self) -> usize {
111 self.bytes.load(Ordering::Relaxed)
112 }
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub struct BackpressureParams {
122 pub high_bytes: usize,
124 pub low_bytes: usize,
126 pub min_pause: Duration,
131}
132
133impl BackpressureParams {
134 #[must_use]
142 pub fn from_budget(
143 max_inflight_bytes: usize,
144 high_ratio: f64,
145 low_ratio: f64,
146 min_pause: Duration,
147 ) -> Self {
148 assert!(
149 max_inflight_bytes > 0,
150 "backpressure budget must be non-zero"
151 );
152 assert!(
153 0.0 < low_ratio && low_ratio <= high_ratio && high_ratio <= 1.0,
154 "backpressure ratios must satisfy 0 < low ({low_ratio}) <= high ({high_ratio}) <= 1"
155 );
156 #[allow(
157 clippy::cast_precision_loss,
158 clippy::cast_sign_loss,
159 clippy::cast_possible_truncation
160 )]
161 let scale = |ratio: f64| (max_inflight_bytes as f64 * ratio) as usize;
162 Self {
163 high_bytes: scale(high_ratio).max(1),
164 low_bytes: scale(low_ratio),
165 min_pause,
166 }
167 }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Transition {
174 Pause,
176 Resume,
178}
179
180#[derive(Clone, Copy, Debug)]
181enum State {
182 Normal,
183 Paused { since: Instant },
184}
185
186#[derive(Debug)]
193pub struct WatermarkController<C: Clock = SystemClock> {
194 params: BackpressureParams,
195 state: State,
196 rejected: bool,
198 clock: C,
199}
200
201impl WatermarkController<SystemClock> {
202 #[must_use]
204 pub fn new(params: BackpressureParams) -> Self {
205 Self::with_clock(params, SystemClock)
206 }
207}
208
209impl<C: Clock> WatermarkController<C> {
210 #[must_use]
212 pub fn with_clock(params: BackpressureParams, clock: C) -> Self {
213 Self {
214 params,
215 state: State::Normal,
216 rejected: false,
217 clock,
218 }
219 }
220
221 pub fn on_send_rejected(&mut self) {
225 self.rejected = true;
226 if let State::Paused { since } = &mut self.state {
227 *since = self.clock.now();
228 }
229 }
230
231 pub fn tick(&mut self, budget: &InflightBudget, queues_below_low: bool) -> Option<Transition> {
237 match self.state {
238 State::Normal => {
239 if self.rejected || budget.usage() >= self.params.high_bytes {
240 self.rejected = false;
241 self.state = State::Paused {
242 since: self.clock.now(),
243 };
244 Some(Transition::Pause)
245 } else {
246 None
247 }
248 }
249 State::Paused { since } => {
250 if self.rejected {
251 self.rejected = false;
255 return None;
256 }
257 let drained = budget.usage() <= self.params.low_bytes && queues_below_low;
258 if drained && self.clock.now().duration_since(since) >= self.params.min_pause {
259 self.state = State::Normal;
260 Some(Transition::Resume)
261 } else {
262 None
263 }
264 }
265 }
266 }
267
268 #[must_use]
270 pub fn is_paused(&self) -> bool {
271 matches!(self.state, State::Paused { .. })
272 }
273
274 #[must_use]
276 pub fn params(&self) -> &BackpressureParams {
277 &self.params
278 }
279}
280
281#[cfg(all(test, not(loom)))]
282mod tests {
283 use super::*;
284 use crate::clock::TestClock;
285
286 const MIN_PAUSE: Duration = Duration::from_millis(500);
287
288 fn params() -> BackpressureParams {
289 BackpressureParams {
290 high_bytes: 800,
291 low_bytes: 500,
292 min_pause: MIN_PAUSE,
293 }
294 }
295
296 fn setup(clock: &TestClock) -> (WatermarkController<&TestClock>, InflightBudget) {
297 (
298 WatermarkController::with_clock(params(), clock),
299 InflightBudget::new(),
300 )
301 }
302
303 #[test]
304 fn budget_saturates_both_directions() {
305 let b = InflightBudget::new();
306 b.sub(100);
307 assert_eq!(b.usage(), 0, "sub never underflows");
308 b.add(usize::MAX);
309 b.add(100);
310 assert_eq!(b.usage(), usize::MAX, "add saturates");
311 b.sub(usize::MAX);
312 assert_eq!(b.usage(), 0);
313 }
314
315 #[test]
316 fn rejection_pauses_on_next_tick() {
317 let clock = TestClock::frozen();
318 let (mut ctl, budget) = setup(&clock);
319 assert_eq!(ctl.tick(&budget, true), None);
320 ctl.on_send_rejected();
321 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
322 assert!(ctl.is_paused());
323 }
324
325 #[test]
326 fn high_watermark_pauses_without_rejection() {
327 let clock = TestClock::frozen();
328 let (mut ctl, budget) = setup(&clock);
329 budget.add(800);
330 assert_eq!(ctl.tick(&budget, true), Some(Transition::Pause));
331 }
332
333 #[test]
334 fn no_resume_before_min_pause() {
335 let clock = TestClock::frozen();
336 let (mut ctl, budget) = setup(&clock);
337 ctl.on_send_rejected();
338 ctl.tick(&budget, true);
339 clock.advance(MIN_PAUSE - Duration::from_millis(1));
340 assert_eq!(ctl.tick(&budget, true), None, "drained but too early");
341 }
342
343 #[test]
344 fn no_resume_above_low_watermark() {
345 let clock = TestClock::frozen();
346 let (mut ctl, budget) = setup(&clock);
347 budget.add(900);
348 ctl.tick(&budget, true);
349 clock.advance(MIN_PAUSE * 2);
350 budget.sub(300); assert_eq!(ctl.tick(&budget, true), None);
352 budget.sub(200); assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
354 }
355
356 #[test]
357 fn no_resume_while_queues_are_full() {
358 let clock = TestClock::frozen();
359 let (mut ctl, budget) = setup(&clock);
360 ctl.on_send_rejected();
361 ctl.tick(&budget, true);
362 clock.advance(MIN_PAUSE * 2);
363 assert_eq!(ctl.tick(&budget, false), None);
364 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
365 }
366
367 #[test]
368 fn rejection_while_paused_restarts_the_timer() {
369 let clock = TestClock::frozen();
370 let (mut ctl, budget) = setup(&clock);
371 ctl.on_send_rejected();
372 ctl.tick(&budget, true);
373 clock.advance(MIN_PAUSE - Duration::from_millis(1));
374 ctl.on_send_rejected(); clock.advance(Duration::from_millis(2)); assert_eq!(ctl.tick(&budget, true), None);
377 clock.advance(MIN_PAUSE);
378 assert_eq!(ctl.tick(&budget, true), Some(Transition::Resume));
379 }
380
381 #[test]
382 fn transitions_strictly_alternate_and_cycles_respect_min_pause() {
383 let clock = TestClock::frozen();
387 let (mut ctl, budget) = setup(&clock);
388 let mut transitions = Vec::new();
389 let step = Duration::from_millis(50);
390 let total = MIN_PAUSE * 10; let mut elapsed = Duration::ZERO;
392 while elapsed < total {
393 if !ctl.is_paused() {
394 ctl.on_send_rejected();
395 }
396 if let Some(t) = ctl.tick(&budget, true) {
397 transitions.push(t);
398 }
399 clock.advance(step);
400 elapsed += step;
401 }
402 for pair in transitions.chunks(2) {
403 assert_eq!(pair[0], Transition::Pause);
404 if let Some(second) = pair.get(1) {
405 assert_eq!(*second, Transition::Resume);
406 }
407 }
408 let cycles = usize::try_from(total.as_millis() / MIN_PAUSE.as_millis()).unwrap();
409 assert!(
410 transitions.len() <= 2 * (cycles + 1),
411 "flapping: {} transitions in {} min_pause windows",
412 transitions.len(),
413 cycles
414 );
415 assert!(transitions.len() >= 2, "controller wedged");
416 }
417
418 #[test]
419 fn from_budget_computes_thresholds() {
420 let p = BackpressureParams::from_budget(1000, 0.8, 0.5, MIN_PAUSE);
421 assert_eq!(p.high_bytes, 800);
422 assert_eq!(p.low_bytes, 500);
423 }
424
425 #[test]
426 #[should_panic(expected = "backpressure ratios")]
427 fn from_budget_rejects_inverted_ratios() {
428 let _ = BackpressureParams::from_budget(1000, 0.5, 0.8, MIN_PAUSE);
429 }
430
431 #[test]
432 #[should_panic(expected = "non-zero")]
433 fn from_budget_rejects_zero_budget() {
434 let _ = BackpressureParams::from_budget(0, 0.8, 0.5, MIN_PAUSE);
435 }
436
437 mod properties {
438 use super::*;
439 use proptest::prelude::*;
440
441 #[derive(Clone, Copy, Debug)]
442 enum Op {
443 Add(usize),
444 Sub(usize),
445 Reject,
446 Advance(u64),
447 Tick,
448 }
449
450 fn op_strategy() -> impl Strategy<Value = Op> {
451 prop_oneof![
452 (0usize..2000).prop_map(Op::Add),
453 (0usize..2000).prop_map(Op::Sub),
454 Just(Op::Reject),
455 (1u64..400).prop_map(Op::Advance),
456 Just(Op::Tick),
457 ]
458 }
459
460 proptest! {
461 #[test]
464 fn model_equivalence(ops in proptest::collection::vec(op_strategy(), 1..200)) {
465 let clock = TestClock::frozen();
466 let (mut ctl, budget) = setup(&clock);
467 let mut model: usize = 0;
468 let mut transitions = Vec::new();
469 for op in ops {
470 match op {
471 Op::Add(n) => { budget.add(n); model = model.saturating_add(n); }
472 Op::Sub(n) => { budget.sub(n); model = model.saturating_sub(n); }
473 Op::Reject => ctl.on_send_rejected(),
474 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
475 Op::Tick => {
476 if let Some(t) = ctl.tick(&budget, true) {
477 transitions.push(t);
478 }
479 }
480 }
481 prop_assert_eq!(budget.usage(), model);
482 }
483 for (i, t) in transitions.iter().enumerate() {
484 let expected = if i % 2 == 0 { Transition::Pause } else { Transition::Resume };
485 prop_assert_eq!(*t, expected);
486 }
487 }
488
489 #[test]
492 fn eventually_resumes_after_drain(ops in proptest::collection::vec(op_strategy(), 1..200)) {
493 let clock = TestClock::frozen();
494 let (mut ctl, budget) = setup(&clock);
495 for op in ops {
496 match op {
497 Op::Add(n) => budget.add(n),
498 Op::Sub(n) => budget.sub(n),
499 Op::Reject => ctl.on_send_rejected(),
500 Op::Advance(ms) => clock.advance(Duration::from_millis(ms)),
501 Op::Tick => { let _ = ctl.tick(&budget, true); }
502 }
503 }
504 budget.sub(budget.usage());
506 let _ = ctl.tick(&budget, true); clock.advance(MIN_PAUSE * 2);
508 let _ = ctl.tick(&budget, true);
509 prop_assert!(!ctl.is_paused(), "controller wedged in Paused");
510 }
511 }
512 }
513}
514
515#[cfg(all(test, loom))]
516mod loom_tests {
517 use super::InflightBudget;
518 use loom::sync::Arc;
519 use loom::thread;
520
521 #[test]
525 fn balanced_ops_converge_to_zero() {
526 loom::model(|| {
527 let budget = Arc::new(InflightBudget::new());
528 let handles: Vec<_> = [10usize, 25]
529 .into_iter()
530 .map(|n| {
531 let b = Arc::clone(&budget);
532 thread::spawn(move || {
533 b.add(n);
534 let _ = b.usage(); b.sub(n);
536 })
537 })
538 .collect();
539 for h in handles {
540 h.join().unwrap();
541 }
542 assert_eq!(budget.usage(), 0);
543 });
544 }
545
546 #[test]
549 fn premature_sub_saturates() {
550 loom::model(|| {
551 let budget = Arc::new(InflightBudget::new());
552 let b = Arc::clone(&budget);
553 let t = thread::spawn(move || b.sub(40));
554 budget.add(15);
555 t.join().unwrap();
556 assert!(budget.usage() <= 15, "usage bounded by what was added");
557 });
558 }
559}