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}