Skip to main content

av_state/
velocity.rs

1//! Token-velocity tracking: sliding-window tokens-per-second intended for
2//! rate limiting. (The loop-breaker's `N+ tokens` arm uses its own
3//! cumulative per-session counter, not this window.)
4//!
5//! # NOT WIRED INTO ENFORCEMENT
6//!
7//! There are currently zero production callers of [`TokenVelocity`]; the
8//! only users outside this crate are the integration tests in
9//! `crates/av-state/tests/e2e_race_velocity.rs`. The type predates the
10//! current admission / loop-breaker plumbing and was never connected to
11//! either the breaker's `min_tokens` gate (in `av_loopdetect`) or any
12//! harness rate-limit path.
13//!
14//! **Do not wire this into the breaker without a shared per-session
15//! lock.** The velocity `record_at` mutation and the breaker's `observe`
16//! mutation live under different [`parking_lot::Mutex`] instances, so a
17//! blind wire-up races the two counters. If you need per-session
18//! velocity in the breaker, extend `av_loopdetect::Breaker` with its own
19//! velocity window guarded by the same mutex the breaker already holds.
20//!
21//! The type is kept public for now (rather than deleted) because the
22//! sliding-window arithmetic is exercised by the
23//! `e2e_race_velocity` adversarial tests that lock in the
24//! `saturating_add` discipline — reusable ground for the
25//! future breaker-integrated window when someone builds it.
26
27use parking_lot::Mutex;
28use std::collections::VecDeque;
29
30/// Sliding-window token counter.
31#[derive(Debug)]
32pub struct TokenVelocity {
33    window_ms: u64,
34    samples: Mutex<VecDeque<(u64, u64)>>, // (timestamp_ms, tokens)
35}
36
37impl TokenVelocity {
38    /// Create a tracker with the given window size in milliseconds.
39    pub fn new(window_ms: u64) -> Self {
40        Self {
41            window_ms: window_ms.max(1),
42            samples: Mutex::new(VecDeque::new()),
43        }
44    }
45
46    /// Record `tokens` at time `now_ms` and return the windowed total.
47    pub fn record_at(&self, now_ms: u64, tokens: u64) -> u64 {
48        let mut samples = self.samples.lock();
49        samples.push_back((now_ms, tokens));
50        let cutoff = now_ms.saturating_sub(self.window_ms);
51        // R108 F4: filter across the WHOLE deque (retain), not just
52        // the front. Prior shape assumed monotonically-increasing
53        // `now_ms` and only evicted via `while front < cutoff`.
54        // Under NTP slew / VM live-migration a caller can produce
55        // a strictly-earlier now_ms than a previously recorded
56        // sample, leaving that older sample stranded mid-deque —
57        // subsequent evictions stop as soon as they hit a
58        // "future" front, so the stranded sample keeps
59        // contributing to the windowed sum long after its own
60        // timestamp fell out of the window. Realistically only
61        // visible under clock skew, but the crate is `pub` and
62        // the module header explicitly frames the sliding-window
63        // arithmetic as "reusable ground for the future breaker-
64        // integrated window when someone builds it" — future
65        // wire-up would inherit the bug. `retain` is O(n) but the
66        // deque is small by construction (few samples per window)
67        // and this is not on any hot path today.
68        samples.retain(|(t, _)| *t >= cutoff);
69        samples.iter().fold(0u64, |acc, (_, n)| acc.saturating_add(*n))
70    }
71
72    /// Record at the current wall clock.
73    pub fn record(&self, tokens: u64) -> u64 {
74        self.record_at(av_core::time::now_ms(), tokens)
75    }
76
77    /// Windowed total without recording.
78    pub fn current_at(&self, now_ms: u64) -> u64 {
79        let samples = self.samples.lock();
80        let cutoff = now_ms.saturating_sub(self.window_ms);
81        // Mirror record_at's saturating_add discipline.
82        samples
83            .iter()
84            .filter(|(t, _)| *t >= cutoff)
85            .fold(0u64, |acc, (_, n)| acc.saturating_add(*n))
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
92
93    use super::*;
94
95    #[test]
96    fn accumulates_within_window() {
97        let v = TokenVelocity::new(1000);
98        assert_eq!(v.record_at(0, 100), 100);
99        assert_eq!(v.record_at(500, 50), 150);
100        assert_eq!(v.record_at(999, 25), 175);
101    }
102
103    #[test]
104    fn old_samples_expire() {
105        let v = TokenVelocity::new(1000);
106        v.record_at(0, 100);
107        v.record_at(500, 50);
108        // t=1400 → cutoff 400: only the t=0 sample expires.
109        assert_eq!(v.record_at(1400, 10), 60);
110        // t=1600 → cutoff 600: the t=500 sample expires too.
111        assert_eq!(v.current_at(1600), 10);
112        assert_eq!(v.current_at(3000), 0, "everything expires eventually");
113    }
114
115    #[test]
116    fn clock_going_backwards_does_not_panic() {
117        let v = TokenVelocity::new(1000);
118        v.record_at(5000, 10);
119        // Skewed clock: earlier timestamp after a later one.
120        let total = v.record_at(4000, 5);
121        assert!(total >= 5, "must count at least the new sample, got {total}");
122    }
123
124    #[test]
125    fn record_returns_the_windowed_total_including_the_new_sample() {
126        // The wall-clock `record` wrapper must not be reducible to a
127        // constant (mutant misses on `record -> 0 / 1`).
128        let v = TokenVelocity::new(60_000);
129        let first = v.record(7);
130        assert_eq!(first, 7, "first record must return the value just written");
131        let second = v.record(3);
132        assert_eq!(
133            second, 10,
134            "second record must reflect both samples inside the window"
135        );
136    }
137
138    /// `Iterator::sum` on `u64` panics in debug and
139    /// wraps in release on overflow. Every other counter/spend
140    /// site in av_state uses checked/saturating arithmetic;
141    /// velocity now does too. Two u64::MAX samples inside the
142    /// window must return u64::MAX (saturated), not panic and not
143    /// wrap to a small number.
144    #[test]
145    fn windowed_sum_saturates_instead_of_wrapping_or_panicking() {
146        let v = TokenVelocity::new(60_000);
147        v.record_at(1_000, u64::MAX);
148        let total = v.record_at(2_000, u64::MAX);
149        assert_eq!(
150            total,
151            u64::MAX,
152            "windowed sum must saturate at u64::MAX, got {total}"
153        );
154        // current_at path also.
155        let peek = v.current_at(3_000);
156        assert_eq!(peek, u64::MAX, "current_at must saturate at u64::MAX, got {peek}");
157    }
158}