Skip to main content

etl_core/
backpressure.rs

1//! Backpressure: the global in-flight byte budget and the watermark
2//! pause/resume controller with hysteresis.
3//!
4//! Invariant (see `docs/DESIGN.md` § Backpressure): source threads never
5//! block on sends. When a `try_send` is rejected or the in-flight budget
6//! crosses its high watermark, the poll loop pauses its source lanes and
7//! *keeps polling*; it resumes only under hysteresis — usage back below the
8//! low watermark, downstream queues drained, and a minimum pause elapsed —
9//! so pause/resume cannot flap faster than once per `min_pause`.
10//!
11//! Everything here is synchronous and tokio-free: pipeline threads call it
12//! on every poll iteration, and the [`InflightBudget`] atomics are modeled
13//! under [loom](https://docs.rs/loom). Run the loom suite with:
14//!
15//! ```text
16//! RUSTFLAGS="--cfg loom" cargo test -p etl-core --release backpressure::loom_tests
17//! ```
18//!
19//! # Poll-loop integration
20//!
21//! ```
22//! use etl_core::backpressure::{
23//!     BackpressureParams, InflightBudget, Transition, WatermarkController,
24//! };
25//! use std::sync::Arc;
26//! use std::time::Duration;
27//!
28//! let budget = Arc::new(InflightBudget::new());
29//! let params = BackpressureParams::from_budget(
30//!     256 * 1024 * 1024, // max in-flight bytes
31//!     0.8,               // pause at 80%
32//!     0.5,               // resume below 50%
33//!     Duration::from_millis(500),
34//! );
35//! let mut controller = WatermarkController::new(params);
36//!
37//! // Inside the poll loop:
38//! // - when a try_send to a shard queue is rejected:
39//! //     controller.on_send_rejected();
40//! //     (stash the undeliverable record; NEVER block)
41//! // - once per iteration:
42//! let queues_below_low = true; // driver-provided: all shard queues < 50% full
43//! match controller.tick(&budget, queues_below_low) {
44//!     Some(Transition::Pause) => { /* source.pause(&owned_lanes) */ }
45//!     Some(Transition::Resume) => { /* source.resume(&owned_lanes) */ }
46//!     None => {}
47//! }
48//! ```
49
50use 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/// Global in-flight byte budget, shared by pipeline threads (which add on
58/// enqueue to sink queues) and sink workers (which subtract when a batch is
59/// acknowledged or abandoned).
60///
61/// This is a heuristic gauge, not a synchronization point: decisions taken
62/// on a slightly stale reading are corrected on the next poll iteration and
63/// absorbed by the controller's hysteresis, so all operations use
64/// [`Ordering::Relaxed`]. Atomic read-modify-write operations cannot lose
65/// updates even under `Relaxed` (every RMW observes the latest value in the
66/// modification order); relaxation only permits *stale reads* in
67/// [`InflightBudget::usage`], which the hysteresis absorbs. Both directions
68/// saturate — `sub` can never underflow past zero even if an
69/// acknowledgement races ahead of the bookkeeping that added its bytes.
70#[derive(Debug, Default)]
71pub struct InflightBudget {
72    bytes: AtomicUsize,
73}
74
75impl InflightBudget {
76    /// An empty budget. Wrap in an `Arc` to share.
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            bytes: AtomicUsize::new(0),
81        }
82    }
83
84    /// Record `bytes` entering the in-flight window (saturating).
85    pub fn add(&self, bytes: usize) {
86        // `fetch_update` never returns `Err` with an always-`Some` closure.
87        let _ = self
88            .bytes
89            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
90                Some(v.saturating_add(bytes))
91            });
92    }
93
94    /// Record `bytes` leaving the in-flight window (saturating at zero).
95    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    /// Current in-flight bytes (possibly slightly stale under contention).
104    #[must_use]
105    pub fn usage(&self) -> usize {
106        self.bytes.load(Ordering::Relaxed)
107    }
108}
109
110/// Time source for the controller, injectable so hysteresis is testable
111/// without sleeping.
112pub trait Clock {
113    /// Current monotonic instant.
114    fn now(&self) -> Instant;
115}
116
117/// Default [`Clock`] over [`Instant::now`].
118#[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/// Hysteresis parameters for one pipeline's watermark controller.
129///
130/// Struct literals are accepted as-is for config wiring;
131/// [`BackpressureParams::from_budget`] validates its inputs. The controller
132/// assumes `low_bytes <= high_bytes`.
133#[derive(Clone, Copy, Debug, PartialEq, Eq)]
134pub struct BackpressureParams {
135    /// Pause when [`InflightBudget::usage`] reaches this many bytes.
136    pub high_bytes: usize,
137    /// Resume only once usage is at or below this many bytes.
138    pub low_bytes: usize,
139    /// Minimum time to stay paused. Bounds the pause/resume flap rate and
140    /// amortizes the prefetch purge that pausing a source implies (a paused
141    /// Kafka partition drops its prefetched messages and refetches on
142    /// resume — spike-verified).
143    pub min_pause: Duration,
144}
145
146impl BackpressureParams {
147    /// Derive watermarks from a byte budget and ratios.
148    ///
149    /// # Panics
150    ///
151    /// Panics unless `max_inflight_bytes > 0` and
152    /// `0.0 < low_ratio <= high_ratio <= 1.0` — these are programmer
153    /// errors; user-facing validation happens at config load.
154    #[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/// A pause or resume decision for the poll loop to apply to its source
184/// lanes (and mirror into the backpressure metrics).
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub enum Transition {
187    /// Pause the lanes this controller governs; keep polling.
188    Pause,
189    /// Resume the paused lanes.
190    Resume,
191}
192
193#[derive(Clone, Copy, Debug)]
194enum State {
195    Normal,
196    Paused { since: Instant },
197}
198
199/// Per-pipeline-thread pause/resume state machine with hysteresis.
200///
201/// The controller never calls anything: [`WatermarkController::tick`]
202/// returns a [`Transition`] and the driver applies it, which keeps this
203/// module free of source and metrics dependencies. Transitions strictly
204/// alternate (`Pause`, `Resume`, `Pause`, ...) and each full cycle takes at
205/// least [`BackpressureParams::min_pause`].
206#[derive(Debug)]
207pub struct WatermarkController<C: Clock = MonotonicClock> {
208    params: BackpressureParams,
209    state: State,
210    /// A `try_send` rejection observed since the last `tick`.
211    rejected: bool,
212    clock: C,
213}
214
215impl WatermarkController<MonotonicClock> {
216    /// Controller on the real monotonic clock.
217    #[must_use]
218    pub fn new(params: BackpressureParams) -> Self {
219        Self::with_clock(params, MonotonicClock)
220    }
221}
222
223impl<C: Clock> WatermarkController<C> {
224    /// Controller with an injected clock (tests).
225    #[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    /// Record that a `try_send` to a downstream queue was rejected. Cheap;
236    /// call from the poll loop's rejection path. While paused this restarts
237    /// the minimum-pause timer — a rejection is proof downstream is still
238    /// congested.
239    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    /// Evaluate the state machine once per poll iteration.
247    ///
248    /// `queues_below_low` is the driver's view of its downstream queues
249    /// (all below the low-watermark fill ratio). Returns a transition for
250    /// the driver to apply, or `None`.
251    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                    // Consumed: `on_send_rejected` already restarted the
267                    // timer; clear the flag so one rejection is not counted
268                    // against two ticks.
269                    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    /// Whether the controller currently holds its lanes paused.
284    #[must_use]
285    pub fn is_paused(&self) -> bool {
286        matches!(self.state, State::Paused { .. })
287    }
288
289    /// The hysteresis parameters in force.
290    #[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    /// Manual clock: starts at an arbitrary instant, advanced explicitly.
302    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); // 600 > low (500)
391        assert_eq!(ctl.tick(&budget, true), None);
392        budget.sub(200); // 400 <= low
393        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(); // congestion evidence: restart
415        clock.advance(Duration::from_millis(2)); // past original deadline
416        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        // Adversary: rejects the instant we resume, drains immediately
424        // after we pause. Transitions must still alternate and the rate is
425        // bounded by min_pause per full cycle.
426        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; // 5s of virtual time
431        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            /// The budget matches a saturating single-threaded model, and
502            /// transitions strictly alternate starting with Pause.
503            #[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            /// Liveness: whatever happened before, once the system drains
530            /// and stays quiet past min_pause, the controller resumes.
531            #[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                // Drain the world and go quiet.
545                budget.sub(budget.usage());
546                let _ = ctl.tick(&budget, true); // consume any pending rejection
547                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    /// Balanced concurrent add/sub from multiple threads never underflows
562    /// and always converges to zero: atomic RMW cannot lose updates, and
563    /// saturation bounds every interleaving.
564    #[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(); // reader interleaves freely
575                        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    /// An unbalanced `sub` racing an `add` saturates at zero rather than
587    /// wrapping.
588    #[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}