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//! [`StreamItem::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/// The publisher a sample came from, when its session attaches SourceInfo
28/// — the same signal the gap counter reads, surfaced (#120). All `Copy`:
29/// carrying it costs no allocation.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct SampleSource {
32 /// The publishing session's Zenoh id.
33 pub zid: zenoh::config::ZenohId,
34 /// The entity id within that session.
35 pub eid: u32,
36 /// The per-entity sequence number — what the gap counter diffs.
37 pub sn: u32,
38}
39
40/// One observed sample, cheap to clone (the payload is zenoh's refcounted
41/// buffer, not a copy — report §14's zero-copy discipline).
42#[derive(Debug, Clone)]
43pub struct SampleView {
44 /// Full wire key, as received (this session is un-namespaced).
45 pub key: String,
46 pub payload: zenoh::bytes::ZBytes,
47 /// The sample's declared encoding, verbatim.
48 pub encoding: String,
49 pub kind: SampleKind,
50 /// HLC timestamp when the publisher's session stamps one.
51 ///
52 /// Absent is common — a publisher whose session is not timestamping stamps
53 /// nothing — and absence must never be defaulted to an arrival time. This
54 /// is the *publisher's* clock; [`SampleView::received`] is ours, and the
55 /// two are never mixed (a consumer plotting a time axis states which one
56 /// it plotted).
57 pub timestamp: Option<zenoh::time::Timestamp>,
58 /// The sample's attachment, when it carried one — zenoh's refcounted
59 /// buffer, like the payload, so retaining it is a refcount bump and the
60 /// per-sample allocation floor stands (`docs/zero-copy.md` §4).
61 ///
62 /// `None` means the sample carried none: an attachment is a wire fact,
63 /// not a decode, and what arrived is a fact to show (#117).
64 pub attachment: Option<zenoh::bytes::ZBytes>,
65 /// The wire's actual QoS axes (#120) — always present: zenoh stamps
66 /// every sample with them, defaults included. A registry *declares* a
67 /// profile; these are what actually rode, and the two can disagree —
68 /// which is exactly what a frontend renders. All `Copy`.
69 ///
70 /// Deliberately absent: SHM-vs-raw buffer provenance. zenoh 1.9's
71 /// public API does not expose it on a received sample, and chasing it
72 /// through `zenoh::internal` is the dependency this crate refuses
73 /// (nuze's decoder is the cautionary tale).
74 pub priority: zenoh::qos::Priority,
75 pub congestion_control: zenoh::qos::CongestionControl,
76 pub reliability: zenoh::qos::Reliability,
77 pub express: bool,
78 /// The publishing entity, when SourceInfo rode the sample. `None` is
79 /// "the publisher's session does not attach it" — common, and not a
80 /// defect.
81 pub source: Option<SampleSource>,
82 /// Arrival, on **this observer's** monotonic clock — always available,
83 /// never wall-clock, and never a claim about when the sample was produced.
84 ///
85 /// Stamped per sample rather than per batch so a consumer that coalesces
86 /// (zengui ticks at 250 ms) can still space a 5 Hz key's samples truthfully
87 /// instead of collapsing a tick's worth onto one instant.
88 pub received: Instant,
89}
90
91impl SampleView {
92 /// Whether the wire's actual axes match a declared profile (RFC 04 §3)
93 /// — the declared-vs-observed comparison nobody else in the field can
94 /// render, because nobody else holds a registry that declares QoS.
95 pub fn qos_matches(&self, profile: zenkey::qos::QosProfile) -> bool {
96 self.priority == profile.priority()
97 && self.congestion_control == profile.congestion_control()
98 && self.reliability == profile.reliability()
99 && self.express == profile.express()
100 }
101}
102
103/// What the monitor emits.
104///
105/// Deliberately **no matching variant** (#38/#80 adoption note): zenoh 1.9
106/// has matching listeners on publishers and queriers only — a subscriber
107/// cannot ask "does anyone publish what I watch", so the monitor's watches
108/// have nothing honest to report here. Matching lives on the write facade's
109/// [`crate::Publication`] and on [`crate::RepeatingQuery`], the two entities
110/// this process declares that zenoh can answer for.
111#[derive(Debug, Clone)]
112pub enum FleetEvent {
113 Sample(Arc<SampleView>),
114 /// A liveliness token appeared (full wire key of the token).
115 NodeUp(String),
116 /// A liveliness token disappeared.
117 NodeDown(String),
118 /// The tree snapshot was rebuilt — pull it via [`Monitor::tree`].
119 StatsTick,
120 /// The watch set changed ([`Monitor::watch`]/[`Monitor::unwatch`]) —
121 /// coverage labels should refresh; pull the set via [`Monitor::watched`].
122 WatchChanged,
123 /// A seeded watch's seed phase resolved (issue #92): both seed paths of
124 /// [`Monitor::watch_seeded`] finished, with what each contributed.
125 /// Everything on this watch after this event is live-only — "seeding"
126 /// panes flip to "live" here, never on a guess.
127 WatchSeeded {
128 id: WatchId,
129 coverage: crate::seed::SeedCoverage,
130 },
131}
132
133/// What to watch.
134#[derive(Debug, Clone)]
135pub struct MonitorSpec {
136 /// Full wire selectors to subscribe to.
137 pub selectors: Vec<String>,
138 /// Also watch these liveliness selectors (with history: current tokens
139 /// arrive on join — no separate seed GET).
140 ///
141 /// A list, not a single selector, because one selector cannot express the
142 /// roster: `*` in the origin position never matches a verbatim service
143 /// origin (RFC 03 §4 **D4**), so the fleet sweep
144 /// `<base>/v1/*/state/*/alive` and `<base>/v1/@catalog/state/alive` are
145 /// necessarily two entries. A dashboard that watches only the first
146 /// renders "catalog dead" and "no entities" identically — the false
147 /// verdict RFC 05 §3.1 forbids.
148 pub liveliness: Vec<String>,
149 /// Snapshot cadence.
150 pub stats_tick: Duration,
151 /// Broadcast capacity: bound it to what an echo pane can drain; lag is
152 /// surfaced, never hidden.
153 pub capacity: usize,
154 /// How many distinct keys to keep statistics for. Least-recently-seen keys
155 /// are dropped past this, and the drops are counted
156 /// ([`MonitorCore::keys_evicted`]) — a long-running observer is bounded,
157 /// and says so (RFC 09 §5.1).
158 pub max_keys: usize,
159}
160
161impl Default for MonitorSpec {
162 fn default() -> Self {
163 MonitorSpec {
164 selectors: Vec::new(),
165 liveliness: Vec::new(),
166 stats_tick: Duration::from_millis(250),
167 capacity: 1024,
168 max_keys: crate::stats::DEFAULT_MAX_KEYS,
169 }
170 }
171}
172
173/// The monitor's shareable core: ingest on one side, events + snapshots on
174/// the other. Session wiring lives in [`Monitor`]; the core is pure and
175/// deterministically testable.
176pub struct MonitorCore {
177 tx: broadcast::Sender<FleetEvent>,
178 stats: Mutex<StatsTable>,
179 tree: ArcSwap<KeyTreeSnapshot>,
180 dropped: AtomicU64,
181}
182
183impl MonitorCore {
184 pub fn new(capacity: usize) -> Arc<MonitorCore> {
185 MonitorCore::bounded(capacity, crate::stats::DEFAULT_MAX_KEYS)
186 }
187
188 /// A core whose statistics table is bounded at `max_keys` distinct keys.
189 pub fn bounded(capacity: usize, max_keys: usize) -> Arc<MonitorCore> {
190 let (tx, _) = broadcast::channel(capacity.max(2));
191 Arc::new(MonitorCore {
192 tx,
193 stats: Mutex::new(StatsTable::with_capacity(max_keys)),
194 tree: ArcSwap::from_pointee(KeyTreeSnapshot::default()),
195 dropped: AtomicU64::new(0),
196 })
197 }
198
199 /// Ingest one sample: stats update + broadcast. Hot path — one lock, no
200 /// tree work (that happens on the tick).
201 pub fn ingest(&self, view: SampleView, sn: Option<u32>) {
202 {
203 // Observed *skewed* latency (#119): our wall clock minus the
204 // publisher's HLC — both halves this crate deliberately never
205 // mixes elsewhere, subtracted here on purpose and labeled as
206 // containing clock skew. Unstamped samples pass None and are
207 // counted, not defaulted (no latency ≠ zero latency).
208 let latency_us = view.timestamp.map(|t| {
209 let published = t.get_time().to_system_time();
210 match std::time::SystemTime::now().duration_since(published) {
211 Ok(d) => i64::try_from(d.as_micros()).unwrap_or(i64::MAX),
212 // The publisher's clock is ahead of ours: negative, and
213 // shown as such — that *is* the skew evidence.
214 Err(e) => -i64::try_from(e.duration().as_micros()).unwrap_or(i64::MAX),
215 }
216 });
217 let mut stats = self.stats.lock().expect("stats lock");
218 stats.record(
219 &view.key,
220 view.payload.len(),
221 sn,
222 Instant::now(),
223 latency_us,
224 );
225 }
226 // Send errors mean "no receiver right now" — not a failure.
227 let _ = self.tx.send(FleetEvent::Sample(Arc::new(view)));
228 }
229
230 pub fn node_event(&self, key: String, up: bool) {
231 let _ = self.tx.send(if up {
232 FleetEvent::NodeUp(key)
233 } else {
234 FleetEvent::NodeDown(key)
235 });
236 }
237
238 /// Rebuild the snapshot from the stats and announce it.
239 pub fn tick(&self) {
240 let snapshot = {
241 let stats = self.stats.lock().expect("stats lock");
242 KeyTreeSnapshot::build(&stats)
243 };
244 self.tree.store(Arc::new(snapshot));
245 let _ = self.tx.send(FleetEvent::StatsTick);
246 }
247
248 /// The latest immutable snapshot (lock-free pull).
249 pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
250 self.tree.load_full()
251 }
252
253 /// Read access to the raw stats (hz/bw commands).
254 pub fn with_stats<R>(&self, f: impl FnOnce(&StatsTable) -> R) -> R {
255 f(&self.stats.lock().expect("stats lock"))
256 }
257
258 /// Mutable access — watch retirement and tests.
259 pub fn with_stats_mut<R>(&self, f: impl FnOnce(&mut StatsTable) -> R) -> R {
260 f(&mut self.stats.lock().expect("stats lock"))
261 }
262
263 /// Keys retired from the table because their watch was released
264 /// (RFC 09 §5.1 O6 — see [`crate::stats::StatsTable::unwatched`]).
265 pub fn keys_unwatched(&self) -> u64 {
266 self.with_stats(|s| s.unwatched())
267 }
268
269 /// Total events dropped across all lagging receivers so far.
270 pub fn dropped(&self) -> u64 {
271 self.dropped.load(Ordering::Relaxed)
272 }
273
274 /// Distinct keys dropped from the statistics table to stay within its
275 /// bound. Non-zero means the key set on display is partial — report it
276 /// rather than letting a shrinking tree read as a quieting bus
277 /// (RFC 09 §5.1).
278 pub fn keys_evicted(&self) -> u64 {
279 self.with_stats(|s| s.evicted())
280 }
281
282 /// Subscribe to the event stream.
283 pub fn events(self: &Arc<Self>) -> EventStream {
284 EventStream {
285 rx: self.tx.subscribe(),
286 core: Arc::clone(self),
287 }
288 }
289}
290
291/// A receiver that surfaces lag as data: when this consumer falls behind the
292/// bounded channel, the next `recv` yields the count of samples it missed —
293/// dropped samples are never invisible (RFC 05 §3.1's honesty, applied to a
294/// UI).
295pub struct EventStream {
296 rx: broadcast::Receiver<FleetEvent>,
297 core: Arc<MonitorCore>,
298}
299
300/// An event, or how many this receiver just missed.
301#[derive(Debug, Clone)]
302pub enum StreamItem {
303 Event(FleetEvent),
304 Dropped(u64),
305}
306
307impl EventStream {
308 /// `None` when the monitor stopped.
309 pub async fn recv(&mut self) -> Option<StreamItem> {
310 match self.rx.recv().await {
311 Ok(ev) => Some(StreamItem::Event(ev)),
312 Err(broadcast::error::RecvError::Lagged(n)) => {
313 self.core.dropped.fetch_add(n, Ordering::Relaxed);
314 Some(StreamItem::Dropped(n))
315 }
316 Err(broadcast::error::RecvError::Closed) => None,
317 }
318 }
319}
320
321/// Opaque handle naming one active watch.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
323pub struct WatchId(u64);
324
325struct WatchEntry {
326 selector: String,
327 subscriber: zenoh::pubsub::Subscriber<()>,
328 /// The seed task, while a seeded watch's seed phase is still running.
329 /// Aborted on [`Monitor::unwatch`] so a released watch cannot keep
330 /// ingesting seed replies. (A dropped *monitor* lets it run out — it is
331 /// bounded by the seed timeout and feeds a core nobody reads.)
332 seed_task: Option<tokio::task::JoinHandle<()>>,
333}
334
335/// The wired monitor: a runtime-mutable watch set + liveliness + tick task
336/// feeding a core.
337///
338/// **Lazy by construction** (issue #84): `start` with empty
339/// `spec.selectors` declares *no data-plane subscribers at all* — only the
340/// zero-payload liveliness watches and the tick. Data flows only for what
341/// [`Monitor::watch`] was asked to observe, and [`Monitor::unwatch`]
342/// provably undeclares (an explicit, awaited undeclaration — not a dropped
343/// handle racing the network).
344pub struct Monitor {
345 core: Arc<MonitorCore>,
346 session: Session,
347 watches: tokio::sync::Mutex<std::collections::HashMap<WatchId, WatchEntry>>,
348 next_watch: AtomicU64,
349 tasks: Vec<tokio::task::JoinHandle<()>>,
350}
351
352impl std::fmt::Debug for Monitor {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 f.debug_struct("Monitor").finish_non_exhaustive()
355 }
356}
357
358impl Monitor {
359 /// Declare the spec's subscribers on `session` and start watching.
360 /// `spec.selectors` are simply the *initial* watches — `[]` is the lazy
361 /// start.
362 pub async fn start(session: &Session, spec: MonitorSpec) -> Result<Monitor> {
363 let core = MonitorCore::bounded(spec.capacity, spec.max_keys);
364 let mut tasks = Vec::new();
365
366 for liveliness_sel in &spec.liveliness {
367 let subscriber = session
368 .liveliness()
369 .declare_subscriber(liveliness_sel)
370 .history(true)
371 .await
372 .map_err(|e| anyhow!("liveliness subscribe {liveliness_sel}: {e}"))?;
373 let core = Arc::clone(&core);
374 tasks.push(tokio::spawn(async move {
375 while let Ok(sample) = subscriber.recv_async().await {
376 let key = sample.key_expr().as_str().to_string();
377 core.node_event(key, sample.kind() == SampleKind::Put);
378 }
379 }));
380 }
381
382 {
383 let core = Arc::clone(&core);
384 let period = spec.stats_tick;
385 tasks.push(tokio::spawn(async move {
386 let mut interval = tokio::time::interval(period);
387 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
388 loop {
389 interval.tick().await;
390 core.tick();
391 }
392 }));
393 }
394
395 let monitor = Monitor {
396 core,
397 session: session.clone(),
398 watches: tokio::sync::Mutex::new(std::collections::HashMap::new()),
399 next_watch: AtomicU64::new(0),
400 tasks,
401 };
402 for selector in &spec.selectors {
403 monitor.watch(selector).await?;
404 }
405 Ok(monitor)
406 }
407
408 /// Observe a selector: declares a callback subscriber feeding the core.
409 ///
410 /// The callback runs on zenoh's network thread and does exactly what the
411 /// old per-selector task did — one stats lock, one bounded broadcast
412 /// send — so a slow UI still cannot exert backpressure into the network
413 /// layer beyond the channel's bound.
414 pub async fn watch(&self, selector: &str) -> Result<WatchId> {
415 let core = Arc::clone(&self.core);
416 let subscriber = self
417 .session
418 .declare_subscriber(selector)
419 .callback(move |sample| {
420 let source = sample.source_info().map(|si| SampleSource {
421 zid: si.source_id().zid(),
422 eid: si.source_id().eid(),
423 sn: si.source_sn(),
424 });
425 core.ingest(
426 SampleView {
427 key: sample.key_expr().as_str().to_string(),
428 payload: sample.payload().clone(),
429 encoding: sample.encoding().to_string(),
430 kind: sample.kind(),
431 timestamp: sample.timestamp().copied(),
432 attachment: sample.attachment().cloned(),
433 priority: sample.priority(),
434 congestion_control: sample.congestion_control(),
435 reliability: sample.reliability(),
436 express: sample.express(),
437 source,
438 received: Instant::now(),
439 },
440 source.map(|s| s.sn),
441 );
442 })
443 .await
444 .map_err(|e| anyhow!("subscribe {selector}: {e}"))?;
445 let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
446 self.watches.lock().await.insert(
447 id,
448 WatchEntry {
449 selector: selector.to_string(),
450 subscriber,
451 seed_task: None,
452 },
453 );
454 let _ = self.core.tx.send(FleetEvent::WatchChanged);
455 Ok(id)
456 }
457
458 /// Observe a selector **with a correct seed phase** (issue #92; the
459 /// RFC 04 §3.2 discipline of [`crate::seed_subscribe`], run through this
460 /// monitor's bounded broadcast):
461 ///
462 /// - the subscriber is declared first, then both seed paths run as
463 /// bounded GETs (`@adv` caches for live publishers, the selector
464 /// itself for router storages — the crashed-producer case);
465 /// - one per-key LWW merge spans seed *and* live samples until the
466 /// boundary, so a transition in the seed window lands exactly once and
467 /// a stale seed cannot regress a key. Suppressions are counted in the
468 /// coverage, never silently absorbed (O6) — and they are *not* part of
469 /// `Dropped(n)`, which counts only broadcast lag;
470 /// - [`FleetEvent::WatchSeeded`] fires once **both** paths resolve,
471 /// carrying this id and the [`crate::SeedCoverage`]. After it, the
472 /// merge is dropped and live samples flow untouched (the merge map is
473 /// a seed-phase structure, not a per-watch leak).
474 pub async fn watch_seeded(
475 &self,
476 selector: &str,
477 policy: crate::seed::SeedPolicy,
478 ) -> Result<WatchId> {
479 use crate::seed::{Merge, SeedCoverage, cache_selector, seed_get, view_of};
480
481 // The merge gate: `Some` while seeding (both live callback and seed
482 // replies pass `admit`), swapped to `None` at the boundary.
483 let gate: Arc<arc_swap::ArcSwapOption<Merge>> =
484 Arc::new(arc_swap::ArcSwapOption::from_pointee(Merge::new()));
485
486 // 1) The subscriber, FIRST (RFC 04 §3.2).
487 let core = Arc::clone(&self.core);
488 let cb_gate = Arc::clone(&gate);
489 let subscriber = self
490 .session
491 .declare_subscriber(selector)
492 .callback(move |sample| {
493 let sn = sample.source_info().map(|si| si.source_sn());
494 let view = view_of(&sample);
495 if let Some(merge) = cb_gate.load_full()
496 && !merge.admit(&view)
497 {
498 return;
499 }
500 core.ingest(view, sn);
501 })
502 .await
503 .map_err(|e| anyhow!("seeded subscribe {selector}: {e}"))?;
504
505 // 2) The seed GETs, AFTER — registered as this watch's seed task so
506 // `unwatch` during the seed phase aborts it.
507 let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
508 let seed_task = {
509 let session = self.session.clone();
510 let core = Arc::clone(&self.core);
511 let selector = selector.to_string();
512 tokio::spawn(async move {
513 let merge = gate
514 .load_full()
515 .expect("gate holds the merge while seeding");
516 let history = async {
517 if policy.history {
518 let sel = cache_selector(&selector);
519 Some(
520 seed_get(&session, &sel, policy.timeout, &merge, |view| {
521 core.ingest(view, None);
522 })
523 .await,
524 )
525 } else {
526 None
527 }
528 };
529 let storage = async {
530 if policy.storage {
531 Some(
532 seed_get(&session, &selector, policy.timeout, &merge, |view| {
533 core.ingest(view, None);
534 })
535 .await,
536 )
537 } else {
538 None
539 }
540 };
541 let (history_replies, storage_replies) = tokio::join!(history, storage);
542 let coverage = SeedCoverage {
543 history_replies,
544 storage_replies,
545 superseded: merge.superseded(),
546 };
547 gate.store(None);
548 // Seeded keys should be visible on the very tick that
549 // announces the boundary, not one tick later.
550 core.tick();
551 let _ = core.tx.send(FleetEvent::WatchSeeded { id, coverage });
552 })
553 };
554 self.watches.lock().await.insert(
555 id,
556 WatchEntry {
557 selector: selector.to_string(),
558 subscriber,
559 seed_task: Some(seed_task),
560 },
561 );
562 let _ = self.core.tx.send(FleetEvent::WatchChanged);
563 Ok(id)
564 }
565
566 /// Stop observing: undeclares the subscriber (awaited to completion — the
567 /// teardown is acknowledged, not racing a drop), then retires statistics
568 /// for keys no remaining watch covers. Retired keys are **counted**
569 /// ([`crate::stats::StatsTable::unwatched`]): a shrinking key set must
570 /// never read as a quieting bus (RFC 09 §5.1 O6).
571 pub async fn unwatch(&self, id: WatchId) -> Result<()> {
572 let mut entry = {
573 let mut watches = self.watches.lock().await;
574 watches
575 .remove(&id)
576 .ok_or_else(|| anyhow!("unknown watch id {id:?}"))?
577 };
578 // A released watch must not keep ingesting seed replies: the seed
579 // task dies with the watch (its boundary event simply never fires —
580 // the watch is gone, so there is nothing left to flip to "live").
581 if let Some(task) = entry.seed_task.take() {
582 task.abort();
583 }
584 entry
585 .subscriber
586 .undeclare()
587 .await
588 .map_err(|e| anyhow!("undeclare {}: {e}", entry.selector))?;
589 let kept: Vec<String> = {
590 let watches = self.watches.lock().await;
591 watches.values().map(|w| w.selector.clone()).collect()
592 };
593 self.core.with_stats_mut(|stats| {
594 stats.retire_unwatched(&entry.selector, &kept);
595 });
596 self.core.tick();
597 let _ = self.core.tx.send(FleetEvent::WatchChanged);
598 Ok(())
599 }
600
601 /// The active watch set.
602 pub async fn watched(&self) -> Vec<(WatchId, String)> {
603 let watches = self.watches.lock().await;
604 let mut v: Vec<(WatchId, String)> = watches
605 .iter()
606 .map(|(id, w)| (*id, w.selector.clone()))
607 .collect();
608 v.sort();
609 v
610 }
611
612 pub fn core(&self) -> &Arc<MonitorCore> {
613 &self.core
614 }
615
616 pub fn events(&self) -> EventStream {
617 self.core.events()
618 }
619
620 pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
621 self.core.tree()
622 }
623
624 /// Stop watching. Equivalent to dropping the monitor — kept as an explicit
625 /// verb for call sites that want to say so.
626 pub fn stop(self) {
627 drop(self);
628 }
629}
630
631/// Dropping a monitor stops it: the ingest tasks are aborted and the
632/// subscribers undeclare.
633///
634/// This is not a nicety. A `JoinHandle` merely *detaches* on drop, so without
635/// this impl every monitor that goes out of scope leaks a live subscriber and
636/// its ingest task for the lifetime of the session. `zenctl` never noticed —
637/// it calls [`Monitor::stop`] once and exits — but a GUI re-scopes its
638/// subscription whenever the user changes what they are watching, dropping and
639/// rebuilding the monitor each time.
640impl Drop for Monitor {
641 fn drop(&mut self) {
642 for t in &self.tasks {
643 t.abort();
644 }
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 fn view(key: &str, len: usize) -> SampleView {
653 SampleView {
654 key: key.to_string(),
655 payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
656 encoding: "zenoh/bytes".to_string(),
657 kind: SampleKind::Put,
658 timestamp: None,
659 attachment: None,
660 priority: zenoh::qos::Priority::DEFAULT,
661 congestion_control: zenoh::qos::CongestionControl::DEFAULT,
662 reliability: zenoh::qos::Reliability::DEFAULT,
663 express: false,
664 source: None,
665 received: Instant::now(),
666 }
667 }
668
669 #[tokio::test]
670 async fn events_flow_and_snapshots_rebuild_on_tick() {
671 let core = MonitorCore::new(8);
672 let mut events = core.events();
673 core.ingest(view("zs/v1/h-a/telemetry/x/m", 4), None);
674 core.tick();
675
676 let Some(StreamItem::Event(FleetEvent::Sample(s))) = events.recv().await else {
677 panic!("expected sample");
678 };
679 assert_eq!(s.key, "zs/v1/h-a/telemetry/x/m");
680 assert_eq!(s.payload.len(), 4);
681 let Some(StreamItem::Event(FleetEvent::StatsTick)) = events.recv().await else {
682 panic!("expected tick");
683 };
684 let snap = core.tree();
685 assert_eq!(snap.keys, 1);
686 assert_eq!(snap.root.subtree_count, 1);
687 }
688
689 /// The bounded-channel honesty contract: a lagging receiver is told how
690 /// many it missed — never a silent gap.
691 #[tokio::test]
692 async fn overflow_surfaces_as_dropped_counts() {
693 let core = MonitorCore::new(2);
694 let mut slow = core.events();
695 for i in 0..10 {
696 core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
697 }
698 let Some(StreamItem::Dropped(n)) = slow.recv().await else {
699 panic!("expected a dropped count first");
700 };
701 assert!(n >= 8, "missed at least 8, reported {n}");
702 assert_eq!(core.dropped(), n);
703 // The stream then resumes with the retained tail.
704 let Some(StreamItem::Event(FleetEvent::Sample(_))) = slow.recv().await else {
705 panic!("expected a sample after the gap report");
706 };
707 }
708}