Skip to main content

zenkey_fleet/
sub.rs

1//! The live monitor (issue #15): subscription multiplexing + liveliness
2//! watching, fanned into a bounded broadcast of [`FleetEvent`]s, with the
3//! key-tree snapshot published on a stats tick.
4//!
5//! The zengui contract, concretely:
6//! - per-sample events feed **only** sample-shaped consumers (echo panes) —
7//!   the channel is bounded, and overflow surfaces as an explicit
8//!   [`FleetEvent::Dropped`] count on the lagging receiver, never silently;
9//! - tree/dashboard consumers redraw on [`FleetEvent::StatsTick`] by
10//!   *pulling* the immutable [`KeyTreeSnapshot`] from an `ArcSwap` — a hot
11//!   bus cannot melt a render loop.
12
13use std::sync::Arc;
14use std::sync::Mutex;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::time::{Duration, Instant};
17
18use anyhow::{Result, anyhow};
19use arc_swap::ArcSwap;
20use tokio::sync::broadcast;
21use zenoh::Session;
22use zenoh::sample::SampleKind;
23
24use crate::stats::StatsTable;
25use crate::tree::KeyTreeSnapshot;
26
27/// One observed sample, cheap to clone (the payload is zenoh's refcounted
28/// buffer, not a copy — report §14's zero-copy discipline).
29#[derive(Debug, Clone)]
30pub struct SampleView {
31    /// Full wire key, as received (this session is un-namespaced).
32    pub key: String,
33    pub payload: zenoh::bytes::ZBytes,
34    /// The sample's declared encoding, verbatim.
35    pub encoding: String,
36    pub kind: SampleKind,
37    /// HLC timestamp when the publisher's session stamps one.
38    pub timestamp: Option<zenoh::time::Timestamp>,
39}
40
41/// What the monitor emits.
42#[derive(Debug, Clone)]
43pub enum FleetEvent {
44    Sample(Arc<SampleView>),
45    /// A liveliness token appeared (full wire key of the token).
46    NodeUp(String),
47    /// A liveliness token disappeared.
48    NodeDown(String),
49    /// The tree snapshot was rebuilt — pull it via [`Monitor::tree`].
50    StatsTick,
51}
52
53/// What to watch.
54#[derive(Debug, Clone)]
55pub struct MonitorSpec {
56    /// Full wire selectors to subscribe to.
57    pub selectors: Vec<String>,
58    /// Also watch the fleet liveliness roster (with history: current tokens
59    /// arrive on join — no separate seed GET).
60    pub liveliness: Option<String>,
61    /// Snapshot cadence.
62    pub stats_tick: Duration,
63    /// Broadcast capacity: bound it to what an echo pane can drain; lag is
64    /// surfaced, never hidden.
65    pub capacity: usize,
66}
67
68impl Default for MonitorSpec {
69    fn default() -> Self {
70        MonitorSpec {
71            selectors: Vec::new(),
72            liveliness: None,
73            stats_tick: Duration::from_millis(250),
74            capacity: 1024,
75        }
76    }
77}
78
79/// The monitor's shareable core: ingest on one side, events + snapshots on
80/// the other. Session wiring lives in [`Monitor`]; the core is pure and
81/// deterministically testable.
82pub struct MonitorCore {
83    tx: broadcast::Sender<FleetEvent>,
84    stats: Mutex<StatsTable>,
85    tree: ArcSwap<KeyTreeSnapshot>,
86    dropped: AtomicU64,
87}
88
89impl MonitorCore {
90    pub fn new(capacity: usize) -> Arc<MonitorCore> {
91        let (tx, _) = broadcast::channel(capacity.max(2));
92        Arc::new(MonitorCore {
93            tx,
94            stats: Mutex::new(StatsTable::new()),
95            tree: ArcSwap::from_pointee(KeyTreeSnapshot::default()),
96            dropped: AtomicU64::new(0),
97        })
98    }
99
100    /// Ingest one sample: stats update + broadcast. Hot path — one lock, no
101    /// tree work (that happens on the tick).
102    pub fn ingest(&self, view: SampleView, sn: Option<u32>) {
103        {
104            let mut stats = self.stats.lock().expect("stats lock");
105            stats.record(&view.key, view.payload.len(), sn, Instant::now());
106        }
107        // Send errors mean "no receiver right now" — not a failure.
108        let _ = self.tx.send(FleetEvent::Sample(Arc::new(view)));
109    }
110
111    pub fn node_event(&self, key: String, up: bool) {
112        let _ = self.tx.send(if up {
113            FleetEvent::NodeUp(key)
114        } else {
115            FleetEvent::NodeDown(key)
116        });
117    }
118
119    /// Rebuild the snapshot from the stats and announce it.
120    pub fn tick(&self) {
121        let snapshot = {
122            let stats = self.stats.lock().expect("stats lock");
123            KeyTreeSnapshot::build(&stats)
124        };
125        self.tree.store(Arc::new(snapshot));
126        let _ = self.tx.send(FleetEvent::StatsTick);
127    }
128
129    /// The latest immutable snapshot (lock-free pull).
130    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
131        self.tree.load_full()
132    }
133
134    /// Read access to the raw stats (hz/bw commands).
135    pub fn with_stats<R>(&self, f: impl FnOnce(&StatsTable) -> R) -> R {
136        f(&self.stats.lock().expect("stats lock"))
137    }
138
139    /// Total events dropped across all lagging receivers so far.
140    pub fn dropped(&self) -> u64 {
141        self.dropped.load(Ordering::Relaxed)
142    }
143
144    /// Subscribe to the event stream.
145    pub fn events(self: &Arc<Self>) -> EventStream {
146        EventStream {
147            rx: self.tx.subscribe(),
148            core: Arc::clone(self),
149        }
150    }
151}
152
153/// A receiver that surfaces lag as data: when this consumer falls behind the
154/// bounded channel, the next `recv` yields the count of samples it missed —
155/// dropped samples are never invisible (RFC 05 §3.1's honesty, applied to a
156/// UI).
157pub struct EventStream {
158    rx: broadcast::Receiver<FleetEvent>,
159    core: Arc<MonitorCore>,
160}
161
162/// An event, or how many this receiver just missed.
163#[derive(Debug, Clone)]
164pub enum StreamItem {
165    Event(FleetEvent),
166    Dropped(u64),
167}
168
169impl EventStream {
170    /// `None` when the monitor stopped.
171    pub async fn recv(&mut self) -> Option<StreamItem> {
172        match self.rx.recv().await {
173            Ok(ev) => Some(StreamItem::Event(ev)),
174            Err(broadcast::error::RecvError::Lagged(n)) => {
175                self.core.dropped.fetch_add(n, Ordering::Relaxed);
176                Some(StreamItem::Dropped(n))
177            }
178            Err(broadcast::error::RecvError::Closed) => None,
179        }
180    }
181}
182
183/// The wired monitor: subscribers + liveliness + tick task feeding a core.
184pub struct Monitor {
185    core: Arc<MonitorCore>,
186    tasks: Vec<tokio::task::JoinHandle<()>>,
187}
188
189impl Monitor {
190    /// Declare the spec's subscribers on `session` and start watching.
191    pub async fn start(session: &Session, spec: MonitorSpec) -> Result<Monitor> {
192        let core = MonitorCore::new(spec.capacity);
193        let mut tasks = Vec::new();
194
195        for selector in &spec.selectors {
196            let subscriber = session
197                .declare_subscriber(selector)
198                .await
199                .map_err(|e| anyhow!("subscribe {selector}: {e}"))?;
200            let core = Arc::clone(&core);
201            tasks.push(tokio::spawn(async move {
202                while let Ok(sample) = subscriber.recv_async().await {
203                    let sn = sample.source_info().map(|si| si.source_sn());
204                    core.ingest(
205                        SampleView {
206                            key: sample.key_expr().as_str().to_string(),
207                            payload: sample.payload().clone(),
208                            encoding: sample.encoding().to_string(),
209                            kind: sample.kind(),
210                            timestamp: sample.timestamp().copied(),
211                        },
212                        sn,
213                    );
214                }
215            }));
216        }
217
218        if let Some(liveliness_sel) = &spec.liveliness {
219            let subscriber = session
220                .liveliness()
221                .declare_subscriber(liveliness_sel)
222                .history(true)
223                .await
224                .map_err(|e| anyhow!("liveliness subscribe {liveliness_sel}: {e}"))?;
225            let core = Arc::clone(&core);
226            tasks.push(tokio::spawn(async move {
227                while let Ok(sample) = subscriber.recv_async().await {
228                    let key = sample.key_expr().as_str().to_string();
229                    core.node_event(key, sample.kind() == SampleKind::Put);
230                }
231            }));
232        }
233
234        {
235            let core = Arc::clone(&core);
236            let period = spec.stats_tick;
237            tasks.push(tokio::spawn(async move {
238                let mut interval = tokio::time::interval(period);
239                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
240                loop {
241                    interval.tick().await;
242                    core.tick();
243                }
244            }));
245        }
246
247        Ok(Monitor { core, tasks })
248    }
249
250    pub fn core(&self) -> &Arc<MonitorCore> {
251        &self.core
252    }
253
254    pub fn events(&self) -> EventStream {
255        self.core.events()
256    }
257
258    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
259        self.core.tree()
260    }
261
262    /// Stop watching (aborts the tasks; subscribers undeclare on drop).
263    pub fn stop(self) {
264        for t in &self.tasks {
265            t.abort();
266        }
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn view(key: &str, len: usize) -> SampleView {
275        SampleView {
276            key: key.to_string(),
277            payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
278            encoding: "zenoh/bytes".to_string(),
279            kind: SampleKind::Put,
280            timestamp: None,
281        }
282    }
283
284    #[tokio::test]
285    async fn events_flow_and_snapshots_rebuild_on_tick() {
286        let core = MonitorCore::new(8);
287        let mut events = core.events();
288        core.ingest(view("zs/v1/h-a/telemetry/x/m", 4), None);
289        core.tick();
290
291        let Some(StreamItem::Event(FleetEvent::Sample(s))) = events.recv().await else {
292            panic!("expected sample");
293        };
294        assert_eq!(s.key, "zs/v1/h-a/telemetry/x/m");
295        assert_eq!(s.payload.len(), 4);
296        let Some(StreamItem::Event(FleetEvent::StatsTick)) = events.recv().await else {
297            panic!("expected tick");
298        };
299        let snap = core.tree();
300        assert_eq!(snap.keys, 1);
301        assert_eq!(snap.root.subtree_count, 1);
302    }
303
304    /// The bounded-channel honesty contract: a lagging receiver is told how
305    /// many it missed — never a silent gap.
306    #[tokio::test]
307    async fn overflow_surfaces_as_dropped_counts() {
308        let core = MonitorCore::new(2);
309        let mut slow = core.events();
310        for i in 0..10 {
311            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
312        }
313        let Some(StreamItem::Dropped(n)) = slow.recv().await else {
314            panic!("expected a dropped count first");
315        };
316        assert!(n >= 8, "missed at least 8, reported {n}");
317        assert_eq!(core.dropped(), n);
318        // The stream then resumes with the retained tail.
319        let Some(StreamItem::Event(FleetEvent::Sample(_))) = slow.recv().await else {
320            panic!("expected a sample after the gap report");
321        };
322    }
323}