Skip to main content

oximedia_distributed/
backpressure.rs

1//! Backpressure and rate-limiting for distributed encoding pipelines.
2//!
3//! Provides token-bucket rate limiting, credit-based flow control, and a
4//! backpressure signal aggregator to prevent upstream producers from
5//! overwhelming downstream consumers.
6
7#![allow(dead_code)]
8
9// ---------------------------------------------------------------------------
10// TokenBucket
11// ---------------------------------------------------------------------------
12
13/// A token-bucket rate limiter.
14///
15/// Tokens accumulate at `refill_rate` tokens per millisecond up to `capacity`.
16/// Each request consumes `tokens` tokens.  Requests that cannot be served
17/// immediately are rejected (non-blocking).
18#[derive(Debug, Clone)]
19pub struct TokenBucket {
20    /// Maximum number of tokens the bucket can hold.
21    pub capacity: f64,
22    /// Token refill rate in tokens per millisecond.
23    pub refill_rate: f64,
24    /// Current number of available tokens.
25    tokens: f64,
26    /// Unix epoch ms of the last refill computation.
27    last_refill_ms: u64,
28}
29
30impl TokenBucket {
31    /// Create a new token bucket, starting full.
32    #[must_use]
33    pub fn new(capacity: f64, refill_rate: f64, now_ms: u64) -> Self {
34        Self {
35            capacity,
36            refill_rate,
37            tokens: capacity,
38            last_refill_ms: now_ms,
39        }
40    }
41
42    /// Refill tokens based on elapsed time since last refill.
43    fn refill(&mut self, now_ms: u64) {
44        if now_ms <= self.last_refill_ms {
45            return;
46        }
47        let elapsed = (now_ms - self.last_refill_ms) as f64;
48        self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.capacity);
49        self.last_refill_ms = now_ms;
50    }
51
52    /// Try to consume `tokens` from the bucket.
53    ///
54    /// Returns `true` if the tokens were available and consumed, `false`
55    /// otherwise (no tokens are consumed on failure).
56    pub fn try_consume(&mut self, tokens: f64, now_ms: u64) -> bool {
57        self.refill(now_ms);
58        if self.tokens >= tokens {
59            self.tokens -= tokens;
60            true
61        } else {
62            false
63        }
64    }
65
66    /// Current available tokens (after refilling to `now_ms`).
67    #[must_use]
68    pub fn available(&mut self, now_ms: u64) -> f64 {
69        self.refill(now_ms);
70        self.tokens
71    }
72
73    /// Returns `true` if the bucket is completely full.
74    #[must_use]
75    pub fn is_full(&mut self, now_ms: u64) -> bool {
76        self.refill(now_ms);
77        (self.tokens - self.capacity).abs() < f64::EPSILON
78    }
79}
80
81// ---------------------------------------------------------------------------
82// CreditAccount
83// ---------------------------------------------------------------------------
84
85/// Credit-based flow control for a single producer-consumer pair.
86///
87/// The consumer grants credits to the producer; the producer sends one unit
88/// of work per credit and decrements the credit balance.
89#[derive(Debug, Clone)]
90pub struct CreditAccount {
91    /// Current credit balance (number of items the producer may send).
92    credits: i64,
93    /// Maximum credits the consumer will grant at once.
94    pub max_credits: i64,
95}
96
97impl CreditAccount {
98    /// Create a new credit account with zero balance.
99    #[must_use]
100    pub fn new(max_credits: i64) -> Self {
101        Self {
102            credits: 0,
103            max_credits,
104        }
105    }
106
107    /// Consumer grants `n` credits (clamped so balance never exceeds `max_credits`).
108    pub fn grant(&mut self, n: i64) {
109        self.credits = (self.credits + n).min(self.max_credits);
110    }
111
112    /// Producer consumes one credit to send one unit of work.
113    ///
114    /// Returns `true` if a credit was available, `false` if the producer
115    /// should pause.
116    pub fn consume(&mut self) -> bool {
117        if self.credits > 0 {
118            self.credits -= 1;
119            true
120        } else {
121            false
122        }
123    }
124
125    /// Current credit balance.
126    #[must_use]
127    pub fn balance(&self) -> i64 {
128        self.credits
129    }
130
131    /// Returns `true` if the producer may send work.
132    #[must_use]
133    pub fn may_send(&self) -> bool {
134        self.credits > 0
135    }
136}
137
138// ---------------------------------------------------------------------------
139// BackpressureSignal
140// ---------------------------------------------------------------------------
141
142/// The current backpressure state of a node.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
144pub enum BackpressureLevel {
145    /// No backpressure; producer may run at full rate.
146    None,
147    /// Mild backpressure; producer should throttle slightly.
148    Low,
149    /// Moderate backpressure; producer should throttle significantly.
150    Medium,
151    /// Severe backpressure; producer should pause.
152    High,
153    /// Critical backpressure; producer must stop immediately.
154    Critical,
155}
156
157impl BackpressureLevel {
158    /// Recommended rate multiplier (fraction of nominal rate to apply).
159    ///
160    /// `1.0` = no throttling; `0.0` = stop completely.
161    #[must_use]
162    pub fn rate_multiplier(&self) -> f64 {
163        match self {
164            Self::None => 1.0,
165            Self::Low => 0.75,
166            Self::Medium => 0.5,
167            Self::High => 0.2,
168            Self::Critical => 0.0,
169        }
170    }
171
172    /// Derive a backpressure level from a queue fill ratio (0.0–1.0).
173    #[must_use]
174    pub fn from_fill_ratio(ratio: f64) -> Self {
175        let ratio = ratio.clamp(0.0, 1.0);
176        if ratio < 0.5 {
177            Self::None
178        } else if ratio < 0.7 {
179            Self::Low
180        } else if ratio < 0.85 {
181            Self::Medium
182        } else if ratio < 0.95 {
183            Self::High
184        } else {
185            Self::Critical
186        }
187    }
188}
189
190// ---------------------------------------------------------------------------
191// NodeBackpressure
192// ---------------------------------------------------------------------------
193
194/// Backpressure signal from a single node.
195#[derive(Debug, Clone)]
196pub struct NodeBackpressure {
197    /// Node identifier.
198    pub node_id: String,
199    /// Current backpressure level.
200    pub level: BackpressureLevel,
201    /// Queue fill ratio that led to this level (0.0–1.0).
202    pub fill_ratio: f64,
203    /// Timestamp when this signal was recorded (Unix epoch ms).
204    pub timestamp_ms: u64,
205}
206
207impl NodeBackpressure {
208    /// Create a node backpressure signal from a fill ratio.
209    #[must_use]
210    pub fn from_fill(node_id: impl Into<String>, fill_ratio: f64, timestamp_ms: u64) -> Self {
211        Self {
212            node_id: node_id.into(),
213            level: BackpressureLevel::from_fill_ratio(fill_ratio),
214            fill_ratio,
215            timestamp_ms,
216        }
217    }
218}
219
220// ---------------------------------------------------------------------------
221// BackpressureAggregator
222// ---------------------------------------------------------------------------
223
224/// Aggregates backpressure signals from multiple nodes and computes a
225/// cluster-wide backpressure recommendation.
226#[derive(Debug, Default)]
227pub struct BackpressureAggregator {
228    signals: Vec<NodeBackpressure>,
229}
230
231impl BackpressureAggregator {
232    /// Create an empty aggregator.
233    #[must_use]
234    pub fn new() -> Self {
235        Self::default()
236    }
237
238    /// Record or update a node's backpressure signal.
239    pub fn update(&mut self, signal: NodeBackpressure) {
240        if let Some(existing) = self
241            .signals
242            .iter_mut()
243            .find(|s| s.node_id == signal.node_id)
244        {
245            *existing = signal;
246        } else {
247            self.signals.push(signal);
248        }
249    }
250
251    /// Remove signals older than `now_ms - ttl_ms`.
252    pub fn evict_stale(&mut self, now_ms: u64, ttl_ms: u64) {
253        let cutoff = now_ms.saturating_sub(ttl_ms);
254        self.signals.retain(|s| s.timestamp_ms >= cutoff);
255    }
256
257    /// The maximum (worst) backpressure level across all nodes.
258    #[must_use]
259    pub fn max_level(&self) -> BackpressureLevel {
260        self.signals
261            .iter()
262            .map(|s| s.level)
263            .max()
264            .unwrap_or(BackpressureLevel::None)
265    }
266
267    /// Mean queue fill ratio across all nodes.
268    #[must_use]
269    pub fn mean_fill_ratio(&self) -> f64 {
270        if self.signals.is_empty() {
271            return 0.0;
272        }
273        self.signals.iter().map(|s| s.fill_ratio).sum::<f64>() / self.signals.len() as f64
274    }
275
276    /// Recommended cluster-wide rate multiplier (minimum across all nodes).
277    #[must_use]
278    pub fn recommended_rate_multiplier(&self) -> f64 {
279        self.signals
280            .iter()
281            .map(|s| s.level.rate_multiplier())
282            .fold(1.0_f64, f64::min)
283    }
284
285    /// Number of nodes reporting at or above `level`.
286    #[must_use]
287    pub fn count_at_or_above(&self, level: BackpressureLevel) -> usize {
288        self.signals.iter().filter(|s| s.level >= level).count()
289    }
290}
291
292// ---------------------------------------------------------------------------
293// Tests
294// ---------------------------------------------------------------------------
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    // ── TokenBucket ──────────────────────────────────────────────────────
301
302    #[test]
303    fn test_token_bucket_starts_full() {
304        let mut bucket = TokenBucket::new(100.0, 1.0, 0);
305        assert!((bucket.available(0) - 100.0).abs() < 1e-9);
306    }
307
308    #[test]
309    fn test_token_bucket_consume_success() {
310        let mut bucket = TokenBucket::new(100.0, 1.0, 0);
311        assert!(bucket.try_consume(50.0, 0));
312        assert!((bucket.available(0) - 50.0).abs() < 1e-9);
313    }
314
315    #[test]
316    fn test_token_bucket_consume_fail_insufficient() {
317        let mut bucket = TokenBucket::new(10.0, 0.0, 0);
318        assert!(!bucket.try_consume(20.0, 0));
319        // Tokens unchanged
320        assert!((bucket.available(0) - 10.0).abs() < 1e-9);
321    }
322
323    #[test]
324    fn test_token_bucket_refills_over_time() {
325        let mut bucket = TokenBucket::new(100.0, 10.0, 0); // 10 tokens/ms
326        bucket.try_consume(100.0, 0); // drain
327        assert!(bucket.available(0) < 1.0);
328        let avail = bucket.available(5); // 5 ms later → +50 tokens
329        assert!((avail - 50.0).abs() < 1e-6, "avail={avail}");
330    }
331
332    #[test]
333    fn test_token_bucket_does_not_exceed_capacity() {
334        let mut bucket = TokenBucket::new(50.0, 100.0, 0);
335        let avail = bucket.available(1000);
336        assert!((avail - 50.0).abs() < 1e-9, "avail={avail}");
337    }
338
339    #[test]
340    fn test_token_bucket_is_full_initially() {
341        let mut bucket = TokenBucket::new(10.0, 1.0, 0);
342        assert!(bucket.is_full(0));
343    }
344
345    // ── CreditAccount ────────────────────────────────────────────────────
346
347    #[test]
348    fn test_credit_account_starts_at_zero() {
349        let account = CreditAccount::new(100);
350        assert_eq!(account.balance(), 0);
351    }
352
353    #[test]
354    fn test_credit_account_grant_increases_balance() {
355        let mut account = CreditAccount::new(100);
356        account.grant(10);
357        assert_eq!(account.balance(), 10);
358    }
359
360    #[test]
361    fn test_credit_account_grant_capped_at_max() {
362        let mut account = CreditAccount::new(5);
363        account.grant(100);
364        assert_eq!(account.balance(), 5);
365    }
366
367    #[test]
368    fn test_credit_account_consume_success() {
369        let mut account = CreditAccount::new(10);
370        account.grant(3);
371        assert!(account.consume());
372        assert_eq!(account.balance(), 2);
373    }
374
375    #[test]
376    fn test_credit_account_consume_fail_when_empty() {
377        let mut account = CreditAccount::new(10);
378        assert!(!account.consume());
379    }
380
381    #[test]
382    fn test_credit_account_may_send() {
383        let mut account = CreditAccount::new(10);
384        assert!(!account.may_send());
385        account.grant(1);
386        assert!(account.may_send());
387    }
388
389    // ── BackpressureLevel ────────────────────────────────────────────────
390
391    #[test]
392    fn test_backpressure_from_fill_ratio_none() {
393        assert_eq!(
394            BackpressureLevel::from_fill_ratio(0.0),
395            BackpressureLevel::None
396        );
397        assert_eq!(
398            BackpressureLevel::from_fill_ratio(0.49),
399            BackpressureLevel::None
400        );
401    }
402
403    #[test]
404    fn test_backpressure_from_fill_ratio_low() {
405        assert_eq!(
406            BackpressureLevel::from_fill_ratio(0.6),
407            BackpressureLevel::Low
408        );
409    }
410
411    #[test]
412    fn test_backpressure_from_fill_ratio_medium() {
413        assert_eq!(
414            BackpressureLevel::from_fill_ratio(0.75),
415            BackpressureLevel::Medium
416        );
417    }
418
419    #[test]
420    fn test_backpressure_from_fill_ratio_high() {
421        assert_eq!(
422            BackpressureLevel::from_fill_ratio(0.9),
423            BackpressureLevel::High
424        );
425    }
426
427    #[test]
428    fn test_backpressure_from_fill_ratio_critical() {
429        assert_eq!(
430            BackpressureLevel::from_fill_ratio(1.0),
431            BackpressureLevel::Critical
432        );
433    }
434
435    #[test]
436    fn test_backpressure_rate_multiplier_none() {
437        assert!((BackpressureLevel::None.rate_multiplier() - 1.0).abs() < 1e-9);
438    }
439
440    #[test]
441    fn test_backpressure_rate_multiplier_critical() {
442        assert!(BackpressureLevel::Critical.rate_multiplier() < f64::EPSILON);
443    }
444
445    // ── BackpressureAggregator ───────────────────────────────────────────
446
447    #[test]
448    fn test_aggregator_empty_max_level_is_none() {
449        let agg = BackpressureAggregator::new();
450        assert_eq!(agg.max_level(), BackpressureLevel::None);
451    }
452
453    #[test]
454    fn test_aggregator_max_level_selects_worst() {
455        let mut agg = BackpressureAggregator::new();
456        agg.update(NodeBackpressure::from_fill("n0", 0.3, 1000));
457        agg.update(NodeBackpressure::from_fill("n1", 0.9, 1000));
458        assert_eq!(agg.max_level(), BackpressureLevel::High);
459    }
460
461    #[test]
462    fn test_aggregator_mean_fill_ratio() {
463        let mut agg = BackpressureAggregator::new();
464        agg.update(NodeBackpressure::from_fill("n0", 0.4, 1000));
465        agg.update(NodeBackpressure::from_fill("n1", 0.6, 1000));
466        let mean = agg.mean_fill_ratio();
467        assert!((mean - 0.5).abs() < 1e-9, "mean={mean}");
468    }
469
470    #[test]
471    fn test_aggregator_recommended_rate_minimum() {
472        let mut agg = BackpressureAggregator::new();
473        agg.update(NodeBackpressure::from_fill("n0", 0.2, 1000)); // None → 1.0
474        agg.update(NodeBackpressure::from_fill("n1", 0.8, 1000)); // Medium → 0.5
475                                                                  // Minimum is 0.5
476        let rate = agg.recommended_rate_multiplier();
477        assert!((rate - 0.5).abs() < 1e-6, "rate={rate}");
478    }
479
480    #[test]
481    fn test_aggregator_evict_stale() {
482        let mut agg = BackpressureAggregator::new();
483        agg.update(NodeBackpressure::from_fill("n0", 0.5, 100));
484        agg.update(NodeBackpressure::from_fill("n1", 0.5, 5000));
485        agg.evict_stale(5000, 1000); // cutoff = 4000 → n0 removed
486        assert_eq!(agg.count_at_or_above(BackpressureLevel::None), 1);
487    }
488
489    #[test]
490    fn test_aggregator_count_at_or_above() {
491        let mut agg = BackpressureAggregator::new();
492        agg.update(NodeBackpressure::from_fill("n0", 0.2, 1000)); // None
493        agg.update(NodeBackpressure::from_fill("n1", 0.6, 1000)); // Low
494        agg.update(NodeBackpressure::from_fill("n2", 0.9, 1000)); // High
495        assert_eq!(agg.count_at_or_above(BackpressureLevel::Low), 2);
496        assert_eq!(agg.count_at_or_above(BackpressureLevel::High), 1);
497    }
498}