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 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` cannot underflow past zero even if an acknowledgment
69/// 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).
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`. User-facing validation of the
153    /// same bounds 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/// [`WatermarkController::tick`] returns a [`Transition`] and the driver
202/// applies it; the controller calls nothing itself. Transitions strictly
203/// alternate (`Pause`, `Resume`, `Pause`, ...) and each full cycle takes at
204/// least [`BackpressureParams::min_pause`].
205#[derive(Debug)]
206pub struct WatermarkController<C: Clock = MonotonicClock> {
207    params: BackpressureParams,
208    state: State,
209    /// A `try_send` rejection observed since the last `tick`.
210    rejected: bool,
211    clock: C,
212}
213
214impl WatermarkController<MonotonicClock> {
215    /// Controller on the real monotonic clock.
216    #[must_use]
217    pub fn new(params: BackpressureParams) -> Self {
218        Self::with_clock(params, MonotonicClock)
219    }
220}
221
222impl<C: Clock> WatermarkController<C> {
223    /// Controller with an injected clock (tests).
224    #[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    /// Record that a `try_send` to a downstream queue was rejected. Cheap;
235    /// call from the poll loop's rejection path. While paused this restarts
236    /// the minimum-pause timer.
237    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    /// Evaluate the state machine once per poll iteration.
245    ///
246    /// `queues_below_low` is the driver's view of its downstream queues
247    /// (all below the low-watermark fill ratio). Returns a transition for
248    /// the driver to apply, or `None`.
249    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                    // `on_send_rejected` already restarted the timer; clear
265                    // the flag so one rejection is not counted against two
266                    // ticks.
267                    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    /// Whether the controller currently holds its lanes paused.
282    #[must_use]
283    pub fn is_paused(&self) -> bool {
284        matches!(self.state, State::Paused { .. })
285    }
286
287    /// The hysteresis parameters in force.
288    #[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    /// Manual clock: starts at an arbitrary instant, advanced explicitly.
300    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); // 600 > low (500)
389        assert_eq!(ctl.tick(&budget, true), None);
390        budget.sub(200); // 400 <= low
391        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(); // congestion evidence: restart
413        clock.advance(Duration::from_millis(2)); // past original deadline
414        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        // Adversary: rejects the instant we resume, drains immediately
422        // after we pause. Transitions must still alternate and the rate is
423        // bounded by min_pause per full cycle.
424        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; // 5s of virtual time
429        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            /// The budget matches a saturating single-threaded model, and
500            /// transitions strictly alternate starting with Pause.
501            #[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            /// Liveness: whatever happened before, once the system drains
528            /// and stays quiet past min_pause, the controller resumes.
529            #[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                // Drain the world and go quiet.
543                budget.sub(budget.usage());
544                let _ = ctl.tick(&budget, true); // consume any pending rejection
545                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    /// Balanced concurrent add/sub from multiple threads never underflows
560    /// and always converges to zero. Atomic RMW cannot lose updates, and
561    /// saturation bounds every interleaving.
562    #[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(); // reader interleaves freely
573                        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    /// An unbalanced `sub` racing an `add` saturates at zero rather than
585    /// wrapping.
586    #[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}