Skip to main content

gossan_engine/
rate.rs

1//! Token-bucket rate limiter with sub-microsecond precision.
2//!
3//! Controls packet transmission rate to avoid overwhelming the NIC or
4//! triggering upstream rate limits. Uses `Instant` for high-resolution
5//! timing without syscall overhead.
6
7use std::time::Instant;
8
9/// Token-bucket rate limiter.
10///
11/// Refills at `rate_pps` tokens per second. Each `consume()` call
12/// blocks until a token is available, providing smooth rate control.
13///
14/// Internal scaling: we represent both the bucket and the refill in
15/// units of `1 token = SCALE`. Since refill comes in tokens-per-μs
16/// (= rate_pps / 1_000_000), we pick `SCALE = 1_000_000` so the
17/// per-μs refill in scaled units equals `rate_pps` exactly — no
18/// truncation for rates below 1000 pps, no 1000× overshoot for
19/// rates at or above 1000 pps. Earlier scaling used `SCALE = 1000`
20/// and stored `rate_pps` in `refill_per_us_x1000` directly, which
21/// was 1000× too fast at every rate ≥ 1000 pps.
22pub struct RateLimiter {
23    /// Tokens available (scaled by SCALE = 1_000_000 for sub-token precision).
24    tokens_x1m: i64,
25    /// Maximum tokens (burst capacity, in scaled units).
26    max_tokens_x1m: i64,
27    /// Tokens added per microsecond (scaled by SCALE).
28    refill_per_us_x1m: i64,
29    /// Last refill time.
30    last_refill: Instant,
31    /// Target rate in packets per second.
32    rate_pps: u64,
33}
34
35const TOKEN_SCALE: i64 = 1_000_000;
36
37impl RateLimiter {
38    /// Create a new rate limiter.
39    ///
40    /// - `rate_pps`: target packets per second (0 = unlimited)
41    /// - `burst`: maximum burst size in packets
42    #[must_use]
43    pub fn new(rate_pps: u64, burst: u64) -> Self {
44        let burst = burst.max(1).min(i64::MAX as u64 / TOKEN_SCALE as u64);
45        let refill_per_us_x1m = if rate_pps == 0 {
46            i64::MAX / 2 // Effectively unlimited
47        } else {
48            // rate_pps tokens/sec = rate_pps / 1_000_000 tokens/μs.
49            // In SCALE = 1_000_000 units that is exactly rate_pps.
50            (rate_pps as i64).max(1)
51        };
52
53        Self {
54            tokens_x1m: (burst as i64) * TOKEN_SCALE,
55            max_tokens_x1m: (burst as i64) * TOKEN_SCALE,
56            refill_per_us_x1m,
57            last_refill: Instant::now(),
58            rate_pps,
59        }
60    }
61
62    /// Create an unlimited rate limiter (no throttling).
63    #[must_use]
64    pub fn unlimited() -> Self {
65        Self::new(0, u64::MAX / 2)
66    }
67
68    /// Try to consume one token. Returns `true` if the token was available.
69    /// Does NOT block.
70    pub fn try_consume(&mut self) -> bool {
71        self.refill();
72        if self.tokens_x1m >= TOKEN_SCALE {
73            self.tokens_x1m -= TOKEN_SCALE;
74            true
75        } else {
76            false
77        }
78    }
79
80    /// Try to consume `n` tokens. Returns the number actually consumed.
81    pub fn try_consume_batch(&mut self, n: u64) -> u64 {
82        self.refill();
83        let available = (self.tokens_x1m / TOKEN_SCALE).max(0) as u64;
84        let consumed = available.min(n);
85        self.tokens_x1m -= (consumed as i64) * TOKEN_SCALE;
86        consumed
87    }
88
89    /// Block until a token is available, then consume it.
90    ///
91    /// Uses spin-wait for sub-microsecond precision when the wait is short,
92    /// and `thread::yield_now` for longer waits.
93    pub fn consume_blocking(&mut self) {
94        loop {
95            self.refill();
96            if self.tokens_x1m >= TOKEN_SCALE {
97                self.tokens_x1m -= TOKEN_SCALE;
98                return;
99            }
100            let deficit = TOKEN_SCALE - self.tokens_x1m;
101            if self.refill_per_us_x1m > 0 {
102                let wait_us = deficit / self.refill_per_us_x1m.max(1);
103                if wait_us > 100 {
104                    std::thread::yield_now();
105                } else {
106                    std::hint::spin_loop();
107                }
108            } else {
109                std::hint::spin_loop();
110            }
111        }
112    }
113
114    /// Current target rate.
115    #[must_use]
116    pub fn rate_pps(&self) -> u64 {
117        self.rate_pps
118    }
119
120    /// Whether this limiter is unlimited.
121    #[must_use]
122    pub fn is_unlimited(&self) -> bool {
123        self.rate_pps == 0
124    }
125
126    /// Re-target the rate at runtime. Used by `AdaptiveLoop` to react
127    /// to TX drops / ICMP-unreachable bursts without rebuilding the
128    /// limiter (which would lose the bucket fill state and stutter).
129    pub fn set_rate_pps(&mut self, rate_pps: u64) {
130        self.rate_pps = rate_pps;
131        self.refill_per_us_x1m = if rate_pps == 0 {
132            i64::MAX / 2
133        } else {
134            (rate_pps as i64).max(1)
135        };
136    }
137
138    fn refill(&mut self) {
139        let now = Instant::now();
140        let elapsed_us = now.duration_since(self.last_refill).as_micros() as i64;
141        if elapsed_us > 0 {
142            let new_tokens = elapsed_us.saturating_mul(self.refill_per_us_x1m);
143            self.tokens_x1m = self
144                .tokens_x1m
145                .saturating_add(new_tokens)
146                .min(self.max_tokens_x1m);
147            self.last_refill = now;
148        }
149    }
150}
151
152/// Adaptive rate controller that adjusts pps based on observed packet loss.
153pub struct AdaptiveRate {
154    /// Current target rate.
155    current_pps: u64,
156    /// Maximum configured rate.
157    max_pps: u64,
158    /// Minimum rate floor.
159    min_pps: u64,
160    /// Consecutive successful batches.
161    success_streak: u32,
162    /// Consecutive failed/dropped batches.
163    drop_streak: u32,
164}
165
166impl AdaptiveRate {
167    /// Create a new adaptive rate controller.
168    #[must_use]
169    pub fn new(max_pps: u64) -> Self {
170        Self {
171            current_pps: max_pps / 2, // Start at half rate
172            max_pps,
173            min_pps: 1000, // Never go below 1K pps
174            success_streak: 0,
175            drop_streak: 0,
176        }
177    }
178
179    /// Report a successful batch (all packets sent).
180    pub fn report_success(&mut self) {
181        self.drop_streak = 0;
182        self.success_streak += 1;
183
184        // Additive increase after 10 consecutive successes
185        if self.success_streak >= 10 {
186            self.current_pps = (self.current_pps + self.max_pps / 20).min(self.max_pps);
187            self.success_streak = 0;
188        }
189    }
190
191    /// Report packet drops.
192    pub fn report_drops(&mut self, _drop_count: u64) {
193        self.success_streak = 0;
194        self.drop_streak += 1;
195
196        // Multiplicative decrease
197        self.current_pps = (self.current_pps * 3 / 4).max(self.min_pps);
198    }
199
200    /// Current recommended rate.
201    #[must_use]
202    pub fn current_pps(&self) -> u64 {
203        self.current_pps
204    }
205}
206
207/// Closed-loop wrapper: drives a [`RateLimiter`] from netforge
208/// `EngineStats` deltas. Call [`AdaptiveLoop::tick`] every batch.
209///
210/// Decision rules:
211///
212/// * `tx_drops` increased since the last tick → packets are being lost
213///   on the way out (NIC ring full, kernel back-pressure). Halve the
214///   target rate via [`AdaptiveRate::report_drops`].
215/// * `tx_drops` flat AND `tx_packets` increased → call
216///   [`AdaptiveRate::report_success`]; after 10 clean ticks the rate
217///   creeps back up by 5% of the configured ceiling.
218/// * The applied rate is propagated to the wrapped [`RateLimiter`] so
219///   the per-batch consume path actually slows down.
220///
221/// This is the interlock between the *observed* loss signal and the
222/// *enforced* token bucket. Without it the bucket would stay pegged
223/// at the configured ceiling regardless of what the wire is doing.
224pub struct AdaptiveLoop {
225    rate: AdaptiveRate,
226    last_tx_packets: u64,
227    last_tx_drops: u64,
228    initialized: bool,
229}
230
231impl AdaptiveLoop {
232    /// Construct with `max_pps` as the ceiling. Initial enforced rate
233    /// starts at half (`max_pps / 2`) per [`AdaptiveRate::new`].
234    #[must_use]
235    pub fn new(max_pps: u64) -> Self {
236        Self {
237            rate: AdaptiveRate::new(max_pps),
238            last_tx_packets: 0,
239            last_tx_drops: 0,
240            initialized: false,
241        }
242    }
243
244    /// Process a fresh stats snapshot and return the enforced rate.
245    ///
246    /// First call seeds the baseline and returns the initial rate
247    /// without classifying anything.
248    pub fn tick(&mut self, tx_packets: u64, tx_drops: u64) -> u64 {
249        if !self.initialized {
250            self.last_tx_packets = tx_packets;
251            self.last_tx_drops = tx_drops;
252            self.initialized = true;
253            return self.rate.current_pps();
254        }
255        let drop_delta = tx_drops.saturating_sub(self.last_tx_drops);
256        let packet_delta = tx_packets.saturating_sub(self.last_tx_packets);
257        if drop_delta > 0 {
258            self.rate.report_drops(drop_delta);
259        } else if packet_delta > 0 {
260            self.rate.report_success();
261        }
262        self.last_tx_packets = tx_packets;
263        self.last_tx_drops = tx_drops;
264        self.rate.current_pps()
265    }
266
267    /// Apply the loop's current target to a [`RateLimiter`]. Call
268    /// after every tick — cheap (one assignment) and safe.
269    pub fn apply(&self, limiter: &mut RateLimiter) {
270        limiter.set_rate_pps(self.rate.current_pps());
271    }
272
273    /// Current enforced rate.
274    #[must_use]
275    pub fn current_pps(&self) -> u64 {
276        self.rate.current_pps()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn rate_limiter_unlimited_always_succeeds() {
286        let mut rl = RateLimiter::unlimited();
287        for _ in 0..10_000 {
288            assert!(rl.try_consume());
289        }
290    }
291
292    #[test]
293    fn rate_limiter_respects_burst() {
294        let mut rl = RateLimiter::new(1_000_000, 10);
295        // Should be able to consume burst immediately
296        for i in 0..10 {
297            assert!(rl.try_consume(), "failed at burst token {i}");
298        }
299        // Next one might fail (no time for refill)
300        // (depending on timing, this is non-deterministic, so we just check burst worked)
301    }
302
303    #[test]
304    fn rate_limiter_batch_consume() {
305        // Rate kept very low (10 pps) so refill between the two
306        // synchronous calls is effectively zero; otherwise at 1M pps
307        // the bucket refills to `burst` in microseconds and the second
308        // batch happily consumes the full 100, not the 50 the test
309        // claims it should.
310        let mut rl = RateLimiter::new(10, 100);
311        let consumed = rl.try_consume_batch(50);
312        assert_eq!(consumed, 50);
313        let consumed2 = rl.try_consume_batch(100);
314        assert_eq!(consumed2, 50); // Only 50 remaining from burst
315    }
316
317    #[test]
318    fn adaptive_rate_decreases_on_drops() {
319        let mut ar = AdaptiveRate::new(1_000_000);
320        let initial = ar.current_pps();
321        ar.report_drops(100);
322        assert!(ar.current_pps() < initial);
323    }
324
325    #[test]
326    fn adaptive_rate_increases_on_success() {
327        let mut ar = AdaptiveRate::new(1_000_000);
328        let initial = ar.current_pps();
329        for _ in 0..20 {
330            ar.report_success();
331        }
332        assert!(ar.current_pps() > initial);
333    }
334
335    #[test]
336    fn adaptive_rate_never_below_floor() {
337        let mut ar = AdaptiveRate::new(1_000_000);
338        for _ in 0..100 {
339            ar.report_drops(1000);
340        }
341        assert!(ar.current_pps() >= 1000);
342    }
343
344    #[test]
345    fn set_rate_pps_changes_refill() {
346        let mut r = RateLimiter::new(1_000, 100);
347        assert_eq!(r.rate_pps(), 1_000);
348        r.set_rate_pps(500);
349        assert_eq!(r.rate_pps(), 500);
350        // zero must put the limiter into unlimited mode
351        r.set_rate_pps(0);
352        assert!(r.is_unlimited());
353    }
354
355    #[test]
356    fn adaptive_loop_initial_tick_is_baseline_only() {
357        let mut lo = AdaptiveLoop::new(1_000_000);
358        let initial = lo.current_pps();
359        // First tick: no classification, just baseline capture.
360        let returned = lo.tick(0, 0);
361        assert_eq!(returned, initial);
362    }
363
364    #[test]
365    fn adaptive_loop_decreases_on_tx_drop_burst() {
366        let mut lo = AdaptiveLoop::new(1_000_000);
367        let before = lo.tick(0, 0); // baseline
368                                    // 1000 packets sent, 50 dropped — classifier sees drops.
369        let after = lo.tick(1000, 50);
370        assert!(
371            after < before,
372            "expected rate to decrease: {after} < {before}"
373        );
374    }
375
376    #[test]
377    fn adaptive_loop_increases_after_clean_streak() {
378        let mut lo = AdaptiveLoop::new(1_000_000);
379        lo.tick(0, 0); // baseline
380        let before = lo.current_pps();
381        for i in 1..=15 {
382            // 100 packets per tick, no drops.
383            lo.tick(i * 100, 0);
384        }
385        assert!(
386            lo.current_pps() > before,
387            "expected rate to increase after streak: {} > {before}",
388            lo.current_pps()
389        );
390    }
391
392    #[test]
393    fn adaptive_loop_apply_propagates_to_limiter() {
394        let mut lo = AdaptiveLoop::new(1_000_000);
395        let mut limiter = RateLimiter::new(1_000_000, 1000);
396        lo.tick(0, 0);
397        // Force a drop tick.
398        lo.tick(1000, 100);
399        lo.apply(&mut limiter);
400        assert_eq!(limiter.rate_pps(), lo.current_pps());
401    }
402
403    /// Real-rate test: configure a known rate, drain the bucket, then
404    /// measure how many tokens we can claim over a short window. Pre-
405    /// fix the math left `refill_per_us_x1000 = rate_pps` instead of
406    /// `rate_pps / 1000`, which means at e.g. 10_000 pps configured
407    /// we actually let through ~10 million pps.
408    #[test]
409    fn rate_limiter_actually_throttles_to_configured_rate() {
410        use std::time::{Duration, Instant};
411        const CONFIGURED_PPS: u64 = 10_000;
412        // Tiny burst so the bucket drains quickly — we want to
413        // measure the steady-state refill rate, not burst capacity.
414        let mut rl = RateLimiter::new(CONFIGURED_PPS, 32);
415
416        // Drain the burst so subsequent consume calls are bounded by
417        // refill alone.
418        while rl.try_consume() {}
419
420        let start = Instant::now();
421        let mut consumed: u64 = 0;
422        while start.elapsed() < Duration::from_millis(100) {
423            if rl.try_consume() {
424                consumed += 1;
425            } else {
426                std::hint::spin_loop();
427            }
428        }
429        let elapsed_ms = start.elapsed().as_millis().max(1) as u64;
430        let observed_pps = consumed.saturating_mul(1000) / elapsed_ms;
431
432        // Allow 4× headroom for jitter / timer slop. If the observed
433        // rate is more than 4× the configured rate the math is wrong.
434        let upper_bound = CONFIGURED_PPS * 4;
435        assert!(
436            observed_pps <= upper_bound,
437            "RateLimiter configured at {CONFIGURED_PPS} pps achieved \
438             {observed_pps} pps (consumed {consumed} in {elapsed_ms}ms) — \
439             refill scaling regressed"
440        );
441    }
442
443    #[test]
444    fn adaptive_loop_converges_under_synthetic_loss_pattern() {
445        // 50% packet loss → loop must clamp the rate hard.
446        let mut lo = AdaptiveLoop::new(10_000_000);
447        let start = lo.current_pps();
448        for i in 1..=20 {
449            lo.tick(i * 1000, i * 500);
450        }
451        let final_pps = lo.current_pps();
452        assert!(
453            final_pps < start / 4,
454            "expected aggressive decrease: {final_pps} >= {} (start/4)",
455            start / 4
456        );
457    }
458}