Skip to main content

hyperi_rustlib/scaling/
rate_window.rs

1// Project:   hyperi-rustlib
2// File:      src/scaling/rate_window.rs
3// Purpose:   Sliding window rate calculator
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Sliding window rate calculator for scaling components.
10//!
11//! [`RateWindow`] tracks a monotonic counter over time and computes
12//! the rate of change per second. Useful for request rate, message rate,
13//! error rate, and similar signals.
14//!
15//! Thread-safe via `parking_lot::RwLock` -- reads are fast and non-blocking.
16
17use std::time::{Duration, Instant};
18
19use parking_lot::RwLock;
20
21/// Sliding window rate calculator.
22///
23/// Tracks (timestamp, counter_value) samples and computes rate per second
24/// over the configured window. Old samples are pruned automatically.
25///
26/// # Example
27///
28/// ```rust
29/// use hyperi_rustlib::scaling::RateWindow;
30/// use std::time::Duration;
31///
32/// let window = RateWindow::new(Duration::from_secs(60));
33///
34/// // Record counter values over time
35/// window.record(100);
36/// // ... some time later ...
37/// window.record(200);
38///
39/// let rate = window.rate_per_second();
40/// // Rate = (200 - 100) / elapsed_seconds
41/// ```
42pub struct RateWindow {
43    inner: RwLock<WindowInner>,
44}
45
46struct WindowInner {
47    samples: Vec<(Instant, u64)>,
48    window_size: Duration,
49}
50
51impl RateWindow {
52    /// Create a new rate window with the given duration.
53    ///
54    /// Samples older than `window_size` are pruned on each `record()` call.
55    #[must_use]
56    pub fn new(window_size: Duration) -> Self {
57        Self {
58            inner: RwLock::new(WindowInner {
59                samples: Vec::with_capacity(64),
60                window_size,
61            }),
62        }
63    }
64
65    /// Create a rate window with the default 60-second window.
66    #[must_use]
67    pub fn default_window() -> Self {
68        Self::new(Duration::from_mins(1))
69    }
70
71    /// Record a monotonic counter value at the current time.
72    ///
73    /// Old samples outside the window are pruned automatically.
74    pub fn record(&self, counter_value: u64) {
75        let now = Instant::now();
76        let mut inner = self.inner.write();
77        let cutoff = now.checked_sub(inner.window_size).unwrap_or(now);
78        inner.samples.retain(|&(t, _)| t >= cutoff);
79        inner.samples.push((now, counter_value));
80    }
81
82    /// Record a counter value at a specific instant (for testing).
83    #[cfg(test)]
84    fn record_at(&self, at: Instant, counter_value: u64) {
85        let mut inner = self.inner.write();
86        let cutoff = at.checked_sub(inner.window_size).unwrap_or(at);
87        inner.samples.retain(|&(t, _)| t >= cutoff);
88        inner.samples.push((at, counter_value));
89    }
90
91    /// Compute the rate per second over the current window.
92    ///
93    /// Returns 0.0 if fewer than 2 samples exist or the time span is zero.
94    #[must_use]
95    pub fn rate_per_second(&self) -> f64 {
96        let inner = self.inner.read();
97        if inner.samples.len() < 2 {
98            return 0.0;
99        }
100
101        let first = inner.samples.first().unwrap();
102        let last = inner.samples.last().unwrap();
103
104        let duration = last.0.duration_since(first.0).as_secs_f64();
105        if duration <= 0.0 {
106            return 0.0;
107        }
108
109        let delta = last.1.saturating_sub(first.1) as f64;
110        delta / duration
111    }
112
113    /// Number of samples currently in the window.
114    #[must_use]
115    pub fn sample_count(&self) -> usize {
116        self.inner.read().samples.len()
117    }
118
119    /// Clear all samples.
120    pub fn clear(&self) {
121        self.inner.write().samples.clear();
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_empty_window() {
131        let w = RateWindow::default_window();
132        assert!((w.rate_per_second()).abs() < f64::EPSILON);
133        assert_eq!(w.sample_count(), 0);
134    }
135
136    #[test]
137    fn test_single_sample() {
138        let w = RateWindow::default_window();
139        w.record(100);
140        assert!((w.rate_per_second()).abs() < f64::EPSILON);
141        assert_eq!(w.sample_count(), 1);
142    }
143
144    #[test]
145    fn test_two_samples_rate() {
146        let w = RateWindow::new(Duration::from_mins(1));
147        let now = Instant::now();
148        w.record_at(now, 0);
149        w.record_at(now + Duration::from_secs(10), 1000);
150
151        let rate = w.rate_per_second();
152        // 1000 events in 10 seconds = 100/s
153        assert!((rate - 100.0).abs() < 0.01, "Expected ~100.0, got {rate}");
154    }
155
156    #[test]
157    fn test_multiple_samples() {
158        let w = RateWindow::new(Duration::from_mins(1));
159        let now = Instant::now();
160        w.record_at(now, 0);
161        w.record_at(now + Duration::from_secs(5), 500);
162        w.record_at(now + Duration::from_secs(10), 1000);
163
164        let rate = w.rate_per_second();
165        // Rate computed from first to last: 1000 / 10 = 100/s
166        assert!((rate - 100.0).abs() < 0.01, "Expected ~100.0, got {rate}");
167    }
168
169    #[test]
170    fn test_window_pruning() {
171        let w = RateWindow::new(Duration::from_secs(5));
172        let now = Instant::now();
173
174        // Old sample (before window)
175        w.record_at(now.checked_sub(Duration::from_secs(10)).unwrap(), 0);
176        // Recent samples (within window)
177        w.record_at(now.checked_sub(Duration::from_secs(2)).unwrap(), 800);
178        w.record_at(now, 1000);
179
180        // Old sample should be pruned, leaving 2
181        assert_eq!(w.sample_count(), 2);
182
183        let rate = w.rate_per_second();
184        // (1000 - 800) / 2 = 100/s
185        assert!((rate - 100.0).abs() < 0.01, "Expected ~100.0, got {rate}");
186    }
187
188    #[test]
189    fn test_clear() {
190        let w = RateWindow::default_window();
191        w.record(100);
192        w.record(200);
193        assert_eq!(w.sample_count(), 2);
194
195        w.clear();
196        assert_eq!(w.sample_count(), 0);
197        assert!((w.rate_per_second()).abs() < f64::EPSILON);
198    }
199
200    #[test]
201    fn test_zero_duration() {
202        let w = RateWindow::new(Duration::from_mins(1));
203        let now = Instant::now();
204        // Two samples at the same instant
205        w.record_at(now, 0);
206        w.record_at(now, 1000);
207        // Should return 0.0, not infinity
208        assert!((w.rate_per_second()).abs() < f64::EPSILON);
209    }
210
211    #[test]
212    fn test_counter_wraparound() {
213        let w = RateWindow::new(Duration::from_mins(1));
214        let now = Instant::now();
215        // Counter value decreases (reset/overflow)
216        w.record_at(now, 1000);
217        w.record_at(now + Duration::from_secs(10), 500);
218        // saturating_sub returns 0
219        assert!((w.rate_per_second()).abs() < f64::EPSILON);
220    }
221}