Skip to main content

zenkey_fleet/
stats.rs

1//! Windowed per-key statistics (issues #13/#15): message/byte counters and
2//! an exponentially-weighted rate, keyed by wire key. Backs `zenctl topic
3//! hz`/`bw`/`echo --rate` and zengui's tree badges.
4//!
5//! Perf posture (report §14): lookups borrow (`&str` against the `String`
6//! keys — no per-sample allocation on the hot hit path); one allocation per
7//! *new* key is the floor.
8
9use std::collections::{HashMap, VecDeque};
10use std::time::{Duration, Instant};
11
12/// How many per-key latency observations the summary window keeps. Bounded
13/// like everything else an hours-long observer accumulates (O6).
14const LAT_WINDOW: usize = 256;
15
16/// The observed **skewed** latency distribution of one key (#119):
17/// (arrival wall-clock − publisher HLC), µs, over the last `LAT_WINDOW`
18/// (a private bound) stamped samples.
19///
20/// The caveat is part of the measurement: this contains clock skew, and
21/// HLCs are only as good as the fleet's time discipline. Negative values
22/// are the skew *evidence* and are never clamped — render this as
23/// "observed skewed latency", an observation, not a verdict on the
24/// transport (RFC 09 §5.1 applied to a number).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
26pub struct LatencySummary {
27    pub min_us: i64,
28    pub median_us: i64,
29    pub p95_us: i64,
30    pub max_us: i64,
31    /// Stamped samples in the window.
32    pub samples: usize,
33}
34
35/// One key's running statistics.
36#[derive(Debug, Clone)]
37pub struct KeyStats {
38    pub count: u64,
39    pub bytes: u64,
40    /// EWMA of the instantaneous rate (Hz), time-decayed.
41    pub rate_hz: f64,
42    pub last_seen: Instant,
43    /// Consecutive source-sequence-number gap count, when publishers attach
44    /// SourceInfo (unstable API) — loss visibility, `--loss`.
45    pub sn_gaps: u64,
46    /// Samples that carried **no** HLC timestamp — an observation of their
47    /// own, counted separately: an unstamped sample has no latency, which
48    /// is not the same as zero latency (#119).
49    pub unstamped: u64,
50    last_sn: Option<u32>,
51    /// Bounded window of observed skewed latencies, µs.
52    lat: VecDeque<i64>,
53}
54
55impl KeyStats {
56    /// The window's distribution, or `None` before any stamped sample.
57    ///
58    pub fn latency(&self) -> Option<LatencySummary> {
59        if self.lat.is_empty() {
60            return None;
61        }
62        let mut sorted: Vec<i64> = self.lat.iter().copied().collect();
63        sorted.sort_unstable();
64        let at = |q: f64| sorted[((sorted.len() - 1) as f64 * q) as usize];
65        Some(LatencySummary {
66            min_us: sorted[0],
67            median_us: at(0.5),
68            p95_us: at(0.95),
69            max_us: *sorted.last().expect("non-empty"),
70            samples: sorted.len(),
71        })
72    }
73}
74
75/// The table. Feed it samples; read it per key or in aggregate.
76///
77/// **Bounded.** A CLI runs for `--window` seconds and exits, so an unbounded
78/// map was fine; a GUI left open overnight on a bus carrying content-addressed
79/// or per-request keys would grow one entry per key forever. The table
80/// therefore keeps at most [`DEFAULT_MAX_KEYS`] entries, evicting the
81/// least-recently-seen first — the keys that stopped publishing are the ones a
82/// live view has least use for — and **counts every eviction**, so a shrinking
83/// key set is never mistaken for a quiet bus (RFC 09 §5.1).
84#[derive(Debug)]
85pub struct StatsTable {
86    keys: HashMap<String, KeyStats>,
87    max_keys: usize,
88    evicted: u64,
89    unwatched: u64,
90}
91
92impl Default for StatsTable {
93    fn default() -> Self {
94        StatsTable::with_capacity(DEFAULT_MAX_KEYS)
95    }
96}
97
98/// EWMA time constant (~2 s: responsive enough for a UI badge, smooth
99/// enough not to flicker); samples older than ~tau contribute e^-1.
100const TAU: Duration = Duration::from_secs(2);
101
102/// Default key bound. Large enough that no ordinary fleet reaches it — the
103/// reference application's whole telemetry fan is a few thousand keys — and
104/// small enough that a runaway key family cannot exhaust memory.
105pub const DEFAULT_MAX_KEYS: usize = 50_000;
106
107/// Fraction of the table dropped when the bound is hit.
108///
109/// Evicting in batches amortises the O(n) scan for the oldest entries across
110/// many inserts; evicting one key per insert would make every sample past the
111/// bound a full table scan.
112const EVICT_FRACTION: usize = 16;
113
114impl StatsTable {
115    pub fn new() -> Self {
116        Self::default()
117    }
118
119    /// A table bounded at `max_keys` entries.
120    pub fn with_capacity(max_keys: usize) -> Self {
121        StatsTable {
122            keys: HashMap::new(),
123            max_keys: max_keys.max(1),
124            evicted: 0,
125            unwatched: 0,
126        }
127    }
128
129    /// Keys dropped to stay within the bound.
130    ///
131    /// Non-zero means the view is partial: some keys that carried traffic are
132    /// no longer represented in [`len`](Self::len), [`totals`](Self::totals) or
133    /// any tree built from this table.
134    pub fn evicted(&self) -> u64 {
135        self.evicted
136    }
137
138    /// The bound in force.
139    pub fn max_keys(&self) -> usize {
140        self.max_keys
141    }
142
143    /// Keys retired because no active watch covers them any more
144    /// ([`retire_unwatched`](Self::retire_unwatched)).
145    ///
146    /// The third O6 category, deliberately distinct from
147    /// [`evicted`](Self::evicted) ("chose to forget under the bound") and the
148    /// broadcast's dropped ("could not keep up"): this one is "stopped
149    /// looking, by request" — and a key set that shrinks because the user
150    /// unwatched a subtree must say so, or it reads as a quieting bus.
151    pub fn unwatched(&self) -> u64 {
152        self.unwatched
153    }
154
155    /// Retire every key that `gone` covers and no selector in `kept` still
156    /// covers, counting them under [`unwatched`](Self::unwatched). Returns
157    /// how many were retired. Selectors that fail to parse as key
158    /// expressions cover nothing (`gone`) / keep nothing (`kept`).
159    pub fn retire_unwatched(&mut self, gone: &str, kept: &[String]) -> usize {
160        use zenoh::key_expr::keyexpr;
161        // Borrowed throughout: `keyexpr::new(&str)` validates without
162        // allocating, where `KeyExpr::new(String)` builds an `OwnedKeyExpr`.
163        // The old form cloned the key *and* built an owned expr for every key
164        // in the table on every unwatch — 100k allocations at the default
165        // bound (`docs/zero-copy.md`).
166        let Ok(gone) = keyexpr::new(gone) else {
167            return 0;
168        };
169        let kept: Vec<&keyexpr> = kept
170            .iter()
171            .filter_map(|k| keyexpr::new(k.as_str()).ok())
172            .collect();
173        let doomed: Vec<String> = self
174            .keys
175            .keys()
176            .filter(|key| match keyexpr::new(key.as_str()) {
177                Ok(ke) => gone.intersects(ke) && !kept.iter().any(|k| k.intersects(ke)),
178                Err(_) => false,
179            })
180            .cloned()
181            .collect();
182        for key in &doomed {
183            self.keys.remove(key);
184        }
185        self.unwatched += doomed.len() as u64;
186        doomed.len()
187    }
188
189    /// Drop the least-recently-seen entries until there is room.
190    fn evict(&mut self) {
191        let target = self.max_keys - (self.max_keys / EVICT_FRACTION).max(1);
192        let mut seen: Vec<(Instant, String)> = self
193            .keys
194            .iter()
195            .map(|(k, s)| (s.last_seen, k.clone()))
196            .collect();
197        // Oldest first.
198        seen.sort_unstable_by_key(|(last_seen, _)| *last_seen);
199        for (_, key) in seen.into_iter().take(self.keys.len() - target) {
200            self.keys.remove(&key);
201            self.evicted += 1;
202        }
203    }
204
205    /// Record one sample. `now` is injected for deterministic tests;
206    /// `latency_us` is the pre-computed skewed latency (#119) — `None` for
207    /// an unstamped sample, which is counted, not defaulted.
208    pub fn record(
209        &mut self,
210        key: &str,
211        payload_len: usize,
212        sn: Option<u32>,
213        now: Instant,
214        latency_us: Option<i64>,
215    ) {
216        if let Some(s) = self.keys.get_mut(key) {
217            let dt = now.saturating_duration_since(s.last_seen).as_secs_f64();
218            if dt > 0.0 {
219                let alpha = 1.0 - (-dt / TAU.as_secs_f64()).exp();
220                let instant_rate = 1.0 / dt;
221                s.rate_hz += alpha * (instant_rate - s.rate_hz);
222            }
223            s.count += 1;
224            s.bytes += payload_len as u64;
225            s.last_seen = now;
226            if let (Some(prev), Some(cur)) = (s.last_sn, sn)
227                && cur > prev + 1
228            {
229                s.sn_gaps += u64::from(cur - prev - 1);
230            }
231            s.last_sn = sn;
232            match latency_us {
233                Some(us) => {
234                    if s.lat.len() >= LAT_WINDOW {
235                        s.lat.pop_front();
236                    }
237                    s.lat.push_back(us);
238                }
239                None => s.unstamped += 1,
240            }
241        } else {
242            if self.keys.len() >= self.max_keys {
243                self.evict();
244            }
245            self.keys.insert(
246                key.to_string(),
247                KeyStats {
248                    count: 1,
249                    bytes: payload_len as u64,
250                    rate_hz: 0.0,
251                    last_seen: now,
252                    sn_gaps: 0,
253                    unstamped: u64::from(latency_us.is_none()),
254                    last_sn: sn,
255                    lat: latency_us.into_iter().collect(),
256                },
257            );
258        }
259    }
260
261    pub fn get(&self, key: &str) -> Option<&KeyStats> {
262        self.keys.get(key)
263    }
264
265    pub fn iter(&self) -> impl Iterator<Item = (&str, &KeyStats)> {
266        self.keys.iter().map(|(k, v)| (k.as_str(), v))
267    }
268
269    pub fn len(&self) -> usize {
270        self.keys.len()
271    }
272
273    pub fn is_empty(&self) -> bool {
274        self.keys.is_empty()
275    }
276
277    /// Aggregate totals: (samples, bytes, summed EWMA rate).
278    pub fn totals(&self) -> (u64, u64, f64) {
279        self.keys.values().fold((0, 0, 0.0), |(c, b, r), s| {
280            (c + s.count, b + s.bytes, r + s.rate_hz)
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    /// An unbounded table is a leak for any observer that runs for hours on a
290    /// bus with content-addressed or per-request keys.
291    #[test]
292    fn the_table_is_bounded() {
293        let mut t = StatsTable::with_capacity(100);
294        let now = Instant::now();
295        for i in 0..1000 {
296            t.record(&format!("demo/k{i}"), 4, None, now, None);
297        }
298        assert!(t.len() <= 100, "len {} exceeds the bound", t.len());
299        assert!(t.evicted() > 0);
300        // Nothing vanishes silently: every key seen is either present or counted.
301        assert_eq!(t.len() as u64 + t.evicted(), 1000);
302    }
303
304    /// Eviction is least-recently-seen: a key still publishing must outlive a
305    /// key that went quiet, or a live view would drop exactly what it is for.
306    #[test]
307    fn eviction_drops_the_least_recently_seen() {
308        let mut t = StatsTable::with_capacity(10);
309        let t0 = Instant::now();
310
311        // Ten keys, oldest first.
312        for i in 0..10 {
313            t.record(
314                &format!("old/k{i}"),
315                4,
316                None,
317                t0 + Duration::from_millis(i),
318                None,
319            );
320        }
321        // One of them keeps publishing, much later.
322        let fresh = t0 + Duration::from_secs(60);
323        t.record("old/k0", 4, None, fresh, None);
324
325        // Now push new keys in, forcing eviction. Each is strictly newer than
326        // `old/k0`'s refresh, so there is no tie for "oldest" to break.
327        for i in 1..=5 {
328            t.record(
329                &format!("new/k{i}"),
330                4,
331                None,
332                fresh + Duration::from_millis(i),
333                None,
334            );
335        }
336
337        assert!(
338            t.get("old/k0").is_some(),
339            "a key that is still publishing must survive"
340        );
341        assert!(
342            t.get("old/k1").is_none(),
343            "a key that went quiet should have been evicted first"
344        );
345    }
346
347    /// #119: the latency window summarises stamped samples and counts
348    /// unstamped ones separately — no latency is not zero latency, and a
349    /// negative value is the skew evidence, kept.
350    #[test]
351    fn latency_is_summarised_and_unstamped_is_counted_not_defaulted() {
352        let mut t = StatsTable::new();
353        let now = Instant::now();
354        for us in [1000, -200, 5000, 3000] {
355            t.record("k", 4, None, now, Some(us));
356        }
357        t.record("k", 4, None, now, None);
358        let s = t.get("k").unwrap();
359        assert_eq!(s.unstamped, 1);
360        let lat = s.latency().unwrap();
361        assert_eq!(lat.min_us, -200, "negative skew is shown, not clamped");
362        assert_eq!(lat.max_us, 5000);
363        assert_eq!(lat.samples, 4);
364        assert!(lat.median_us >= -200 && lat.median_us <= 5000);
365
366        // Never stamped: no summary, rather than an invented zero.
367        t.record("quiet", 4, None, now, None);
368        assert!(t.get("quiet").unwrap().latency().is_none());
369        assert_eq!(t.get("quiet").unwrap().unstamped, 1);
370    }
371
372    /// Updating a known key must never evict — the bound is on distinct keys,
373    /// not on samples.
374    #[test]
375    fn repeated_keys_never_trigger_eviction() {
376        let mut t = StatsTable::with_capacity(4);
377        let t0 = Instant::now();
378        for i in 0..1000 {
379            t.record("demo/one", 4, None, t0 + Duration::from_millis(i), None);
380        }
381        assert_eq!(t.len(), 1);
382        assert_eq!(t.evicted(), 0);
383        assert_eq!(t.get("demo/one").unwrap().count, 1000);
384    }
385
386    /// A degenerate bound must not panic or spin.
387    #[test]
388    fn a_capacity_of_one_still_works() {
389        let mut t = StatsTable::with_capacity(1);
390        let now = Instant::now();
391        t.record("a", 1, None, now, None);
392        t.record("b", 1, None, now, None);
393        assert_eq!(t.len(), 1);
394        assert_eq!(t.evicted(), 1);
395        // Zero is clamped rather than accepted.
396        assert_eq!(StatsTable::with_capacity(0).max_keys(), 1);
397    }
398
399    #[test]
400    fn rates_converge_and_gaps_count() {
401        let mut t = StatsTable::new();
402        let t0 = Instant::now();
403        // 10 Hz for 100 samples: the EWMA converges near 10.
404        for i in 0..100u32 {
405            t.record(
406                "v1/h-a/telemetry/x/m",
407                8,
408                Some(i),
409                t0 + Duration::from_millis(100 * u64::from(i)),
410                None,
411            );
412        }
413        let s = t.get("v1/h-a/telemetry/x/m").unwrap();
414        assert_eq!(s.count, 100);
415        assert_eq!(s.bytes, 800);
416        assert!((s.rate_hz - 10.0).abs() < 1.0, "rate {}", s.rate_hz);
417        assert_eq!(s.sn_gaps, 0);
418
419        // A sequence jump records the gap.
420        t.record(
421            "v1/h-a/telemetry/x/m",
422            8,
423            Some(105),
424            t0 + Duration::from_millis(10_100),
425            None,
426        );
427        assert_eq!(t.get("v1/h-a/telemetry/x/m").unwrap().sn_gaps, 5);
428    }
429
430    #[test]
431    fn totals_aggregate() {
432        let mut t = StatsTable::new();
433        let now = Instant::now();
434        t.record("a", 10, None, now, None);
435        t.record("b", 20, None, now, None);
436        let (count, bytes, _) = t.totals();
437        assert_eq!((count, bytes), (2, 30));
438        assert_eq!(t.len(), 2);
439    }
440
441    /// Unwatch retirement: covered-by-gone and not-by-kept keys leave the
442    /// table, counted separately from bound eviction (O6's third category).
443    #[test]
444    fn retire_unwatched_respects_remaining_coverage() {
445        let mut t = StatsTable::new();
446        let now = Instant::now();
447        t.record("v1/h-a/telemetry/x/m1", 4, None, now, None);
448        t.record("v1/h-a/state/x/health", 4, None, now, None);
449        t.record("v1/h-b/telemetry/y/m2", 4, None, now, None);
450
451        // Release the telemetry watch, but keep watching h-a entirely.
452        let retired = t.retire_unwatched("v1/*/telemetry/**", &["v1/h-a/**".to_string()]);
453        assert_eq!(retired, 1, "only h-b's telemetry loses coverage");
454        assert!(
455            t.get("v1/h-a/telemetry/x/m1").is_some(),
456            "still covered by kept"
457        );
458        assert!(t.get("v1/h-b/telemetry/y/m2").is_none());
459        assert_eq!(t.unwatched(), 1);
460
461        // Release the rest: everything goes, and the ledger adds up.
462        let retired = t.retire_unwatched("**", &[]);
463        assert_eq!(retired, 2);
464        assert_eq!(t.len(), 0);
465        assert_eq!(t.unwatched(), 3);
466    }
467
468    /// A selector that is not a valid keyexpr covers nothing — no panic, no
469    /// accidental mass retirement.
470    #[test]
471    fn retire_unwatched_tolerates_bad_selectors() {
472        let mut t = StatsTable::new();
473        t.record("a/b", 1, None, Instant::now(), None);
474        assert_eq!(t.retire_unwatched("", &[]), 0);
475        assert_eq!(t.len(), 1);
476    }
477}