Skip to main content

zenkey_fleet/
tree.rs

1//! The live key tree (issue #15): an immutable snapshot of everything the
2//! monitor has seen, grouped by key chunks, with per-node statistics.
3//!
4//! Snapshots are rebuilt on the monitor's stats tick and published through
5//! an `ArcSwap` — render loops *pull* the latest snapshot at their own pace
6//! and never contend with the per-sample hot path (a hot bus cannot melt a
7//! zengui redraw).
8
9use std::collections::BTreeMap;
10use std::time::Instant;
11
12use crate::stats::{KeyStats, StatsTable};
13
14/// Fold one key's stats into every node on its path (the node itself included).
15fn accumulate(node: &mut TreeNode, s: &KeyStats) {
16    node.subtree_count += s.count;
17    node.subtree_bytes += s.bytes;
18    node.subtree_rate_hz += s.rate_hz;
19    node.subtree_keys += 1;
20    node.subtree_last_seen = node.subtree_last_seen.max(Some(s.last_seen));
21}
22
23/// One node of the snapshot: a key chunk, its subtree, and — when a sample
24/// has landed exactly here — its stats.
25///
26/// The `subtree_*` fields are what a **collapsed** node shows: a UI that can
27/// only report the traffic of keys it happens to have expanded is reporting a
28/// number the user will misread as the total.
29#[derive(Debug, Clone, Default)]
30pub struct TreeNode {
31    pub children: BTreeMap<String, TreeNode>,
32    /// Samples observed at exactly this key (leaf traffic).
33    pub count: u64,
34    pub bytes: u64,
35    pub rate_hz: f64,
36    /// When a sample last landed exactly here.
37    pub last_seen: Option<Instant>,
38    /// Aggregates over the whole subtree (this node included).
39    pub subtree_count: u64,
40    pub subtree_bytes: u64,
41    pub subtree_rate_hz: f64,
42    /// Most recent sample anywhere in the subtree.
43    pub subtree_last_seen: Option<Instant>,
44    /// Distinct keys that have carried traffic in this subtree.
45    pub subtree_keys: usize,
46}
47
48/// An immutable point-in-time view of the observed keyspace.
49///
50/// Carries the table's O6 counters too, so a render loop can report what the
51/// bound cost **without taking the ingest lock**: `root.subtree_count/bytes/
52/// rate_hz` are already the fold `StatsTable::totals` performs, and `keys` is
53/// its `len`. A consumer that pulled this `Arc` and then locked the table
54/// anyway was walking 50k entries a second time, four times a second, on the
55/// same mutex 100k samples/s need (`docs/zero-copy.md`).
56#[derive(Debug, Clone, Default)]
57pub struct KeyTreeSnapshot {
58    pub root: TreeNode,
59    pub keys: usize,
60    /// Keys retired to stay within the table's bound, as of this snapshot.
61    pub evicted: u64,
62    /// Keys retired because their watch was released.
63    pub unwatched: u64,
64}
65
66impl KeyTreeSnapshot {
67    /// Build from the stats table (called on the stats tick, off the
68    /// per-sample path).
69    pub fn build(stats: &StatsTable) -> KeyTreeSnapshot {
70        let mut root = TreeNode::default();
71        for (key, s) in stats.iter() {
72            let mut node = &mut root;
73            accumulate(node, s);
74            for chunk in key.split('/') {
75                // `entry` would need an owned key, so `chunk.to_string()`
76                // would run — and be dropped — on every *hit*, which is
77                // almost every chunk of almost every key. At 50k keys of 6
78                // chunks that is 300 000 wasted allocations per tick, four
79                // times a second. `BTreeMap<String, _>` looks up by `&str`
80                // through `Borrow`, so the owned key is built only when the
81                // node is genuinely new (`docs/zero-copy.md`).
82                if !node.children.contains_key(chunk) {
83                    node.children.insert(chunk.to_string(), TreeNode::default());
84                }
85                node = node
86                    .children
87                    .get_mut(chunk)
88                    .expect("just inserted if it was missing");
89                accumulate(node, s);
90            }
91            node.count = s.count;
92            node.bytes = s.bytes;
93            node.rate_hz = s.rate_hz;
94            node.last_seen = Some(s.last_seen);
95        }
96        KeyTreeSnapshot {
97            root,
98            keys: stats.len(),
99            evicted: stats.evicted(),
100            unwatched: stats.unwatched(),
101        }
102    }
103
104    /// Walk to a node by its chunk path.
105    pub fn node(&self, path: &[&str]) -> Option<&TreeNode> {
106        let mut node = &self.root;
107        for chunk in path {
108            node = node.children.get(*chunk)?;
109        }
110        Some(node)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::time::Instant;
118
119    #[test]
120    fn builds_grouped_counts() {
121        let mut stats = StatsTable::new();
122        let now = Instant::now();
123        stats.record("zs/v1/h-a/telemetry/x/m1", 4, None, now, None);
124        stats.record("zs/v1/h-a/telemetry/x/m1", 4, None, now, None);
125        stats.record("zs/v1/h-a/telemetry/x/m2", 4, None, now, None);
126        stats.record("zs/v1/h-b/state/x/health", 4, None, now, None);
127
128        let snap = KeyTreeSnapshot::build(&stats);
129        assert_eq!(snap.keys, 3);
130        assert_eq!(snap.root.subtree_count, 4);
131        let telemetry = snap.node(&["zs", "v1", "h-a", "telemetry", "x"]).unwrap();
132        assert_eq!(telemetry.subtree_count, 3);
133        let m1 = snap
134            .node(&["zs", "v1", "h-a", "telemetry", "x", "m1"])
135            .unwrap();
136        assert_eq!(m1.count, 2);
137        assert_eq!(m1.bytes, 8);
138        assert!(snap.node(&["zs", "v1", "h-c"]).is_none());
139    }
140
141    /// A collapsed node must be able to report its subtree's traffic — bytes
142    /// and distinct keys, not only the sample count.
143    #[test]
144    fn collapsed_nodes_aggregate_their_subtree() {
145        let mut stats = StatsTable::new();
146        let now = Instant::now();
147        stats.record("zs/v1/h-a/telemetry/x/m1", 4, None, now, None);
148        stats.record("zs/v1/h-a/telemetry/x/m1", 4, None, now, None);
149        stats.record("zs/v1/h-a/telemetry/x/m2", 10, None, now, None);
150        stats.record("zs/v1/h-b/state/x/health", 7, None, now, None);
151
152        let snap = KeyTreeSnapshot::build(&stats);
153        let root = &snap.root;
154        assert_eq!(root.subtree_count, 4);
155        assert_eq!(root.subtree_bytes, 4 + 4 + 10 + 7);
156        assert_eq!(root.subtree_keys, 3, "three distinct keys carried traffic");
157        assert!(root.subtree_last_seen.is_some());
158        // The root itself is not a leaf: no sample landed exactly there.
159        assert_eq!(root.count, 0);
160        assert_eq!(root.last_seen, None);
161
162        let x = snap.node(&["zs", "v1", "h-a", "telemetry", "x"]).unwrap();
163        assert_eq!(x.subtree_count, 3);
164        assert_eq!(x.subtree_bytes, 18);
165        assert_eq!(x.subtree_keys, 2);
166
167        let m1 = snap
168            .node(&["zs", "v1", "h-a", "telemetry", "x", "m1"])
169            .unwrap();
170        assert_eq!(m1.last_seen, Some(now));
171        assert_eq!(m1.subtree_keys, 1, "a leaf counts only itself");
172    }
173}