Skip to main content

spate_core/
backpressure.rs

1//! Backpressure: the global in-flight byte budget and the watermark
2//! pause/resume controller with hysteresis.
3//!
4//! Invariant (INV-2): source threads never block on sends. When a
5//! `try_send` is rejected or the in-flight budget crosses its high
6//! 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 spate-core --release backpressure::loom_tests
17//! ```
18//!
19//! # Poll-loop integration
20//!
21//! ```
22//! use spate_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 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/// Global in-flight byte budget, shared by pipeline threads (which add on
59/// enqueue to sink queues) and sink workers (which subtract when a batch is
60/// acknowledged or abandoned).
61///
62/// This is a heuristic gauge, not a synchronization point. Decisions taken
63/// on a slightly stale reading are corrected on the next poll iteration and
64/// absorbed by the controller's hysteresis, so all operations use
65/// [`Ordering::Relaxed`]. Atomic read-modify-write operations cannot lose
66/// updates even under `Relaxed` (every RMW observes the latest value in the
67/// modification order); relaxation only permits *stale reads* in
68/// [`InflightBudget::usage`], which the hysteresis absorbs. Both directions
69/// saturate. `sub` cannot underflow past zero even if an acknowledgment
70/// races ahead of the bookkeeping that added its bytes.
71#[derive(Debug, Default)]
72pub struct InflightBudget {
73    bytes: AtomicUsize,
74}
75
76impl InflightBudget {
77    /// An empty budget. Wrap in an `Arc` to share.
78    #[must_use]
79    pub fn new() -> Self {
80        Self {
81            bytes: AtomicUsize::new(0),
82        }
83    }
84
85    /// Record `bytes` entering the in-flight window (saturating).
86    pub fn add(&self, bytes: usize) {
87        self.update(|v| v.saturating_add(bytes));
88    }
89
90    /// Record `bytes` leaving the in-flight window (saturating at zero).
91    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    // loom has no `update`.
101    #[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    /// Current in-flight bytes (possibly slightly stale under contention).
109    #[must_use]
110    pub fn usage(&self) -> usize {
111        self.bytes.load(Ordering::Relaxed)
112    }
113}
114
115/// Hysteresis parameters for one pipeline's watermark controller.
116///
117/// Struct literals are accepted as-is for config wiring;
118/// [`BackpressureParams::from_budget`] validates its inputs. The controller
119/// assumes `low_bytes <= high_bytes`.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub struct BackpressureParams {
122    /// Pause when [`InflightBudget::usage`] reaches this many bytes.
123    pub high_bytes: usize,
124    /// Resume only once usage is at or below this many bytes.
125    pub low_bytes: usize,
126    /// Minimum time to stay paused. Bounds the pause/resume flap rate and
127    /// amortizes the prefetch purge that pausing a source implies (a paused
128    /// Kafka partition drops its prefetched messages and refetches on
129    /// resume).
130    pub min_pause: Duration,
131}
132
133impl BackpressureParams {
134    /// Derive watermarks from a byte budget and ratios.
135    ///
136    /// # Panics
137    ///
138    /// Panics unless `max_inflight_bytes > 0` and
139    /// `0.0 < low_ratio <= high_ratio <= 1.0`. User-facing validation of the
140    /// same bounds happens at config load.
141    #[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/// A pause or resume decision for the poll loop to apply to its source
171/// lanes (and mirror into the backpressure metrics).
172#[derive(Clone, Copy, Debug, PartialEq, Eq)]
173pub enum Transition {
174    /// Pause the lanes this controller governs; keep polling.
175    Pause,
176    /// Resume the paused lanes.
177    Resume,
178}
179
180#[derive(Clone, Copy, Debug)]
181enum State {
182    Normal,
183    Paused { since: Instant },
184}
185
186/// Per-pipeline-thread pause/resume state machine with hysteresis.
187///
188/// [`WatermarkController::tick`] returns a [`Transition`] and the driver
189/// applies it; the controller calls nothing itself. Transitions strictly
190/// alternate (`Pause`, `Resume`, `Pause`, ...) and each full cycle takes at
191/// least [`BackpressureParams::min_pause`].
192#[derive(Debug)]
193pub struct WatermarkController<C: Clock = SystemClock> {
194    params: BackpressureParams,
195    state: State,
196    /// A `try_send` rejection observed since the last `tick`.
197    rejected: bool,
198    clock: C,
199}
200
201impl WatermarkController<SystemClock> {
202    /// Controller on the real monotonic clock.
203    #[must_use]
204    pub fn new(params: BackpressureParams) -> Self {
205        Self::with_clock(params, SystemClock)
206    }
207}
208
209impl<C: Clock> WatermarkController<C> {
210    /// Controller on an injected clock.
211    #[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    /// Record that a `try_send` to a downstream queue was rejected. Cheap;
222    /// call from the poll loop's rejection path. While paused this restarts
223    /// the minimum-pause timer.
224    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    /// Evaluate the state machine once per poll iteration.
232    ///
233    /// `queues_below_low` is the driver's view of its downstream queues
234    /// (all below the low-watermark fill ratio). Returns a transition for
235    /// the driver to apply, or `None`.
236    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                    // `on_send_rejected` already restarted the timer; clear
252                    // the flag so one rejection is not counted against two
253                    // ticks.
254                    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    /// Whether the controller currently holds its lanes paused.
269    #[must_use]
270    pub fn is_paused(&self) -> bool {
271        matches!(self.state, State::Paused { .. })
272    }
273
274    /// The hysteresis parameters in force.
275    #[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); // 600 > low (500)
351        assert_eq!(ctl.tick(&budget, true), None);
352        budget.sub(200); // 400 <= low
353        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(); // congestion evidence: restart
375        clock.advance(Duration::from_millis(2)); // past original deadline
376        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        // Adversary: rejects the instant we resume, drains immediately
384        // after we pause. Transitions must still alternate and the rate is
385        // bounded by min_pause per full cycle.
386        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; // 5s of virtual time
391        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            /// The budget matches a saturating single-threaded model, and
462            /// transitions strictly alternate starting with Pause.
463            #[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            /// Liveness: whatever happened before, once the system drains
490            /// and stays quiet past min_pause, the controller resumes.
491            #[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                // Drain the world and go quiet.
505                budget.sub(budget.usage());
506                let _ = ctl.tick(&budget, true); // consume any pending rejection
507                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    /// Balanced concurrent add/sub from multiple threads never underflows
522    /// and always converges to zero. Atomic RMW cannot lose updates, and
523    /// saturation bounds every interleaving.
524    #[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(); // reader interleaves freely
535                        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    /// An unbalanced `sub` racing an `add` saturates at zero rather than
547    /// wrapping.
548    #[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}