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