Skip to main content

zenkey_fleet/bus/
monitor.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 crate::{Error, Result};
19use arc_swap::ArcSwap;
20use tokio::sync::broadcast;
21use zenoh::Session;
22use zenoh::sample::SampleKind;
23
24use crate::model::retain::{Retention, RetentionBudget, RetentionStats};
25use crate::model::stats::StatsTable;
26use crate::model::tree::KeyTreeSnapshot;
27
28/// The publisher a sample came from, when its session attaches SourceInfo
29/// — the same signal the gap counter reads, surfaced (#120). All `Copy`:
30/// carrying it costs no allocation.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct SampleSource {
33    /// The publishing session's Zenoh id.
34    pub zid: zenoh::config::ZenohId,
35    /// The entity id within that session.
36    pub eid: u32,
37    /// The per-entity sequence number — what the gap counter diffs.
38    pub sn: u32,
39}
40
41/// Who stamped a sample's HLC (issue #213, RFC 09 §5.1 **O7**).
42///
43/// zenoh stamps at the **first node with timestamping enabled**, not
44/// necessarily at the publisher: `timestamping.enabled` is mode-dependent and
45/// documented as "whether data messages should be timestamped *if not
46/// already*", so on a fleet configured `{ router: true }` an unstamped sample
47/// picks up a **router's** clock on the way past. A tool that calls that
48/// "the publisher's HLC" is reporting a different measurement than the one it
49/// names, and every latency built on it inherits the mislabel.
50///
51/// The comparison is exact rather than heuristic: a `Timestamp`'s id *is* a
52/// [`zenoh::time::TimestampId`] (= `uhlc::ID`), a `ZenohId` is a transparent
53/// newtype over the same type, and zenoh provides the conversion.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum StampProvenance {
56    /// The publishing session stamped it: this is the publisher's own clock.
57    SelfStamped,
58    /// Another node stamped it — commonly a router with timestamping enabled.
59    /// The HLC is *that* node's clock, and a latency computed from it measures
60    /// stamper → observer, not publisher → observer.
61    Foreign { stamper: zenoh::time::TimestampId },
62    /// Stamped, but the sample carried no `SourceInfo`, so there is nothing to
63    /// compare the stamper against. Not "foreign" — unknown (O4).
64    Unattributable { stamper: zenoh::time::TimestampId },
65}
66
67impl StampProvenance {
68    /// The stamping node, when the sample said who it was.
69    pub fn stamper(self) -> Option<zenoh::time::TimestampId> {
70        match self {
71            StampProvenance::SelfStamped => None,
72            StampProvenance::Foreign { stamper } | StampProvenance::Unattributable { stamper } => {
73                Some(stamper)
74            }
75        }
76    }
77
78    /// Judge one sample's stamp against the publisher it claims to come from.
79    fn of(timestamp: &zenoh::time::Timestamp, source: Option<SampleSource>) -> StampProvenance {
80        let stamper = *timestamp.get_id();
81        match source {
82            Some(src) if stamper == zenoh::time::TimestampId::from(src.zid) => {
83                StampProvenance::SelfStamped
84            }
85            Some(_) => StampProvenance::Foreign { stamper },
86            None => StampProvenance::Unattributable { stamper },
87        }
88    }
89}
90
91/// One observed sample, cheap to clone (the payload is zenoh's refcounted
92/// buffer, not a copy — report §14's zero-copy discipline).
93#[derive(Debug, Clone)]
94pub struct SampleView {
95    /// Full wire key, as received (this session is un-namespaced).
96    pub key: String,
97    pub payload: zenoh::bytes::ZBytes,
98    /// The sample's declared encoding, verbatim.
99    pub encoding: String,
100    pub kind: SampleKind,
101    /// HLC timestamp, when the sample carried one.
102    ///
103    /// Absent is common — nothing on the path had timestamping enabled — and
104    /// absence must never be defaulted to an arrival time. This is *not*
105    /// necessarily the publisher's clock: see [`SampleView::stamped_by`] for
106    /// whose it is. [`SampleView::received`] is ours, and the two are never
107    /// mixed (a consumer plotting a time axis states which one it plotted).
108    pub timestamp: Option<zenoh::time::Timestamp>,
109    /// Who stamped [`SampleView::timestamp`] — `None` exactly when it is.
110    ///
111    /// The stamper's identity rode on every sample all along and was thrown
112    /// away, which is how "the publisher's HLC" survived as a description of
113    /// a number that is often a router's (issue #213).
114    pub stamped_by: Option<StampProvenance>,
115    /// The sample's attachment, when it carried one — zenoh's refcounted
116    /// buffer, like the payload, so retaining it is a refcount bump and the
117    /// per-sample allocation floor stands (`docs/zero-copy.md` §4).
118    ///
119    /// `None` means the sample carried none: an attachment is a wire fact,
120    /// not a decode, and what arrived is a fact to show (#117).
121    pub attachment: Option<zenoh::bytes::ZBytes>,
122    /// The wire's actual QoS axes (#120) — always present: zenoh stamps
123    /// every sample with them, defaults included. A registry *declares* a
124    /// profile; these are what actually rode, and the two can disagree —
125    /// which is exactly what a frontend renders. All `Copy`.
126    ///
127    /// Deliberately absent: SHM-vs-raw buffer provenance. zenoh 1.9's
128    /// public API does not expose it on a received sample, and chasing it
129    /// through `zenoh::internal` is the dependency this crate refuses
130    /// (nuze's decoder is the cautionary tale).
131    pub priority: zenoh::qos::Priority,
132    pub congestion_control: zenoh::qos::CongestionControl,
133    pub reliability: zenoh::qos::Reliability,
134    pub express: bool,
135    /// The publishing entity, when SourceInfo rode the sample. `None` is
136    /// "the publisher's session does not attach it" — common, and not a
137    /// defect.
138    pub source: Option<SampleSource>,
139    /// Arrival, on **this observer's** monotonic clock — always available,
140    /// never wall-clock, and never a claim about when the sample was produced.
141    ///
142    /// Stamped per sample rather than per batch so a consumer that coalesces
143    /// (zengui ticks at 250 ms) can still space a 5 Hz key's samples truthfully
144    /// instead of collapsing a tick's worth onto one instant.
145    pub received: Instant,
146}
147
148impl SampleView {
149    /// Build a view from a received sample.
150    ///
151    /// The one place this conversion lives. It was written out by hand twice —
152    /// here and on the seed/GET-reply path — and the second copy is exactly
153    /// the kind of site that silently misses a new field: adding stamp
154    /// provenance to only one of them would have left seeded samples
155    /// unattributed for no stated reason (issue #213).
156    pub fn of(sample: &zenoh::sample::Sample) -> SampleView {
157        let source = sample.source_info().map(|si| SampleSource {
158            zid: si.source_id().zid(),
159            eid: si.source_id().eid(),
160            sn: si.source_sn(),
161        });
162        let timestamp = sample.timestamp().copied();
163        SampleView {
164            key: sample.key_expr().as_str().to_string(),
165            payload: sample.payload().clone(),
166            encoding: sample.encoding().to_string(),
167            kind: sample.kind(),
168            stamped_by: timestamp.as_ref().map(|t| StampProvenance::of(t, source)),
169            timestamp,
170            attachment: sample.attachment().cloned(),
171            priority: sample.priority(),
172            congestion_control: sample.congestion_control(),
173            reliability: sample.reliability(),
174            express: sample.express(),
175            source,
176            // Arrival, not production: a sample is *received* now, however old
177            // the value it carries is. The HLC above is the only thing that
178            // speaks for when it was produced, and it is often absent.
179            received: Instant::now(),
180        }
181    }
182
183    /// Whether the wire's actual axes match a declared profile (RFC 04 §3)
184    /// — the declared-vs-observed comparison nobody else in the field can
185    /// render, because nobody else holds a registry that declares QoS.
186    pub fn qos_matches(&self, profile: zenkey::qos::QosProfile) -> bool {
187        self.priority == profile.priority()
188            && self.congestion_control == profile.congestion_control()
189            && self.reliability == profile.reliability()
190            && self.express == profile.express()
191    }
192}
193
194/// What the monitor emits.
195///
196/// Deliberately **no matching variant** (#38/#80 adoption note): zenoh 1.9
197/// has matching listeners on publishers and queriers only — a subscriber
198/// cannot ask "does anyone publish what I watch", so the monitor's watches
199/// have nothing honest to report here. Matching lives on the write facade's
200/// [`crate::Publication`] and on [`crate::RepeatingQuery`], the two entities
201/// this process declares that zenoh can answer for.
202#[derive(Debug, Clone)]
203pub enum FleetEvent {
204    Sample(Arc<SampleView>),
205    /// A liveliness token appeared (full wire key of the token).
206    NodeUp(String),
207    /// A liveliness token disappeared.
208    NodeDown(String),
209    /// The tree snapshot was rebuilt — pull it via [`Monitor::tree`].
210    StatsTick,
211    /// The watch set changed ([`Monitor::watch`]/[`Monitor::unwatch`]) —
212    /// coverage labels should refresh; pull the set via [`Monitor::watched`].
213    WatchChanged,
214    /// A seeded watch's seed phase resolved (issue #92): both seed paths of
215    /// [`Monitor::watch_seeded`] finished, with what each contributed.
216    /// Everything on this watch after this event is live-only — "seeding"
217    /// panes flip to "live" here, never on a guess.
218    WatchSeeded {
219        id: WatchId,
220        coverage: crate::report::SeedCoverage,
221    },
222}
223
224/// What to watch.
225#[derive(Debug, Clone)]
226pub struct MonitorSpec {
227    /// Full wire selectors to subscribe to.
228    pub selectors: Vec<String>,
229    /// Also watch these liveliness selectors (with history: current tokens
230    /// arrive on join — no separate seed GET).
231    ///
232    /// A list, not a single selector, because one selector cannot express the
233    /// roster: `*` in the origin position never matches a verbatim service
234    /// origin (RFC 03 §4 **D4**), so the fleet sweep
235    /// `<base>/v1/*/state/*/alive` and `<base>/v1/@catalog/state/alive` are
236    /// necessarily two entries. A dashboard that watches only the first
237    /// renders "catalog dead" and "no entities" identically — the false
238    /// verdict RFC 05 §3.1 forbids.
239    pub liveliness: Vec<String>,
240    /// Snapshot cadence.
241    pub stats_tick: Duration,
242    /// Broadcast capacity: bound it to what an echo pane can drain; lag is
243    /// surfaced, never hidden.
244    pub capacity: usize,
245    /// How many distinct keys to keep statistics for. Least-recently-seen keys
246    /// are dropped past this, and the drops are counted
247    /// ([`MonitorCore::keys_evicted`]) — a long-running observer is bounded,
248    /// and says so (RFC 09 §5.1).
249    pub max_keys: usize,
250}
251
252impl Default for MonitorSpec {
253    fn default() -> Self {
254        MonitorSpec {
255            selectors: Vec::new(),
256            liveliness: Vec::new(),
257            stats_tick: Duration::from_millis(250),
258            capacity: 1024,
259            max_keys: crate::model::bounded::DEFAULT_MAX_KEYS,
260        }
261    }
262}
263
264/// The monitor's shareable core: ingest on one side, events + snapshots on
265/// the other. Session wiring lives in [`Monitor`]; the core is pure and
266/// deterministically testable.
267pub struct MonitorCore {
268    tx: broadcast::Sender<FleetEvent>,
269    stats: Mutex<StatsTable>,
270    tree: ArcSwap<KeyTreeSnapshot>,
271    dropped: AtomicU64,
272    /// The retained window (#217): recent samples off the same ingest path,
273    /// bounded by bytes *and* age. Its own mutex, never held with the stats
274    /// lock — the two bounds are different facts and different contention.
275    retain: Mutex<Retention>,
276    /// Monotonic tick ordering, so an **older** fold can never overwrite a
277    /// newer snapshot.
278    ///
279    /// #330 moved the periodic fold to the blocking pool, which opened the
280    /// window: `tick_off_runtime` copies the rows, hands the fold away, and
281    /// publishes when it comes back — so a synchronous `tick()` taken *later*
282    /// (a seed boundary, an unwatch) can publish first and then be overwritten
283    /// by the earlier fold's stale result. The seed test caught it as
284    /// "the seeded key is already in the tree at the boundary tick" failing
285    /// with an empty tree while its own coverage said the reply had arrived.
286    ///
287    /// A `Mutex` rather than an atomic pair: the check and the store must be
288    /// one step, and it is only ever taken on the *write* path — a tick, not
289    /// a read. Readers stay lock-free through the `ArcSwap`.
290    tick_seq: AtomicU64,
291    published_seq: Mutex<u64>,
292}
293
294impl MonitorCore {
295    pub fn new(capacity: usize) -> Arc<MonitorCore> {
296        MonitorCore::bounded(capacity, crate::model::bounded::DEFAULT_MAX_KEYS)
297    }
298
299    /// A core whose statistics table is bounded at `max_keys` distinct keys.
300    pub fn bounded(capacity: usize, max_keys: usize) -> Arc<MonitorCore> {
301        let (tx, _) = broadcast::channel(capacity.max(2));
302        Arc::new(MonitorCore {
303            tx,
304            stats: Mutex::new(StatsTable::with_capacity(max_keys)),
305            tree: ArcSwap::from_pointee(KeyTreeSnapshot::default()),
306            dropped: AtomicU64::new(0),
307            retain: Mutex::new(Retention::new(RetentionBudget::default())),
308            tick_seq: AtomicU64::new(0),
309            published_seq: Mutex::new(0),
310        })
311    }
312
313    /// Ingest one sample: stats update + retention + broadcast. Hot path —
314    /// two short locks, no tree work (that happens on the tick).
315    pub fn ingest(&self, view: SampleView, sn: Option<u32>) {
316        self.ingest_at(
317            Arc::new(view),
318            sn,
319            Instant::now(),
320            std::time::SystemTime::now(),
321        );
322    }
323
324    /// [`MonitorCore::ingest`] with **both** clocks injected (#217).
325    ///
326    /// Replay rebuilds feed this with the **capture clock** — the row's `t`
327    /// offset from the load epoch, on both axes: `now` for the monotonic
328    /// fold, `wall` for the skewed-latency subtraction — rather than the
329    /// live clocks, which is what makes a rebuild deterministic down to the
330    /// EWMA rates and the latency window: the same rows at the same instants
331    /// fold to bit-identical statistics, however fast the rebuild loop runs.
332    /// (Before `wall` was threaded, the latency read the *live* wall clock
333    /// even under an injected `now`, so a rebuild folded arrival-time
334    /// garbage — deep-review D2.)
335    pub fn ingest_at(
336        &self,
337        view: Arc<SampleView>,
338        sn: Option<u32>,
339        now: Instant,
340        wall: std::time::SystemTime,
341    ) {
342        {
343            // Observed *skewed* latency (#119): our wall clock minus the
344            // sample's HLC — both halves this crate deliberately never mixes
345            // elsewhere, subtracted here on purpose and labeled as containing
346            // clock skew. Unstamped samples pass None and are counted, not
347            // defaulted (no latency ≠ zero latency).
348            //
349            // The class rides with the number (#213), because the HLC is not
350            // always the publisher's: whichever node stamped it is the one
351            // this measures from, and three populations that mean different
352            // things must not land in one median.
353            let latency = view.timestamp.as_ref().map(|t| {
354                let stamped = t.get_time().to_system_time();
355                let us = match wall.duration_since(stamped) {
356                    Ok(d) => i64::try_from(d.as_micros()).unwrap_or(i64::MAX),
357                    // The stamping node's clock is ahead of ours: negative,
358                    // and shown as such — that *is* the skew evidence.
359                    Err(e) => -i64::try_from(e.duration().as_micros()).unwrap_or(i64::MAX),
360                };
361                let class = match view.stamped_by {
362                    Some(StampProvenance::SelfStamped) => {
363                        crate::model::stats::StampClass::SelfStamped
364                    }
365                    Some(StampProvenance::Foreign { .. }) => {
366                        crate::model::stats::StampClass::Foreign
367                    }
368                    _ => crate::model::stats::StampClass::Unattributable,
369                };
370                (us, class)
371            });
372            let stamper = view.stamped_by.and_then(StampProvenance::stamper);
373            let mut stats = self.stats.lock().expect("stats lock");
374            stats.record(&view.key, view.payload.len(), sn, now, latency, stamper);
375        }
376        // The retained window (#217): an Arc clone, off the same path the
377        // broadcast rides — the ring can never disagree with what was
378        // ingested, and retaining costs a refcount bump, not a copy.
379        self.retain
380            .lock()
381            .expect("retain lock")
382            .push(Arc::clone(&view), now);
383        // Send errors mean "no receiver right now" — not a failure.
384        let _ = self.tx.send(FleetEvent::Sample(view));
385    }
386
387    pub fn node_event(&self, key: String, up: bool) {
388        let _ = self.tx.send(if up {
389            FleetEvent::NodeUp(key)
390        } else {
391            FleetEvent::NodeDown(key)
392        });
393    }
394
395    /// Rebuild the snapshot from the stats and announce it.
396    ///
397    /// **Two phases, and the split is the whole point** (#330). The stats
398    /// mutex is the one [`ingest`](Self::ingest) takes on zenoh's network
399    /// callback thread, so whatever this holds it for, the network layer
400    /// waits for. It therefore holds it for the O(keys) row copy
401    /// ([`StatsTable::rows`]) and folds the tree — O(keys × chunks) of
402    /// `BTreeMap` descents, a `String` per new node, ~300 000 map operations
403    /// at the 50 000-key bound — after releasing it. Four times a second the
404    /// old shape held the lock for the whole rebuild, which made
405    /// [`Monitor::watch`]'s promise that a slow UI cannot exert backpressure
406    /// into the network layer false four times a second.
407    ///
408    /// The fold still runs on the calling thread here. The periodic tick
409    /// takes [`tick_off_runtime`](Self::tick_off_runtime) instead, which puts
410    /// it on the blocking pool where that much CPU belongs.
411    pub fn tick(&self) {
412        let (rows, seq) = self.stats_rows();
413        self.publish(KeyTreeSnapshot::fold(rows), seq);
414    }
415
416    /// [`tick`](Self::tick) with the fold on the blocking pool (#330): the
417    /// copy is taken here, the CPU is spent on a blocking thread, and the
418    /// runtime's workers stay free for the drains they exist for. Used by the
419    /// stats-tick task; `tick` remains the synchronous form for the paths
420    /// that publish a snapshot as part of another operation (a seed boundary,
421    /// an unwatch).
422    pub async fn tick_off_runtime(&self) {
423        let (rows, seq) = self.stats_rows();
424        match tokio::task::spawn_blocking(move || KeyTreeSnapshot::fold(rows)).await {
425            Ok(snapshot) => self.publish(snapshot, seq),
426            // A blocking-pool panic must not take the tick task with it: the
427            // snapshot simply does not advance this tick, and says so.
428            Err(e) => tracing::warn!("key-tree fold: {e}"),
429        }
430    }
431
432    /// The rows the fold needs — the entire critical section of a tick —
433    /// stamped with the order they were taken in.
434    fn stats_rows(&self) -> (crate::model::tree::TreeRows, u64) {
435        // The sequence is taken *with* the rows, under the stats lock, so two
436        // ticks can never disagree about which of them saw the newer table.
437        let stats = self.stats.lock().expect("stats lock");
438        let seq = self.tick_seq.fetch_add(1, Ordering::Relaxed) + 1;
439        (stats.rows(), seq)
440    }
441
442    /// Publish a folded snapshot and announce it. Lock-free: an `ArcSwap`
443    /// store and a bounded send.
444    fn publish(&self, snapshot: KeyTreeSnapshot, seq: u64) {
445        let mut published = self.published_seq.lock().expect("tick seq lock");
446        if seq <= *published {
447            // An older fold finished last. Dropping it is the whole point: the
448            // newer snapshot is already the truth, and storing this one would
449            // walk the tree backwards (#330's window).
450            return;
451        }
452        *published = seq;
453        self.tree.store(Arc::new(snapshot));
454        let _ = self.tx.send(FleetEvent::StatsTick);
455    }
456
457    /// The latest immutable snapshot (lock-free pull).
458    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
459        self.tree.load_full()
460    }
461
462    /// Read access to the raw stats (hz/bw commands).
463    pub fn with_stats<R>(&self, f: impl FnOnce(&StatsTable) -> R) -> R {
464        f(&self.stats.lock().expect("stats lock"))
465    }
466
467    /// Mutable access — watch retirement and tests.
468    pub fn with_stats_mut<R>(&self, f: impl FnOnce(&mut StatsTable) -> R) -> R {
469        f(&mut self.stats.lock().expect("stats lock"))
470    }
471
472    /// Keys retired from the table because their watch was released
473    /// (RFC 09 §5.1 O6 — see [`crate::model::stats::StatsTable::unwatched`]).
474    pub fn keys_unwatched(&self) -> u64 {
475        self.with_stats(|s| s.unwatched())
476    }
477
478    /// Total events dropped across all lagging receivers so far.
479    pub fn dropped(&self) -> u64 {
480        self.dropped.load(Ordering::Relaxed)
481    }
482
483    /// Distinct keys dropped from the statistics table to stay within its
484    /// bound. Non-zero means the key set on display is partial — report it
485    /// rather than letting a shrinking tree read as a quieting bus
486    /// (RFC 09 §5.1).
487    pub fn keys_evicted(&self) -> u64 {
488        self.with_stats(|s| s.evicted())
489    }
490
491    /// The retained window, oldest first (#217): `Arc` clones of every
492    /// sample still inside both retention budgets. This is what the GUI's
493    /// retained scrub rebuilds panes from, and what "save window as `.zrec`"
494    /// writes — the same rows either way.
495    ///
496    /// Covers only **watched** keys by construction: the ring sits on the
497    /// ingest path, and nothing unwatched is ever ingested. A consumer that
498    /// presents this window MUST say so (RFC 09 §5.1 O5 — a retained window
499    /// over three watches is not a retained window over the bus).
500    ///
501    /// **What this read costs the network thread** (#331): the retain mutex
502    /// is [`ingest`](Self::ingest)'s, taken on zenoh's callback thread, so a
503    /// read that walked the window blocked the network layer for as long as
504    /// the window was long — 64 MiB of 256-byte samples is ~260 000 refcount
505    /// atomics, and zengui calls this from `update()`. The ring is chunked
506    /// instead ([`crate::model::retain`]): under the lock this clones the
507    /// sealed chunks' pointers and the open tail — bounded by
508    /// `window / 1024 + 1024`, independent of payload — and the flatten into
509    /// the returned slice happens **after** the guard is dropped. The result
510    /// is an `Arc<[_]>` so passing the window on costs nothing again.
511    pub fn retained(&self) -> Arc<[Arc<SampleView>]> {
512        // Two statements, deliberately: the guard is dropped at the end of
513        // this one, and only then does the O(window) flatten run.
514        let parts = self
515            .retain
516            .lock()
517            .expect("retain lock")
518            .parts(Instant::now());
519        parts.flatten()
520    }
521
522    /// The retained window's account of itself: budget in force, what it
523    /// holds, and what each bound cost — `evicted` (byte budget) apart from
524    /// `expired` (age), both apart from [`MonitorCore::dropped`],
525    /// [`MonitorCore::keys_evicted`] and [`MonitorCore::keys_unwatched`]
526    /// (RFC 09 §5.1 O6; v1.18 R1 forbids folding the kinds).
527    pub fn retention(&self) -> RetentionStats {
528        self.retain
529            .lock()
530            .expect("retain lock")
531            .stats(Instant::now())
532    }
533
534    /// Change the retention budget in force; applied from the next push or
535    /// read. The default ([`RetentionBudget::default`]) is 64 MiB / 2 min.
536    pub fn set_retention_budget(&self, budget: RetentionBudget) {
537        self.retain.lock().expect("retain lock").set_budget(budget);
538    }
539
540    /// Subscribe to the event stream.
541    pub fn events(self: &Arc<Self>) -> EventStream {
542        EventStream {
543            rx: self.tx.subscribe(),
544            core: Arc::clone(self),
545        }
546    }
547}
548
549/// A receiver that surfaces lag as data: when this consumer falls behind the
550/// bounded channel, the next `recv` yields the count of samples it missed —
551/// dropped samples are never invisible (RFC 05 §3.1's honesty, applied to a
552/// UI).
553pub struct EventStream {
554    rx: broadcast::Receiver<FleetEvent>,
555    core: Arc<MonitorCore>,
556}
557
558/// An event, or how many this receiver just missed.
559#[derive(Debug, Clone)]
560pub enum StreamItem {
561    Event(FleetEvent),
562    Dropped(u64),
563}
564
565impl EventStream {
566    /// `None` when the monitor stopped.
567    pub async fn recv(&mut self) -> Option<StreamItem> {
568        match self.rx.recv().await {
569            Ok(ev) => Some(StreamItem::Event(ev)),
570            Err(broadcast::error::RecvError::Lagged(n)) => {
571                self.core.dropped.fetch_add(n, Ordering::Relaxed);
572                Some(StreamItem::Dropped(n))
573            }
574            Err(broadcast::error::RecvError::Closed) => None,
575        }
576    }
577}
578
579/// Opaque handle naming one active watch.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
581pub struct WatchId(u64);
582
583struct WatchEntry {
584    selector: String,
585    subscriber: zenoh::pubsub::Subscriber<()>,
586    /// The seed task, while a seeded watch's seed phase is still running.
587    ///
588    /// Aborted wherever the watch ends — [`Monitor::unwatch`],
589    /// [`Monitor::shutdown`] and [`Drop`] alike — so a released watch cannot
590    /// keep ingesting seed replies. `Drop` used to be the outlier, on a note
591    /// that predated `shutdown`: letting it run out was called harmless
592    /// because the seed timeout bounds it and it feeds a core nobody reads.
593    /// It is not harmless (#342). The task holds a cloned [`Session`], so a
594    /// frontend that re-scopes rapidly leaves one of these alive per dropped
595    /// monitor, each holding session teardown open for up to `policy.timeout`.
596    seed_task: Option<tokio::task::JoinHandle<()>>,
597}
598
599/// The wired monitor: a runtime-mutable watch set + liveliness + tick task
600/// feeding a core.
601///
602/// **Lazy by construction** (issue #84): `start` with empty
603/// `spec.selectors` declares *no data-plane subscribers at all* — only the
604/// zero-payload liveliness watches and the tick. Data flows only for what
605/// [`Monitor::watch`] was asked to observe, and [`Monitor::unwatch`]
606/// provably undeclares (an explicit, awaited undeclaration — not a dropped
607/// handle racing the network).
608pub struct Monitor {
609    core: Arc<MonitorCore>,
610    session: Session,
611    watches: tokio::sync::Mutex<std::collections::HashMap<WatchId, WatchEntry>>,
612    next_watch: AtomicU64,
613    tasks: Vec<tokio::task::JoinHandle<()>>,
614}
615
616impl std::fmt::Debug for Monitor {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        f.debug_struct("Monitor").finish_non_exhaustive()
619    }
620}
621
622impl Monitor {
623    /// Declare the spec's subscribers on `session` and start watching.
624    /// `spec.selectors` are simply the *initial* watches — `[]` is the lazy
625    /// start.
626    pub async fn start(session: &Session, spec: MonitorSpec) -> Result<Monitor> {
627        let core = MonitorCore::bounded(spec.capacity, spec.max_keys);
628        let mut tasks = Vec::new();
629
630        for liveliness_sel in &spec.liveliness {
631            let subscriber = crate::bus::teardown::declared(
632                "liveliness subscribe",
633                liveliness_sel,
634                session
635                    .liveliness()
636                    .declare_subscriber(liveliness_sel)
637                    .history(true),
638            )
639            .await?;
640            let core = Arc::clone(&core);
641            tasks.push(tokio::spawn(async move {
642                while let Ok(sample) = subscriber.recv_async().await {
643                    let key = sample.key_expr().as_str().to_string();
644                    core.node_event(key, sample.kind() == SampleKind::Put);
645                }
646            }));
647        }
648
649        {
650            let core = Arc::clone(&core);
651            let period = spec.stats_tick;
652            tasks.push(tokio::spawn(async move {
653                let mut interval = tokio::time::interval(period);
654                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
655                loop {
656                    interval.tick().await;
657                    // Off the runtime (#330): the fold is the heaviest CPU
658                    // this crate schedules periodically, and a worker thread
659                    // spending 300 000 map operations on it is a worker not
660                    // draining anything.
661                    core.tick_off_runtime().await;
662                }
663            }));
664        }
665
666        let monitor = Monitor {
667            core,
668            session: session.clone(),
669            watches: tokio::sync::Mutex::new(std::collections::HashMap::new()),
670            next_watch: AtomicU64::new(0),
671            tasks,
672        };
673        for selector in &spec.selectors {
674            monitor.watch(selector).await?;
675        }
676        Ok(monitor)
677    }
678
679    /// Observe a selector: declares a callback subscriber feeding the core.
680    ///
681    /// The callback runs on zenoh's network thread and does exactly what the
682    /// old per-selector task did — one stats lock, one bounded broadcast
683    /// send — so a slow UI still cannot exert backpressure into the network
684    /// layer beyond the channel's bound.
685    pub async fn watch(&self, selector: &str) -> Result<WatchId> {
686        let core = Arc::clone(&self.core);
687        let subscriber = crate::bus::teardown::declared(
688            "subscribe",
689            selector,
690            self.session
691                .declare_subscriber(selector)
692                .callback(move |sample| {
693                    let view = SampleView::of(&sample);
694                    let sn = view.source.map(|s| s.sn);
695                    core.ingest(view, sn);
696                }),
697        )
698        .await?;
699        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
700        self.watches.lock().await.insert(
701            id,
702            WatchEntry {
703                selector: selector.to_string(),
704                subscriber,
705                seed_task: None,
706            },
707        );
708        let _ = self.core.tx.send(FleetEvent::WatchChanged);
709        Ok(id)
710    }
711
712    /// Declare `selectors` on this monitor, tearing it down — acknowledged —
713    /// if any of them fails.
714    ///
715    /// This is the judge windows' opening move (#336): `start`, take the event
716    /// stream, then declare what the window will observe. In that order,
717    /// deliberately — a sample arriving between the subscriber's declaration
718    /// and the stream's creation would be counted and not delivered, and these
719    /// windows exist to say what they saw. But the `?` on the declaration used
720    /// to return with the monitor's liveliness and tick tasks running and its
721    /// subscribers left to `Drop`: the unacknowledged teardown
722    /// [`shutdown`](Self::shutdown) exists to refuse, on the one path nobody
723    /// thinks about.
724    ///
725    /// Consuming and returning the monitor is what lets the failing path
726    /// `shutdown().await` before it returns. The declaration error is the one
727    /// reported — a teardown failure behind a failed declaration is noise —
728    /// but the teardown itself is never skipped.
729    pub async fn watching<S: AsRef<str>>(
730        self,
731        selectors: impl IntoIterator<Item = S>,
732    ) -> Result<Monitor> {
733        for selector in selectors {
734            if let Err(declare) = self.watch(selector.as_ref()).await {
735                if let Err(teardown) = self.shutdown().await {
736                    tracing::warn!("after a failed watch: {teardown}");
737                }
738                return Err(declare);
739            }
740        }
741        Ok(self)
742    }
743
744    /// Observe a selector **with a correct seed phase** (issue #92; the
745    /// RFC 04 §3.2 discipline of [`crate::seed_subscribe`], run through this
746    /// monitor's bounded broadcast):
747    ///
748    /// - the subscriber is declared first, then both seed paths run as
749    ///   bounded GETs (`@adv` caches for live publishers, the selector
750    ///   itself for router storages — the crashed-producer case);
751    /// - one per-key LWW merge spans seed *and* live samples until the
752    ///   boundary, so a transition in the seed window lands exactly once and
753    ///   a stale seed cannot regress a key. Suppressions are counted in the
754    ///   coverage, never silently absorbed (O6) — and they are *not* part of
755    ///   `Dropped(n)`, which counts only broadcast lag;
756    /// - [`FleetEvent::WatchSeeded`] fires once **both** paths resolve,
757    ///   carrying this id and the [`crate::SeedCoverage`]. After it, the
758    ///   merge is dropped and live samples flow untouched (the merge map is
759    ///   a seed-phase structure, not a per-watch leak).
760    pub async fn watch_seeded(
761        &self,
762        selector: &str,
763        policy: crate::bus::seed::SeedPolicy,
764    ) -> Result<WatchId> {
765        use crate::bus::seed::{Merge, cache_selector, seed_get, view_of};
766        use crate::report::SeedCoverage;
767
768        // The merge gate: `Some` while seeding (both live callback and seed
769        // replies pass `admit`), swapped to `None` at the boundary.
770        let gate: Arc<arc_swap::ArcSwapOption<Merge>> =
771            Arc::new(arc_swap::ArcSwapOption::from_pointee(Merge::new()));
772
773        // 1) The subscriber, FIRST (RFC 04 §3.2).
774        let core = Arc::clone(&self.core);
775        let cb_gate = Arc::clone(&gate);
776        let subscriber = crate::bus::teardown::declared(
777            "seeded subscribe",
778            selector,
779            self.session
780                .declare_subscriber(selector)
781                .callback(move |sample| {
782                    let sn = sample.source_info().map(|si| si.source_sn());
783                    let view = view_of(&sample);
784                    if let Some(merge) = cb_gate.load_full()
785                        && !merge.admit(&view)
786                    {
787                        return;
788                    }
789                    core.ingest(view, sn);
790                }),
791        )
792        .await?;
793
794        // 2) The watch is **registered before its seed task exists**, so the
795        //    boundary event can never name an id `watched()` does not list
796        //    (#346). It used to spawn first and insert after, and a seed that
797        //    completed inside that window announced a watch nothing could yet
798        //    see — an observer keying on `WatchSeeded { id }` had no watch to
799        //    key it to.
800        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
801        self.watches.lock().await.insert(
802            id,
803            WatchEntry {
804                selector: selector.to_string(),
805                subscriber,
806                seed_task: None,
807            },
808        );
809
810        // 3) The seed GETs, AFTER — attached to the entry so `unwatch` during
811        //    the seed phase aborts them.
812        let seed_task = {
813            let session = self.session.clone();
814            let core = Arc::clone(&self.core);
815            let selector = selector.to_string();
816            tokio::spawn(async move {
817                let merge = gate
818                    .load_full()
819                    .expect("gate holds the merge while seeding");
820                let history = async {
821                    if policy.history {
822                        let sel = cache_selector(&selector);
823                        Some(
824                            seed_get(&session, &sel, policy.timeout, &merge, |view| {
825                                core.ingest(view, None);
826                            })
827                            .await,
828                        )
829                    } else {
830                        None
831                    }
832                };
833                let storage = async {
834                    if policy.storage {
835                        Some(
836                            seed_get(&session, &selector, policy.timeout, &merge, |view| {
837                                core.ingest(view, None);
838                            })
839                            .await,
840                        )
841                    } else {
842                        None
843                    }
844                };
845                let (history_replies, storage_replies) = tokio::join!(history, storage);
846                let coverage = SeedCoverage {
847                    history_replies,
848                    storage_replies,
849                    superseded: merge.superseded(),
850                };
851                gate.store(None);
852                // Seeded keys should be visible on the very tick that
853                // announces the boundary, not one tick later.
854                core.tick();
855                let _ = core.tx.send(FleetEvent::WatchSeeded { id, coverage });
856            })
857        };
858        match self.watches.lock().await.get_mut(&id) {
859            Some(entry) => entry.seed_task = Some(seed_task),
860            // `unwatch` won the race and took the entry away. Its own abort
861            // could not reach a task that did not exist yet, so this is where
862            // that ends.
863            None => seed_task.abort(),
864        }
865        let _ = self.core.tx.send(FleetEvent::WatchChanged);
866        Ok(id)
867    }
868
869    /// Stop observing: undeclares the subscriber (awaited to completion — the
870    /// teardown is acknowledged, not racing a drop), then retires statistics
871    /// for keys no remaining watch covers. Retired keys are **counted**
872    /// ([`crate::model::stats::StatsTable::unwatched`]): a shrinking key set must
873    /// never read as a quieting bus (RFC 09 §5.1 O6).
874    pub async fn unwatch(&self, id: WatchId) -> Result<()> {
875        let mut entry = {
876            let mut watches = self.watches.lock().await;
877            watches.remove(&id).ok_or_else(|| {
878                Error::unaskable(
879                    format!("watch id {id:?}"),
880                    "is not a watch this monitor holds",
881                )
882            })?
883        };
884        // A released watch must not keep ingesting seed replies: the seed
885        // task dies with the watch (its boundary event simply never fires —
886        // the watch is gone, so there is nothing left to flip to "live").
887        if let Some(task) = entry.seed_task.take() {
888            task.abort();
889        }
890        entry
891            .subscriber
892            .undeclare()
893            .await
894            .map_err(|e| Error::bus("undeclare", &entry.selector, e))?;
895        let kept: Vec<String> = {
896            let watches = self.watches.lock().await;
897            watches.values().map(|w| w.selector.clone()).collect()
898        };
899        self.core.with_stats_mut(|stats| {
900            stats.retire_unwatched(&entry.selector, &kept);
901        });
902        self.core.tick();
903        let _ = self.core.tx.send(FleetEvent::WatchChanged);
904        Ok(())
905    }
906
907    /// The active watch set.
908    pub async fn watched(&self) -> Vec<(WatchId, String)> {
909        let watches = self.watches.lock().await;
910        let mut v: Vec<(WatchId, String)> = watches
911            .iter()
912            .map(|(id, w)| (*id, w.selector.clone()))
913            .collect();
914        v.sort();
915        v
916    }
917
918    pub fn core(&self) -> &Arc<MonitorCore> {
919        &self.core
920    }
921
922    pub fn events(&self) -> EventStream {
923        self.core.events()
924    }
925
926    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
927        self.core.tree()
928    }
929
930    /// Stop watching. Equivalent to dropping the monitor — kept as an explicit
931    /// verb for call sites that want to say so.
932    ///
933    /// The teardown is the [`Drop`] one: tasks aborted, subscribers left to
934    /// undeclare in the background. Where the *acknowledgement* matters —
935    /// tearing one monitor down to declare another over the same keys — use
936    /// [`shutdown`](Self::shutdown) instead.
937    pub fn stop(self) {
938        drop(self);
939    }
940
941    /// Stop watching, **acknowledged**: every watch undeclares and is waited
942    /// for before this returns.
943    ///
944    /// [`unwatch`](Self::unwatch) awaits `undeclare` on purpose — "the
945    /// teardown is acknowledged, not racing a drop" — but the whole-monitor
946    /// path had no such verb: [`Drop`] can only abort the tasks and let the
947    /// subscribers undeclare on their own, in the background, which is the
948    /// race that doc disavows. A frontend that re-scopes by rebuilding its
949    /// monitor was therefore declaring the new subscribers while the old ones
950    /// were still tearing down.
951    ///
952    /// Every watch is drained even if one fails to undeclare — a monitor half
953    /// torn down is worse than one torn down noisily — and the failures are
954    /// reported together. `Drop` still runs afterwards, aborting the
955    /// liveliness and tick tasks, and remains the fallback for every path
956    /// that does not come through here.
957    ///
958    /// Statistics are **not** retired the way `unwatch` retires them: that
959    /// counter answers "the key set shrank because you stopped looking"
960    /// (RFC 09 §5.1 O6) for a monitor that goes on running. This one is the
961    /// end of the observation; the core goes with it unless a caller kept an
962    /// `Arc`, and a re-scope's next monitor starts from a fresh one.
963    pub async fn shutdown(self) -> Result<()> {
964        let drained: Vec<WatchEntry> = {
965            let mut watches = self.watches.lock().await;
966            watches.drain().map(|(_, entry)| entry).collect()
967        };
968        let mut failed = Vec::new();
969        for mut entry in drained {
970            if let Some(task) = entry.seed_task.take() {
971                task.abort();
972            }
973            if let Err(e) = entry.subscriber.undeclare().await {
974                failed.push(format!("{}: {e}", entry.selector));
975            }
976        }
977        drop(self);
978        if failed.is_empty() {
979            Ok(())
980        } else {
981            Err(Error::bus(
982                "undeclare",
983                failed.join("; "),
984                "one or more handles refused",
985            ))
986        }
987    }
988}
989
990/// Dropping a monitor stops it: the ingest tasks are aborted and the
991/// subscribers undeclare.
992///
993/// This is not a nicety. A `JoinHandle` merely *detaches* on drop, so without
994/// this impl every monitor that goes out of scope leaks a live subscriber and
995/// its ingest task for the lifetime of the session. `zenctl` never noticed —
996/// it calls [`Monitor::stop`] once and exits — but a GUI re-scopes its
997/// subscription whenever the user changes what they are watching, dropping and
998/// rebuilding the monitor each time.
999///
1000/// **Every** task, which for one release meant every task but the seeded
1001/// watches' (#342): those handles live in `watches`, and aborting only
1002/// `self.tasks` detached them. Each holds a cloned [`Session`] and goes on
1003/// calling `core.ingest`/`core.tick`, so the re-scoping GUI above left one
1004/// running per drop, each holding session teardown open for up to the seed
1005/// timeout. `unwatch` and `shutdown` had aborted them all along; the async
1006/// mutex is `get_mut` here, which needs no lock because `Drop` holds
1007/// `&mut self`.
1008impl Drop for Monitor {
1009    fn drop(&mut self) {
1010        for t in &self.tasks {
1011            t.abort();
1012        }
1013        for entry in self.watches.get_mut().values_mut() {
1014            if let Some(task) = entry.seed_task.take() {
1015                task.abort();
1016            }
1017        }
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    /// A fold that started earlier must never overwrite one that started
1026    /// later, however long it takes to come back.
1027    ///
1028    /// #330 moved the periodic fold to the blocking pool and opened exactly
1029    /// that window: `tick_off_runtime` copies the rows, hands the fold away,
1030    /// and publishes on return — so a synchronous `tick()` taken *after* it
1031    /// (a seed boundary, an unwatch) could publish first and then be
1032    /// overwritten by the earlier fold's stale result. It surfaced as the
1033    /// seeding test's "the seeded key is already in the tree at the boundary
1034    /// tick" failing with an empty tree while its own coverage line said the
1035    /// seed reply had arrived.
1036    ///
1037    /// Driven through `publish` directly, because reproducing the interleave
1038    /// through the blocking pool is exactly the race that only shows up under
1039    /// load — the ordering rule is the property, and it is testable.
1040    #[test]
1041    fn an_older_fold_never_walks_the_tree_backwards() {
1042        let core = MonitorCore::new(8);
1043
1044        // Two ticks, taken in order: the second sees a key the first did not.
1045        let (empty_rows, first) = core.stats_rows();
1046        core.ingest(view("v1/h-3fa9c2d41b7e/telemetry/p/x", 4), None);
1047        let (seeded_rows, second) = core.stats_rows();
1048        assert!(second > first, "the sequence orders the two takes");
1049
1050        // The *newer* fold lands first — the seed boundary's synchronous tick.
1051        core.publish(KeyTreeSnapshot::fold(seeded_rows), second);
1052        assert_eq!(core.tree().keys, 1, "the boundary tick published");
1053
1054        // …and the older one, back from the blocking pool, is dropped.
1055        core.publish(KeyTreeSnapshot::fold(empty_rows), first);
1056        assert_eq!(
1057            core.tree().keys,
1058            1,
1059            "an older fold overwrote a newer snapshot — the tree walked backwards"
1060        );
1061    }
1062
1063    fn view(key: &str, len: usize) -> SampleView {
1064        SampleView {
1065            key: key.to_string(),
1066            payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
1067            encoding: "zenoh/bytes".to_string(),
1068            kind: SampleKind::Put,
1069            timestamp: None,
1070            stamped_by: None,
1071            attachment: None,
1072            priority: zenoh::qos::Priority::DEFAULT,
1073            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
1074            reliability: zenoh::qos::Reliability::DEFAULT,
1075            express: false,
1076            source: None,
1077            received: Instant::now(),
1078        }
1079    }
1080
1081    #[tokio::test]
1082    async fn events_flow_and_snapshots_rebuild_on_tick() {
1083        let core = MonitorCore::new(8);
1084        let mut events = core.events();
1085        core.ingest(view("zs/v1/h-a/telemetry/x/m", 4), None);
1086        core.tick();
1087
1088        let Some(StreamItem::Event(FleetEvent::Sample(s))) = events.recv().await else {
1089            panic!("expected sample");
1090        };
1091        assert_eq!(s.key, "zs/v1/h-a/telemetry/x/m");
1092        assert_eq!(s.payload.len(), 4);
1093        let Some(StreamItem::Event(FleetEvent::StatsTick)) = events.recv().await else {
1094            panic!("expected tick");
1095        };
1096        let snap = core.tree();
1097        assert_eq!(snap.keys, 1);
1098        assert_eq!(snap.root.subtree_count, 1);
1099    }
1100
1101    /// Deep-review D2: `ingest_at` measures the skewed latency (#119) from
1102    /// the **injected** wall clock, never `SystemTime::now()` — so a replay
1103    /// rebuild that injects the capture clock on both axes folds the same
1104    /// latencies every time, exactly (the #217 bit-identical promise), and
1105    /// the number itself is `wall − HLC`, not `rebuild-time − HLC`.
1106    #[test]
1107    fn injected_wall_clock_drives_the_latency_fold_deterministically() {
1108        let stamp_epoch = Duration::from_secs(1_000_000);
1109        let ts = zenoh::time::Timestamp::new(
1110            zenoh::time::NTP64::from(stamp_epoch),
1111            zenoh::time::TimestampId::rand(),
1112        );
1113        let key = "zs/v1/h-a/telemetry/x/m";
1114        let stamped_view = || {
1115            let mut v = view(key, 4);
1116            v.timestamp = Some(ts);
1117            v.stamped_by = Some(StampProvenance::Unattributable {
1118                stamper: *ts.get_id(),
1119            });
1120            v
1121        };
1122        let now = Instant::now();
1123        // The injected wall clock says the sample arrived 5 ms after its
1124        // stamp — regardless of what the live wall clock reads (it is a
1125        // million seconds past this epoch already).
1126        let wall = ts.get_time().to_system_time() + Duration::from_millis(5);
1127
1128        let fold = || {
1129            let core = MonitorCore::new(8);
1130            core.ingest_at(Arc::new(stamped_view()), None, now, wall);
1131            core.with_stats(|s| s.get(key).expect("recorded").latency())
1132                .expect("a stamped sample has a latency window")
1133        };
1134        let a = fold();
1135        let summary = a.unattributable.expect("unattributable population");
1136        assert_eq!(summary.samples, 1);
1137        assert_eq!(
1138            summary.median_us, 5_000,
1139            "the latency is wall − HLC, on the injected wall clock"
1140        );
1141        // A second rebuild with the same injected clocks folds identically —
1142        // a live `SystemTime::now()` read in between would not.
1143        assert_eq!(a, fold());
1144    }
1145
1146    /// The bounded-channel honesty contract: a lagging receiver is told how
1147    /// many it missed — never a silent gap.
1148    #[tokio::test]
1149    async fn overflow_surfaces_as_dropped_counts() {
1150        let core = MonitorCore::new(2);
1151        let mut slow = core.events();
1152        for i in 0..10 {
1153            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
1154        }
1155        let Some(StreamItem::Dropped(n)) = slow.recv().await else {
1156            panic!("expected a dropped count first");
1157        };
1158        assert!(n >= 8, "missed at least 8, reported {n}");
1159        assert_eq!(core.dropped(), n);
1160        // The stream then resumes with the retained tail.
1161        let Some(StreamItem::Event(FleetEvent::Sample(_))) = slow.recv().await else {
1162            panic!("expected a sample after the gap report");
1163        };
1164    }
1165
1166    /// The retained window rides the ingest path, not the broadcast: a
1167    /// receiver that lagged its way to `Dropped(n)` lost nothing from the
1168    /// ring, and the window still holds every ingested sample (#217).
1169    #[tokio::test]
1170    async fn the_ring_sees_what_a_lagging_receiver_missed() {
1171        let core = MonitorCore::new(2);
1172        let mut slow = core.events();
1173        for i in 0..10 {
1174            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
1175        }
1176        let Some(StreamItem::Dropped(_)) = slow.recv().await else {
1177            panic!("the broadcast lagged");
1178        };
1179        let window = core.retained();
1180        assert_eq!(window.len(), 10, "the ring is upstream of the lag");
1181        assert_eq!(window[0].key, "zs/v1/h-a/telemetry/x/m0");
1182        assert_eq!(window[9].key, "zs/v1/h-a/telemetry/x/m9");
1183    }
1184
1185    /// #330: the ingest lock is held for the **copy**, not for the build.
1186    ///
1187    /// The measurement is a ratio rather than a wall-clock budget, because
1188    /// what the issue asserts is a complexity claim: the critical section is
1189    /// O(keys), the fold O(keys × chunks). Timed on the same table in the
1190    /// same build, the copy must therefore come out a small fraction of the
1191    /// fold — and it is the copy, and only the copy, that a network callback
1192    /// thread waits behind.
1193    #[test]
1194    fn the_tick_holds_the_ingest_lock_only_for_the_row_copy() {
1195        const KEYS: usize = 5_000;
1196        let core = MonitorCore::bounded(2, KEYS * 2);
1197        let now = Instant::now();
1198        core.with_stats_mut(|stats| {
1199            for i in 0..KEYS {
1200                // Eight chunks: the fold does eight `BTreeMap` descents per
1201                // key, the copy does one refcount bump.
1202                stats.record(
1203                    &format!(
1204                        "zs/v1/h-{:04}/telemetry/proc-{i}/group/sub/leaf/m{i}",
1205                        i % 97
1206                    ),
1207                    64,
1208                    None,
1209                    now,
1210                    None,
1211                    None,
1212                );
1213            }
1214        });
1215
1216        // Phase 1 — everything the lock is held for.
1217        let t0 = Instant::now();
1218        let (rows, _seq) = core.stats_rows();
1219        let copy = t0.elapsed();
1220        assert_eq!(rows.rows.len(), KEYS);
1221        let copied_rows = rows.rows.len();
1222
1223        // Phase 2 — everything that now happens with the lock released.
1224        let t1 = Instant::now();
1225        let snapshot = KeyTreeSnapshot::fold(rows);
1226        let fold = t1.elapsed();
1227        assert_eq!(snapshot.keys, KEYS);
1228
1229        // Measured at 5 000 keys of 8 chunks (debug): copy ~0.9 ms, fold
1230        // ~48 ms — a factor of ~55, and `benches/frame.rs` (`tree/rows_50k`
1231        // beside `tree/build_50k`) is where that number is tracked.
1232        //
1233        // The assertion here is deliberately NOT the ratio. A wall-clock
1234        // comparison in a unit test measures the scheduler as much as the
1235        // code, and CI runs this beside sixty other binaries; a test that
1236        // fails when the box is busy teaches people to re-run rather than to
1237        // read. What must hold structurally is that phase 1 hands phase 2
1238        // every key *without* having built anything — one row per key, no
1239        // tree — so folding under the lock again cannot pass unnoticed: the
1240        // rows would have to come back already folded, and `fold` consumes
1241        // them.
1242        assert_eq!(copied_rows, snapshot.keys, "one row in, one key out");
1243        assert!(
1244            copy < fold * 100,
1245            "a sane machine folds slower than it copies; copy {copy:?}, fold {fold:?}"
1246        );
1247    }
1248
1249    /// The split changes what the lock costs, never what the snapshot says:
1250    /// `build` (copy + fold) and the tick agree, key for key.
1251    #[test]
1252    fn the_split_fold_is_the_same_snapshot() {
1253        let core = MonitorCore::new(8);
1254        for i in 0..50 {
1255            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 4), None);
1256        }
1257        core.tick();
1258        let ticked = core.tree();
1259        let direct = core.with_stats(KeyTreeSnapshot::build);
1260        assert_eq!(ticked.keys, direct.keys);
1261        assert_eq!(ticked.root.subtree_count, direct.root.subtree_count);
1262        assert_eq!(ticked.root.subtree_bytes, direct.root.subtree_bytes);
1263        assert_eq!(
1264            ticked
1265                .node(&["zs", "v1", "h-a", "telemetry", "x"])
1266                .unwrap()
1267                .subtree_keys,
1268            50
1269        );
1270    }
1271
1272    /// #331: a retained-window read holds the ingest mutex for the chunk
1273    /// pointers and the open tail, and flattens the window afterwards.
1274    ///
1275    /// The same shape of measurement as the tick's (#330), for the same
1276    /// reason: the claim is that the under-lock half no longer scales with
1277    /// the window. It is asserted as a ratio against the flatten — the half
1278    /// that does scale — so a return to `ring.iter().cloned().collect()`
1279    /// under the guard fails it.
1280    #[test]
1281    fn a_retained_read_holds_the_ingest_lock_for_chunk_pointers_only() {
1282        const SAMPLES: usize = 40_000;
1283        let core = MonitorCore::new(2);
1284        let now = Instant::now();
1285        for i in 0..SAMPLES {
1286            core.ingest_at(
1287                Arc::new(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 8)),
1288                None,
1289                now,
1290                std::time::SystemTime::now(),
1291            );
1292        }
1293
1294        // Phase 1 — everything the network callback thread waits behind.
1295        let t0 = Instant::now();
1296        let parts = core
1297            .retain
1298            .lock()
1299            .expect("retain lock")
1300            .parts(Instant::now());
1301        let under_lock = t0.elapsed();
1302        let held_chunks = parts.chunks();
1303
1304        // Phase 2 — everything that now happens with the guard dropped.
1305        let t1 = Instant::now();
1306        let window = parts.flatten();
1307        let flatten = t1.elapsed();
1308
1309        assert_eq!(window.len(), SAMPLES, "the whole window, unchanged");
1310        assert_eq!(window[0].key, "zs/v1/h-a/telemetry/x/m0");
1311        // As above: the property is structural, not a stopwatch reading.
1312        // What the lock holds is chunk pointers — bounded by
1313        // `window / CHUNK + 1` regardless of how many samples the window
1314        // carries — and `parts` proves it by construction, so a read that
1315        // went back to cloning the ring would fail the count, not a race.
1316        assert!(
1317            held_chunks <= SAMPLES / crate::model::retain::CHUNK + 2,
1318            "the critical section holds chunk pointers, not samples: {held_chunks} chunks for {SAMPLES} samples"
1319        );
1320        let _ = (under_lock, flatten);
1321    }
1322
1323    /// RFC 09 §5.1 **O6** / v1.18 **R1**: the eviction populations stay
1324    /// separate numbers (#217). Broadcast lag ("could not keep up"),
1325    /// stats-table eviction ("chose to forget under the key bound") and
1326    /// retention eviction ("chose to forget under the window's byte budget")
1327    /// are three different facts about the same session, and each ledger
1328    /// balances on its own.
1329    #[tokio::test]
1330    async fn the_eviction_populations_are_never_folded() {
1331        const SAMPLES: usize = 100;
1332        let core = MonitorCore::bounded(2, 8);
1333        core.set_retention_budget(crate::model::retain::RetentionBudget {
1334            max_bytes: 1100,
1335            max_age: Duration::from_secs(3600),
1336        });
1337        let mut slow = core.events();
1338        for i in 0..SAMPLES {
1339            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 64), None);
1340        }
1341        let Some(StreamItem::Dropped(lagged)) = slow.recv().await else {
1342            panic!("the broadcast lagged");
1343        };
1344
1345        // Each population's own ledger balances — nothing crossed over.
1346        assert_eq!(core.dropped(), lagged, "lag counts only broadcast lag");
1347        let table_kept = core.with_stats(StatsTable::len);
1348        assert_eq!(
1349            table_kept as u64 + core.keys_evicted(),
1350            SAMPLES as u64,
1351            "every key is in the table or in its eviction count"
1352        );
1353        let r = core.retention();
1354        assert_eq!(
1355            r.retained as u64 + r.evicted,
1356            SAMPLES as u64,
1357            "every sample is in the ring or in its eviction count"
1358        );
1359        assert_eq!(r.expired, 0, "nothing aged out in this window");
1360        assert_eq!(core.keys_unwatched(), 0, "nothing was unwatched");
1361
1362        // And they are genuinely different numbers, not one figure worn
1363        // three ways.
1364        assert_ne!(r.evicted, core.keys_evicted());
1365        assert_ne!(r.evicted, core.dropped());
1366    }
1367}