Skip to main content

scatter_proxy/
metrics.rs

1use std::collections::VecDeque;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5use serde::{Deserialize, Serialize};
6
7/// Snapshot of the overall proxy-pool and task-pool state.
8///
9/// Returned by `ScatterProxy::metrics()` so callers can build dashboards,
10/// alerting, or adaptive back-pressure on top of the scheduler.
11#[derive(Debug, Clone, Default)]
12pub struct PoolMetrics {
13    // ── Proxy pool ───────────────────────────────────────────────────
14    pub total_proxies: usize,
15    pub healthy_proxies: usize,
16    pub cooldown_proxies: usize,
17    pub dead_proxies: usize,
18    /// Proxies voluntarily retired after reaching `max_requests_per_proxy`.
19    pub retired_proxies: usize,
20
21    // ── Task pool ────────────────────────────────────────────────────
22    pub pending_tasks: usize,
23    pub delayed_tasks: usize,
24    pub completed_tasks: u64,
25    pub failed_tasks: u64,
26
27    // ── Throughput (sliding window) ──────────────────────────────────
28    pub throughput_1s: f64,
29    pub throughput_10s: f64,
30    pub throughput_60s: f64,
31
32    // ── Quality ──────────────────────────────────────────────────────
33    pub success_rate_1m: f64,
34    pub avg_latency_ms: f64,
35
36    // ── Resources ────────────────────────────────────────────────────
37    pub inflight: usize,
38    pub requeued_tasks: u64,
39    pub zero_available_events: u64,
40    pub skipped_no_permit: u64,
41    pub skipped_rate_limit: u64,
42    pub skipped_cooldown: u64,
43    pub dispatch_count: u64,
44}
45
46/// Per-(proxy, host) statistics snapshot (user-facing / serialisable).
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct ProxyHostStats {
49    pub success: u32,
50    pub fail: u32,
51    pub success_rate: f64,
52    pub avg_latency_ms: f64,
53    pub consecutive_fails: u32,
54}
55
56impl Default for ProxyHostStats {
57    fn default() -> Self {
58        Self {
59            success: 0,
60            fail: 0,
61            success_rate: 0.0,
62            avg_latency_ms: 0.0,
63            consecutive_fails: 0,
64        }
65    }
66}
67
68// ─── Internal throughput tracker ─────────────────────────────────────────────
69
70/// Sliding-window throughput tracker.
71///
72/// Stores the [`Instant`] of every recorded event and computes throughput
73/// (events / second) over arbitrary windows up to `max_window`.
74///
75/// Thread-safe: the inner [`VecDeque`] is wrapped in a [`Mutex`].
76pub(crate) struct ThroughputTracker {
77    timestamps: Mutex<VecDeque<Instant>>,
78    max_window: Duration,
79}
80
81impl ThroughputTracker {
82    /// Create a new tracker that retains timestamps for 60 seconds.
83    pub fn new() -> Self {
84        Self::with_max_window(Duration::from_secs(60))
85    }
86
87    /// Create a tracker with a custom retention window.
88    pub fn with_max_window(max_window: Duration) -> Self {
89        Self {
90            timestamps: Mutex::new(VecDeque::new()),
91            max_window,
92        }
93    }
94
95    /// Record a completed event at the current instant.
96    pub fn record(&self) {
97        self.record_at(Instant::now());
98    }
99
100    /// Record a completed event at a specific instant (useful for testing).
101    pub(crate) fn record_at(&self, now: Instant) {
102        let mut ts = self.timestamps.lock().unwrap();
103        ts.push_back(now);
104        Self::prune(&mut ts, now, self.max_window);
105    }
106
107    /// Compute throughput (events per second) over the given `window`.
108    ///
109    /// If `window` is zero the result is `0.0`.
110    pub fn throughput(&self, window: Duration) -> f64 {
111        self.throughput_at(Instant::now(), window)
112    }
113
114    /// Compute throughput relative to a specific instant (useful for testing).
115    pub(crate) fn throughput_at(&self, now: Instant, window: Duration) -> f64 {
116        if window.is_zero() {
117            return 0.0;
118        }
119        let mut ts = self.timestamps.lock().unwrap();
120        Self::prune(&mut ts, now, self.max_window);
121
122        let cutoff = now.checked_sub(window).unwrap_or(now);
123        let count = ts.iter().filter(|&&t| t >= cutoff).count();
124        count as f64 / window.as_secs_f64()
125    }
126
127    /// Remove timestamps older than `max_window` before `now`.
128    fn prune(ts: &mut VecDeque<Instant>, now: Instant, max_window: Duration) {
129        let cutoff = now.checked_sub(max_window).unwrap_or(now);
130        while let Some(&front) = ts.front() {
131            if front < cutoff {
132                ts.pop_front();
133            } else {
134                break;
135            }
136        }
137    }
138
139    /// Return the number of timestamps currently stored (after pruning).
140    #[cfg(test)]
141    fn len(&self) -> usize {
142        let ts = self.timestamps.lock().unwrap();
143        ts.len()
144    }
145}
146
147// ─── Tests ───────────────────────────────────────────────────────────────────
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::time::Duration;
153
154    // ── PoolMetrics ──────────────────────────────────────────────────
155
156    #[test]
157    fn pool_metrics_default_is_zeroed() {
158        let m = PoolMetrics::default();
159        assert_eq!(m.total_proxies, 0);
160        assert_eq!(m.healthy_proxies, 0);
161        assert_eq!(m.cooldown_proxies, 0);
162        assert_eq!(m.dead_proxies, 0);
163        assert_eq!(m.retired_proxies, 0);
164        assert_eq!(m.pending_tasks, 0);
165        assert_eq!(m.delayed_tasks, 0);
166        assert_eq!(m.completed_tasks, 0);
167        assert_eq!(m.failed_tasks, 0);
168        assert!((m.throughput_1s).abs() < f64::EPSILON);
169        assert!((m.throughput_10s).abs() < f64::EPSILON);
170        assert!((m.throughput_60s).abs() < f64::EPSILON);
171        assert!((m.success_rate_1m).abs() < f64::EPSILON);
172        assert!((m.avg_latency_ms).abs() < f64::EPSILON);
173        assert_eq!(m.inflight, 0);
174        assert_eq!(m.requeued_tasks, 0);
175        assert_eq!(m.zero_available_events, 0);
176        assert_eq!(m.skipped_no_permit, 0);
177        assert_eq!(m.skipped_rate_limit, 0);
178        assert_eq!(m.skipped_cooldown, 0);
179        assert_eq!(m.dispatch_count, 0);
180    }
181
182    #[test]
183    fn pool_metrics_is_clone() {
184        let m = PoolMetrics {
185            total_proxies: 42,
186            ..Default::default()
187        };
188        let m2 = m.clone();
189        assert_eq!(m2.total_proxies, 42);
190    }
191
192    // ── ProxyHostStats ───────────────────────────────────────────────
193
194    #[test]
195    fn proxy_host_stats_default() {
196        let s = ProxyHostStats::default();
197        assert_eq!(s.success, 0);
198        assert_eq!(s.fail, 0);
199        assert!((s.success_rate).abs() < f64::EPSILON);
200        assert!((s.avg_latency_ms).abs() < f64::EPSILON);
201        assert_eq!(s.consecutive_fails, 0);
202    }
203
204    #[test]
205    fn proxy_host_stats_serde_round_trip() {
206        let stats = ProxyHostStats {
207            success: 10,
208            fail: 2,
209            success_rate: 0.833,
210            avg_latency_ms: 120.5,
211            consecutive_fails: 0,
212        };
213        let json = serde_json::to_string(&stats).unwrap();
214        let deser: ProxyHostStats = serde_json::from_str(&json).unwrap();
215        assert_eq!(deser.success, 10);
216        assert_eq!(deser.fail, 2);
217        assert!((deser.success_rate - 0.833).abs() < 1e-6);
218        assert!((deser.avg_latency_ms - 120.5).abs() < 1e-6);
219        assert_eq!(deser.consecutive_fails, 0);
220    }
221
222    // ── ThroughputTracker ────────────────────────────────────────────
223
224    #[test]
225    fn new_tracker_is_empty() {
226        let t = ThroughputTracker::new();
227        assert_eq!(t.len(), 0);
228        assert!((t.throughput(Duration::from_secs(1))).abs() < f64::EPSILON);
229    }
230
231    #[test]
232    fn record_increases_count() {
233        let t = ThroughputTracker::new();
234        t.record();
235        assert_eq!(t.len(), 1);
236        t.record();
237        assert_eq!(t.len(), 2);
238    }
239
240    #[test]
241    fn throughput_with_zero_window_is_zero() {
242        let t = ThroughputTracker::new();
243        t.record();
244        assert!((t.throughput(Duration::ZERO)).abs() < f64::EPSILON);
245    }
246
247    #[test]
248    fn throughput_over_1s_window() {
249        let t = ThroughputTracker::new();
250        let now = Instant::now();
251
252        // Record 5 events all within the last second.
253        for i in 0..5 {
254            t.record_at(now - Duration::from_millis(100 * i));
255        }
256
257        let tp = t.throughput_at(now, Duration::from_secs(1));
258        // 5 events in 1 second = 5.0/s
259        assert!((tp - 5.0).abs() < 0.01);
260    }
261
262    #[test]
263    fn throughput_over_10s_window() {
264        let t = ThroughputTracker::new();
265        let now = Instant::now();
266
267        // 20 events spread over the last 10 seconds.
268        for i in 0..20 {
269            t.record_at(now - Duration::from_millis(500 * i));
270        }
271
272        let tp = t.throughput_at(now, Duration::from_secs(10));
273        // 20 events in 10 seconds = 2.0/s
274        assert!((tp - 2.0).abs() < 0.01);
275    }
276
277    #[test]
278    fn old_entries_are_pruned() {
279        let t = ThroughputTracker::with_max_window(Duration::from_secs(5));
280        let now = Instant::now();
281
282        // Record an event 10s ago — beyond the 5s max_window.
283        t.record_at(now - Duration::from_secs(10));
284        // Record an event 1s ago — within the window.
285        t.record_at(now - Duration::from_secs(1));
286
287        // Trigger prune by computing throughput.
288        let tp = t.throughput_at(now, Duration::from_secs(5));
289        // Only the recent event should survive.
290        assert!((tp - 0.2).abs() < 0.01); // 1 event / 5s = 0.2
291        assert_eq!(t.len(), 1);
292    }
293
294    #[test]
295    fn window_larger_than_max_window_still_works() {
296        let t = ThroughputTracker::with_max_window(Duration::from_secs(5));
297        let now = Instant::now();
298
299        t.record_at(now - Duration::from_secs(3));
300        t.record_at(now - Duration::from_secs(1));
301
302        // Ask for 60s window, but tracker only keeps 5s.
303        let tp = t.throughput_at(now, Duration::from_secs(60));
304        // 2 events / 60s ≈ 0.033
305        assert!((tp - 2.0 / 60.0).abs() < 0.01);
306    }
307
308    #[test]
309    fn concurrent_access_does_not_panic() {
310        use std::sync::Arc;
311        use std::thread;
312
313        let tracker = Arc::new(ThroughputTracker::new());
314        let mut handles = Vec::new();
315
316        for _ in 0..8 {
317            let t = Arc::clone(&tracker);
318            handles.push(thread::spawn(move || {
319                for _ in 0..100 {
320                    t.record();
321                    let _ = t.throughput(Duration::from_secs(1));
322                }
323            }));
324        }
325
326        for h in handles {
327            h.join().unwrap();
328        }
329
330        // All 800 events should be recorded (within the 60s window, they all are).
331        assert_eq!(tracker.len(), 800);
332    }
333
334    #[test]
335    fn throughput_only_counts_events_within_requested_window() {
336        let t = ThroughputTracker::new();
337        let now = Instant::now();
338
339        // 3 events 500ms ago.
340        for _ in 0..3 {
341            t.record_at(now - Duration::from_millis(500));
342        }
343        // 2 events 5s ago.
344        for _ in 0..2 {
345            t.record_at(now - Duration::from_secs(5));
346        }
347
348        // 1s window should only see the 3 recent events.
349        let tp_1s = t.throughput_at(now, Duration::from_secs(1));
350        assert!((tp_1s - 3.0).abs() < 0.01);
351
352        // 10s window should see all 5.
353        let tp_10s = t.throughput_at(now, Duration::from_secs(10));
354        assert!((tp_10s - 0.5).abs() < 0.01);
355    }
356
357    #[test]
358    fn custom_max_window() {
359        let t = ThroughputTracker::with_max_window(Duration::from_secs(2));
360        assert_eq!(t.max_window, Duration::from_secs(2));
361    }
362
363    #[test]
364    fn default_max_window_is_60s() {
365        let t = ThroughputTracker::new();
366        assert_eq!(t.max_window, Duration::from_secs(60));
367    }
368}