Skip to main content

hashiverse_server_lib/server/stats/
request_counts.rs

1use hashiverse_lib::protocol::payload::payload::{PayloadRequestKind, PAYLOAD_REQUEST_KIND_COUNT};
2use hashiverse_lib::tools::decaying_counter::DecayingCounter;
3use hashiverse_lib::tools::time::{TimeMillis, MILLIS_IN_DAY, MILLIS_IN_HOUR, MILLIS_IN_MONTH};
4use parking_lot::Mutex;
5use std::sync::atomic::{AtomicU64, Ordering};
6
7/// Per-`PayloadRequestKind` decaying call-rate estimates over three trailing
8/// windows. Each [`DecayingCounter`] settles at `rate · τ`, so the values read
9/// directly as "estimated calls in the last hour / day / month". Pairs with the
10/// lock-free all-time `request_counters` totals, which stay exact.
11///
12/// `MILLIS_IN_MONTH` is the codebase's 4-week (28-day) month.
13pub struct RequestRateWindows {
14    per_hour: DecayingCounter,
15    per_day: DecayingCounter,
16    per_month: DecayingCounter,
17}
18
19impl RequestRateWindows {
20    pub fn new() -> Self {
21        Self {
22            per_hour: DecayingCounter::new(MILLIS_IN_HOUR),
23            per_day: DecayingCounter::new(MILLIS_IN_DAY),
24            per_month: DecayingCounter::new(MILLIS_IN_MONTH),
25        }
26    }
27
28    /// Record one inbound call at `now` across all three windows.
29    pub fn record(&mut self, now: TimeMillis) {
30        self.per_hour.record(now, 1);
31        self.per_day.record(now, 1);
32        self.per_month.record(now, 1);
33    }
34}
35
36impl Default for RequestRateWindows {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42/// Build the `requests` subtree: one object per [`PayloadRequestKind`] variant,
43/// keyed by its `Display` name, holding the all-time `total` plus the decaying
44/// `per_hour` / `per_day` / `per_month` rate estimates evaluated at `now`.
45///
46/// Total reads use `Ordering::Relaxed` — counters are advisory metrics, not
47/// synchronisation primitives, and the snapshot doesn't need to be coherent
48/// across kinds.
49pub fn request_counts_subtree(totals: &[AtomicU64; PAYLOAD_REQUEST_KIND_COUNT], windows: &[Mutex<RequestRateWindows>; PAYLOAD_REQUEST_KIND_COUNT], now: TimeMillis) -> serde_json::Value {
50    let mut map = serde_json::Map::with_capacity(PAYLOAD_REQUEST_KIND_COUNT);
51    for index in 0..PAYLOAD_REQUEST_KIND_COUNT {
52        let kind = match PayloadRequestKind::from_u16(index as u16) {
53            Ok(kind) => kind,
54            Err(_) => continue,
55        };
56
57        let total = totals[index].load(Ordering::Relaxed);
58        let (per_hour, per_day, per_month) = {
59            let window = windows[index].lock();
60            (window.per_hour.estimate(now), window.per_day.estimate(now), window.per_month.estimate(now))
61        };
62
63        let entry = serde_json::json!({
64            "total":     total,
65            "per_hour":  per_hour.round() as u64,
66            "per_day":   per_day.round() as u64,
67            "per_month": per_month.round() as u64,
68        });
69        map.insert(kind.to_string(), entry);
70    }
71    serde_json::Value::Object(map)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use hashiverse_lib::tools::time::MILLIS_IN_MINUTE;
78
79    fn fresh_state() -> ([AtomicU64; PAYLOAD_REQUEST_KIND_COUNT], [Mutex<RequestRateWindows>; PAYLOAD_REQUEST_KIND_COUNT]) {
80        (std::array::from_fn(|_| AtomicU64::new(0)), std::array::from_fn(|_| Mutex::new(RequestRateWindows::new())))
81    }
82
83    #[test]
84    fn subtree_has_nested_shape_per_kind() {
85        let (totals, windows) = fresh_state();
86        let doc = request_counts_subtree(&totals, &windows, TimeMillis::zero());
87
88        let ping = doc.get(&PayloadRequestKind::PingV1.to_string()).expect("PingV1 entry present");
89        for key in ["total", "per_hour", "per_day", "per_month"] {
90            assert!(ping.get(key).is_some(), "PingV1 entry should have {} key: {}", key, ping);
91        }
92    }
93
94    #[test]
95    fn records_reflect_in_total_and_windows() {
96        let (totals, windows) = fresh_state();
97        let ping_index = PayloadRequestKind::PingV1 as usize;
98
99        // Drive one call per minute for an hour into PingV1.
100        let mut now = TimeMillis::zero();
101        for _ in 0..60 {
102            now = TimeMillis(now.0 + MILLIS_IN_MINUTE.0);
103            totals[ping_index].fetch_add(1, Ordering::Relaxed);
104            windows[ping_index].lock().record(now);
105        }
106
107        let doc = request_counts_subtree(&totals, &windows, now);
108        let ping = doc.get(&PayloadRequestKind::PingV1.to_string()).unwrap();
109
110        assert_eq!(ping.get("total").unwrap().as_u64().unwrap(), 60);
111        // ~one-per-minute into a 1h window settles in the tens (not the full 60 yet
112        // after only one time constant of accumulation); just assert it registered.
113        let per_hour = ping.get("per_hour").unwrap().as_u64().unwrap();
114        assert!(per_hour > 0, "per_hour should be non-zero after 60 calls, got {}", per_hour);
115
116        // An untouched kind stays all zero.
117        let error = doc.get(&PayloadRequestKind::ErrorV1.to_string()).unwrap();
118        assert_eq!(error.get("total").unwrap().as_u64().unwrap(), 0);
119        assert_eq!(error.get("per_hour").unwrap().as_u64().unwrap(), 0);
120    }
121}