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    /// The same items as a [`Stream`](futures_core::Stream) (#343).
567    ///
568    /// Consuming rather than borrowing, because the consumer that wanted this
569    /// is a UI subscription that must own a `'static` stream — a borrowing
570    /// adapter could not be handed to one. Take a second `EventStream` from
571    /// [`Monitor::events`](crate::Monitor::events) if the original is still
572    /// needed for a `recv` loop; each receiver has its own place in the ring.
573    ///
574    /// `unfold` rather than a hand-written `poll_next`: a broadcast receiver
575    /// has no `poll_recv`, so a direct impl would have to store the borrowed
576    /// `recv` future beside the receiver it borrows from — a self-referential
577    /// struct, and unsafe for nothing. Lag still folds into the monitor's
578    /// `dropped` counter and still arrives as [`StreamItem::Dropped`], since
579    /// this drives the same [`recv`](Self::recv) that does both. A generic
580    /// broadcast-to-stream adapter would surface lag as an *error* instead,
581    /// which is the one thing this type exists to prevent.
582    pub fn into_stream(self) -> impl futures_core::Stream<Item = StreamItem> + Send {
583        futures_util::stream::unfold(self, |mut events| async move {
584            events.recv().await.map(|item| (item, events))
585        })
586    }
587
588    /// `None` when the monitor stopped.
589    pub async fn recv(&mut self) -> Option<StreamItem> {
590        match self.rx.recv().await {
591            Ok(ev) => Some(StreamItem::Event(ev)),
592            Err(broadcast::error::RecvError::Lagged(n)) => {
593                self.core.dropped.fetch_add(n, Ordering::Relaxed);
594                Some(StreamItem::Dropped(n))
595            }
596            Err(broadcast::error::RecvError::Closed) => None,
597        }
598    }
599}
600
601/// Opaque handle naming one active watch.
602#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
603pub struct WatchId(u64);
604
605struct WatchEntry {
606    selector: String,
607    subscriber: zenoh::pubsub::Subscriber<()>,
608    /// The seed task, while a seeded watch's seed phase is still running.
609    ///
610    /// Aborted wherever the watch ends — [`Monitor::unwatch`],
611    /// [`Monitor::shutdown`] and [`Drop`] alike — so a released watch cannot
612    /// keep ingesting seed replies. `Drop` used to be the outlier, on a note
613    /// that predated `shutdown`: letting it run out was called harmless
614    /// because the seed timeout bounds it and it feeds a core nobody reads.
615    /// It is not harmless (#342). The task holds a cloned [`Session`], so a
616    /// frontend that re-scopes rapidly leaves one of these alive per dropped
617    /// monitor, each holding session teardown open for up to `policy.timeout`.
618    seed_task: Option<tokio::task::JoinHandle<()>>,
619}
620
621/// The wired monitor: a runtime-mutable watch set + liveliness + tick task
622/// feeding a core.
623///
624/// **Lazy by construction** (issue #84): `start` with empty
625/// `spec.selectors` declares *no data-plane subscribers at all* — only the
626/// zero-payload liveliness watches and the tick. Data flows only for what
627/// [`Monitor::watch`] was asked to observe, and [`Monitor::unwatch`]
628/// provably undeclares (an explicit, awaited undeclaration — not a dropped
629/// handle racing the network).
630pub struct Monitor {
631    core: Arc<MonitorCore>,
632    session: Session,
633    watches: tokio::sync::Mutex<std::collections::HashMap<WatchId, WatchEntry>>,
634    next_watch: AtomicU64,
635    tasks: Vec<tokio::task::JoinHandle<()>>,
636}
637
638impl std::fmt::Debug for Monitor {
639    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
640        f.debug_struct("Monitor").finish_non_exhaustive()
641    }
642}
643
644impl Monitor {
645    /// Declare the spec's subscribers on `session` and start watching.
646    /// `spec.selectors` are simply the *initial* watches — `[]` is the lazy
647    /// start.
648    pub async fn start(session: &Session, spec: MonitorSpec) -> Result<Monitor> {
649        let core = MonitorCore::bounded(spec.capacity, spec.max_keys);
650        let mut tasks = Vec::new();
651
652        for liveliness_sel in &spec.liveliness {
653            let subscriber = crate::bus::teardown::declared(
654                "liveliness subscribe",
655                liveliness_sel,
656                session
657                    .liveliness()
658                    .declare_subscriber(liveliness_sel)
659                    .history(true),
660            )
661            .await?;
662            let core = Arc::clone(&core);
663            tasks.push(tokio::spawn(async move {
664                while let Ok(sample) = subscriber.recv_async().await {
665                    let key = sample.key_expr().as_str().to_string();
666                    core.node_event(key, sample.kind() == SampleKind::Put);
667                }
668            }));
669        }
670
671        {
672            let core = Arc::clone(&core);
673            let period = spec.stats_tick;
674            tasks.push(tokio::spawn(async move {
675                let mut interval = tokio::time::interval(period);
676                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
677                loop {
678                    interval.tick().await;
679                    // Off the runtime (#330): the fold is the heaviest CPU
680                    // this crate schedules periodically, and a worker thread
681                    // spending 300 000 map operations on it is a worker not
682                    // draining anything.
683                    core.tick_off_runtime().await;
684                }
685            }));
686        }
687
688        let monitor = Monitor {
689            core,
690            session: session.clone(),
691            watches: tokio::sync::Mutex::new(std::collections::HashMap::new()),
692            next_watch: AtomicU64::new(0),
693            tasks,
694        };
695        for selector in &spec.selectors {
696            monitor.watch(selector).await?;
697        }
698        Ok(monitor)
699    }
700
701    /// Observe a selector: declares a callback subscriber feeding the core.
702    ///
703    /// The callback runs on zenoh's network thread and does exactly what the
704    /// old per-selector task did — one stats lock, one bounded broadcast
705    /// send — so a slow UI still cannot exert backpressure into the network
706    /// layer beyond the channel's bound.
707    pub async fn watch(&self, selector: &str) -> Result<WatchId> {
708        let core = Arc::clone(&self.core);
709        let subscriber = crate::bus::teardown::declared(
710            "subscribe",
711            selector,
712            self.session
713                .declare_subscriber(selector)
714                .callback(move |sample| {
715                    let view = SampleView::of(&sample);
716                    let sn = view.source.map(|s| s.sn);
717                    core.ingest(view, sn);
718                }),
719        )
720        .await?;
721        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
722        self.watches.lock().await.insert(
723            id,
724            WatchEntry {
725                selector: selector.to_string(),
726                subscriber,
727                seed_task: None,
728            },
729        );
730        let _ = self.core.tx.send(FleetEvent::WatchChanged);
731        Ok(id)
732    }
733
734    /// Declare `selectors` on this monitor, tearing it down — acknowledged —
735    /// if any of them fails.
736    ///
737    /// This is the judge windows' opening move (#336): `start`, take the event
738    /// stream, then declare what the window will observe. In that order,
739    /// deliberately — a sample arriving between the subscriber's declaration
740    /// and the stream's creation would be counted and not delivered, and these
741    /// windows exist to say what they saw. But the `?` on the declaration used
742    /// to return with the monitor's liveliness and tick tasks running and its
743    /// subscribers left to `Drop`: the unacknowledged teardown
744    /// [`shutdown`](Self::shutdown) exists to refuse, on the one path nobody
745    /// thinks about.
746    ///
747    /// Consuming and returning the monitor is what lets the failing path
748    /// `shutdown().await` before it returns. The declaration error is the one
749    /// reported — a teardown failure behind a failed declaration is noise —
750    /// but the teardown itself is never skipped.
751    pub async fn watching<S: AsRef<str>>(
752        self,
753        selectors: impl IntoIterator<Item = S>,
754    ) -> Result<Monitor> {
755        for selector in selectors {
756            if let Err(declare) = self.watch(selector.as_ref()).await {
757                if let Err(teardown) = self.shutdown().await {
758                    tracing::warn!("after a failed watch: {teardown}");
759                }
760                return Err(declare);
761            }
762        }
763        Ok(self)
764    }
765
766    /// Observe a selector **with a correct seed phase** (issue #92; the
767    /// RFC 04 §3.2 discipline of [`crate::seed_subscribe`], run through this
768    /// monitor's bounded broadcast):
769    ///
770    /// - the subscriber is declared first, then both seed paths run as
771    ///   bounded GETs (`@adv` caches for live publishers, the selector
772    ///   itself for router storages — the crashed-producer case);
773    /// - one per-key LWW merge spans seed *and* live samples until the
774    ///   boundary, so a transition in the seed window lands exactly once and
775    ///   a stale seed cannot regress a key. Suppressions are counted in the
776    ///   coverage, never silently absorbed (O6) — and they are *not* part of
777    ///   `Dropped(n)`, which counts only broadcast lag;
778    /// - [`FleetEvent::WatchSeeded`] fires once **both** paths resolve,
779    ///   carrying this id and the [`crate::SeedCoverage`]. After it, the
780    ///   merge is dropped and live samples flow untouched (the merge map is
781    ///   a seed-phase structure, not a per-watch leak).
782    pub async fn watch_seeded(
783        &self,
784        selector: &str,
785        policy: crate::bus::seed::SeedPolicy,
786    ) -> Result<WatchId> {
787        use crate::bus::seed::{Merge, cache_selector, seed_get, view_of};
788        use crate::report::SeedCoverage;
789
790        // The merge gate: `Some` while seeding (both live callback and seed
791        // replies pass `admit`), swapped to `None` at the boundary.
792        let gate: Arc<arc_swap::ArcSwapOption<Merge>> =
793            Arc::new(arc_swap::ArcSwapOption::from_pointee(Merge::new()));
794
795        // 1) The subscriber, FIRST (RFC 04 §3.2).
796        let core = Arc::clone(&self.core);
797        let cb_gate = Arc::clone(&gate);
798        let subscriber = crate::bus::teardown::declared(
799            "seeded subscribe",
800            selector,
801            self.session
802                .declare_subscriber(selector)
803                .callback(move |sample| {
804                    let sn = sample.source_info().map(|si| si.source_sn());
805                    let view = view_of(&sample);
806                    if let Some(merge) = cb_gate.load_full()
807                        && !merge.admit(&view)
808                    {
809                        return;
810                    }
811                    core.ingest(view, sn);
812                }),
813        )
814        .await?;
815
816        // 2) The watch is **registered before its seed task exists**, so the
817        //    boundary event can never name an id `watched()` does not list
818        //    (#346). It used to spawn first and insert after, and a seed that
819        //    completed inside that window announced a watch nothing could yet
820        //    see — an observer keying on `WatchSeeded { id }` had no watch to
821        //    key it to.
822        let id = WatchId(self.next_watch.fetch_add(1, Ordering::Relaxed));
823        self.watches.lock().await.insert(
824            id,
825            WatchEntry {
826                selector: selector.to_string(),
827                subscriber,
828                seed_task: None,
829            },
830        );
831
832        // 3) The seed GETs, AFTER — attached to the entry so `unwatch` during
833        //    the seed phase aborts them.
834        let seed_task = {
835            let session = self.session.clone();
836            let core = Arc::clone(&self.core);
837            let selector = selector.to_string();
838            tokio::spawn(async move {
839                let merge = gate
840                    .load_full()
841                    .expect("gate holds the merge while seeding");
842                let history = async {
843                    if policy.history {
844                        let sel = cache_selector(&selector);
845                        Some(
846                            seed_get(&session, &sel, policy.timeout, &merge, |view| {
847                                core.ingest(view, None);
848                            })
849                            .await,
850                        )
851                    } else {
852                        None
853                    }
854                };
855                let storage = async {
856                    if policy.storage {
857                        Some(
858                            seed_get(&session, &selector, policy.timeout, &merge, |view| {
859                                core.ingest(view, None);
860                            })
861                            .await,
862                        )
863                    } else {
864                        None
865                    }
866                };
867                let (history_replies, storage_replies) = tokio::join!(history, storage);
868                let coverage = SeedCoverage {
869                    history_replies,
870                    storage_replies,
871                    superseded: merge.superseded(),
872                };
873                gate.store(None);
874                // Seeded keys should be visible on the very tick that
875                // announces the boundary, not one tick later.
876                core.tick();
877                let _ = core.tx.send(FleetEvent::WatchSeeded { id, coverage });
878            })
879        };
880        match self.watches.lock().await.get_mut(&id) {
881            Some(entry) => entry.seed_task = Some(seed_task),
882            // `unwatch` won the race and took the entry away. Its own abort
883            // could not reach a task that did not exist yet, so this is where
884            // that ends.
885            None => seed_task.abort(),
886        }
887        let _ = self.core.tx.send(FleetEvent::WatchChanged);
888        Ok(id)
889    }
890
891    /// Stop observing: undeclares the subscriber (awaited to completion — the
892    /// teardown is acknowledged, not racing a drop), then retires statistics
893    /// for keys no remaining watch covers. Retired keys are **counted**
894    /// ([`crate::model::stats::StatsTable::unwatched`]): a shrinking key set must
895    /// never read as a quieting bus (RFC 09 §5.1 O6).
896    pub async fn unwatch(&self, id: WatchId) -> Result<()> {
897        let mut entry = {
898            let mut watches = self.watches.lock().await;
899            watches.remove(&id).ok_or_else(|| {
900                Error::unaskable(
901                    format!("watch id {id:?}"),
902                    "is not a watch this monitor holds",
903                )
904            })?
905        };
906        // A released watch must not keep ingesting seed replies: the seed
907        // task dies with the watch (its boundary event simply never fires —
908        // the watch is gone, so there is nothing left to flip to "live").
909        if let Some(task) = entry.seed_task.take() {
910            task.abort();
911        }
912        entry
913            .subscriber
914            .undeclare()
915            .await
916            .map_err(|e| Error::bus("undeclare", &entry.selector, e))?;
917        let kept: Vec<String> = {
918            let watches = self.watches.lock().await;
919            watches.values().map(|w| w.selector.clone()).collect()
920        };
921        self.core.with_stats_mut(|stats| {
922            stats.retire_unwatched(&entry.selector, &kept);
923        });
924        self.core.tick();
925        let _ = self.core.tx.send(FleetEvent::WatchChanged);
926        Ok(())
927    }
928
929    /// The active watch set.
930    pub async fn watched(&self) -> Vec<(WatchId, String)> {
931        let watches = self.watches.lock().await;
932        let mut v: Vec<(WatchId, String)> = watches
933            .iter()
934            .map(|(id, w)| (*id, w.selector.clone()))
935            .collect();
936        v.sort();
937        v
938    }
939
940    pub fn core(&self) -> &Arc<MonitorCore> {
941        &self.core
942    }
943
944    pub fn events(&self) -> EventStream {
945        self.core.events()
946    }
947
948    pub fn tree(&self) -> Arc<KeyTreeSnapshot> {
949        self.core.tree()
950    }
951
952    /// Stop watching. Equivalent to dropping the monitor — kept as an explicit
953    /// verb for call sites that want to say so.
954    ///
955    /// The teardown is the [`Drop`] one: tasks aborted, subscribers left to
956    /// undeclare in the background. Where the *acknowledgement* matters —
957    /// tearing one monitor down to declare another over the same keys — use
958    /// [`shutdown`](Self::shutdown) instead.
959    pub fn stop(self) {
960        drop(self);
961    }
962
963    /// Stop watching, **acknowledged**: every watch undeclares and is waited
964    /// for before this returns.
965    ///
966    /// [`unwatch`](Self::unwatch) awaits `undeclare` on purpose — "the
967    /// teardown is acknowledged, not racing a drop" — but the whole-monitor
968    /// path had no such verb: [`Drop`] can only abort the tasks and let the
969    /// subscribers undeclare on their own, in the background, which is the
970    /// race that doc disavows. A frontend that re-scopes by rebuilding its
971    /// monitor was therefore declaring the new subscribers while the old ones
972    /// were still tearing down.
973    ///
974    /// Every watch is drained even if one fails to undeclare — a monitor half
975    /// torn down is worse than one torn down noisily — and the failures are
976    /// reported together. `Drop` still runs afterwards, aborting the
977    /// liveliness and tick tasks, and remains the fallback for every path
978    /// that does not come through here.
979    ///
980    /// Statistics are **not** retired the way `unwatch` retires them: that
981    /// counter answers "the key set shrank because you stopped looking"
982    /// (RFC 09 §5.1 O6) for a monitor that goes on running. This one is the
983    /// end of the observation; the core goes with it unless a caller kept an
984    /// `Arc`, and a re-scope's next monitor starts from a fresh one.
985    pub async fn shutdown(self) -> Result<()> {
986        let drained: Vec<WatchEntry> = {
987            let mut watches = self.watches.lock().await;
988            watches.drain().map(|(_, entry)| entry).collect()
989        };
990        let mut failed = Vec::new();
991        for mut entry in drained {
992            if let Some(task) = entry.seed_task.take() {
993                task.abort();
994            }
995            if let Err(e) = entry.subscriber.undeclare().await {
996                failed.push(format!("{}: {e}", entry.selector));
997            }
998        }
999        drop(self);
1000        if failed.is_empty() {
1001            Ok(())
1002        } else {
1003            Err(Error::bus(
1004                "undeclare",
1005                failed.join("; "),
1006                "one or more handles refused",
1007            ))
1008        }
1009    }
1010}
1011
1012/// Dropping a monitor stops it: the ingest tasks are aborted and the
1013/// subscribers undeclare.
1014///
1015/// This is not a nicety. A `JoinHandle` merely *detaches* on drop, so without
1016/// this impl every monitor that goes out of scope leaks a live subscriber and
1017/// its ingest task for the lifetime of the session. `zenctl` never noticed —
1018/// it calls [`Monitor::stop`] once and exits — but a GUI re-scopes its
1019/// subscription whenever the user changes what they are watching, dropping and
1020/// rebuilding the monitor each time.
1021///
1022/// **Every** task, which for one release meant every task but the seeded
1023/// watches' (#342): those handles live in `watches`, and aborting only
1024/// `self.tasks` detached them. Each holds a cloned [`Session`] and goes on
1025/// calling `core.ingest`/`core.tick`, so the re-scoping GUI above left one
1026/// running per drop, each holding session teardown open for up to the seed
1027/// timeout. `unwatch` and `shutdown` had aborted them all along; the async
1028/// mutex is `get_mut` here, which needs no lock because `Drop` holds
1029/// `&mut self`.
1030impl Drop for Monitor {
1031    fn drop(&mut self) {
1032        for t in &self.tasks {
1033            t.abort();
1034        }
1035        for entry in self.watches.get_mut().values_mut() {
1036            if let Some(task) = entry.seed_task.take() {
1037                task.abort();
1038            }
1039        }
1040    }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    /// A fold that started earlier must never overwrite one that started
1048    /// later, however long it takes to come back.
1049    ///
1050    /// #330 moved the periodic fold to the blocking pool and opened exactly
1051    /// that window: `tick_off_runtime` copies the rows, hands the fold away,
1052    /// and publishes on return — so a synchronous `tick()` taken *after* it
1053    /// (a seed boundary, an unwatch) could publish first and then be
1054    /// overwritten by the earlier fold's stale result. It surfaced as the
1055    /// seeding test's "the seeded key is already in the tree at the boundary
1056    /// tick" failing with an empty tree while its own coverage line said the
1057    /// seed reply had arrived.
1058    ///
1059    /// Driven through `publish` directly, because reproducing the interleave
1060    /// through the blocking pool is exactly the race that only shows up under
1061    /// load — the ordering rule is the property, and it is testable.
1062    #[test]
1063    fn an_older_fold_never_walks_the_tree_backwards() {
1064        let core = MonitorCore::new(8);
1065
1066        // Two ticks, taken in order: the second sees a key the first did not.
1067        let (empty_rows, first) = core.stats_rows();
1068        core.ingest(view("v1/h-3fa9c2d41b7e/telemetry/p/x", 4), None);
1069        let (seeded_rows, second) = core.stats_rows();
1070        assert!(second > first, "the sequence orders the two takes");
1071
1072        // The *newer* fold lands first — the seed boundary's synchronous tick.
1073        core.publish(KeyTreeSnapshot::fold(seeded_rows), second);
1074        assert_eq!(core.tree().keys, 1, "the boundary tick published");
1075
1076        // …and the older one, back from the blocking pool, is dropped.
1077        core.publish(KeyTreeSnapshot::fold(empty_rows), first);
1078        assert_eq!(
1079            core.tree().keys,
1080            1,
1081            "an older fold overwrote a newer snapshot — the tree walked backwards"
1082        );
1083    }
1084
1085    fn view(key: &str, len: usize) -> SampleView {
1086        SampleView {
1087            key: key.to_string(),
1088            payload: zenoh::bytes::ZBytes::from(vec![0u8; len]),
1089            encoding: "zenoh/bytes".to_string(),
1090            kind: SampleKind::Put,
1091            timestamp: None,
1092            stamped_by: None,
1093            attachment: None,
1094            priority: zenoh::qos::Priority::DEFAULT,
1095            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
1096            reliability: zenoh::qos::Reliability::DEFAULT,
1097            express: false,
1098            source: None,
1099            received: Instant::now(),
1100        }
1101    }
1102
1103    #[tokio::test]
1104    async fn events_flow_and_snapshots_rebuild_on_tick() {
1105        let core = MonitorCore::new(8);
1106        let mut events = core.events();
1107        core.ingest(view("zs/v1/h-a/telemetry/x/m", 4), None);
1108        core.tick();
1109
1110        let Some(StreamItem::Event(FleetEvent::Sample(s))) = events.recv().await else {
1111            panic!("expected sample");
1112        };
1113        assert_eq!(s.key, "zs/v1/h-a/telemetry/x/m");
1114        assert_eq!(s.payload.len(), 4);
1115        let Some(StreamItem::Event(FleetEvent::StatsTick)) = events.recv().await else {
1116            panic!("expected tick");
1117        };
1118        let snap = core.tree();
1119        assert_eq!(snap.keys, 1);
1120        assert_eq!(snap.root.subtree_count, 1);
1121    }
1122
1123    /// Deep-review D2: `ingest_at` measures the skewed latency (#119) from
1124    /// the **injected** wall clock, never `SystemTime::now()` — so a replay
1125    /// rebuild that injects the capture clock on both axes folds the same
1126    /// latencies every time, exactly (the #217 bit-identical promise), and
1127    /// the number itself is `wall − HLC`, not `rebuild-time − HLC`.
1128    #[test]
1129    fn injected_wall_clock_drives_the_latency_fold_deterministically() {
1130        let stamp_epoch = Duration::from_secs(1_000_000);
1131        let ts = zenoh::time::Timestamp::new(
1132            zenoh::time::NTP64::from(stamp_epoch),
1133            zenoh::time::TimestampId::rand(),
1134        );
1135        let key = "zs/v1/h-a/telemetry/x/m";
1136        let stamped_view = || {
1137            let mut v = view(key, 4);
1138            v.timestamp = Some(ts);
1139            v.stamped_by = Some(StampProvenance::Unattributable {
1140                stamper: *ts.get_id(),
1141            });
1142            v
1143        };
1144        let now = Instant::now();
1145        // The injected wall clock says the sample arrived 5 ms after its
1146        // stamp — regardless of what the live wall clock reads (it is a
1147        // million seconds past this epoch already).
1148        let wall = ts.get_time().to_system_time() + Duration::from_millis(5);
1149
1150        let fold = || {
1151            let core = MonitorCore::new(8);
1152            core.ingest_at(Arc::new(stamped_view()), None, now, wall);
1153            core.with_stats(|s| s.get(key).expect("recorded").latency())
1154                .expect("a stamped sample has a latency window")
1155        };
1156        let a = fold();
1157        let summary = a.unattributable.expect("unattributable population");
1158        assert_eq!(summary.samples, 1);
1159        assert_eq!(
1160            summary.median_us, 5_000,
1161            "the latency is wall − HLC, on the injected wall clock"
1162        );
1163        // A second rebuild with the same injected clocks folds identically —
1164        // a live `SystemTime::now()` read in between would not.
1165        assert_eq!(a, fold());
1166    }
1167
1168    /// The bounded-channel honesty contract: a lagging receiver is told how
1169    /// many it missed — never a silent gap.
1170    #[tokio::test]
1171    async fn overflow_surfaces_as_dropped_counts() {
1172        let core = MonitorCore::new(2);
1173        let mut slow = core.events();
1174        for i in 0..10 {
1175            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
1176        }
1177        let Some(StreamItem::Dropped(n)) = slow.recv().await else {
1178            panic!("expected a dropped count first");
1179        };
1180        assert!(n >= 8, "missed at least 8, reported {n}");
1181        assert_eq!(core.dropped(), n);
1182        // The stream then resumes with the retained tail.
1183        let Some(StreamItem::Event(FleetEvent::Sample(_))) = slow.recv().await else {
1184            panic!("expected a sample after the gap report");
1185        };
1186    }
1187
1188    /// The retained window rides the ingest path, not the broadcast: a
1189    /// receiver that lagged its way to `Dropped(n)` lost nothing from the
1190    /// ring, and the window still holds every ingested sample (#217).
1191    #[tokio::test]
1192    async fn the_ring_sees_what_a_lagging_receiver_missed() {
1193        let core = MonitorCore::new(2);
1194        let mut slow = core.events();
1195        for i in 0..10 {
1196            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 1), None);
1197        }
1198        let Some(StreamItem::Dropped(_)) = slow.recv().await else {
1199            panic!("the broadcast lagged");
1200        };
1201        let window = core.retained();
1202        assert_eq!(window.len(), 10, "the ring is upstream of the lag");
1203        assert_eq!(window[0].key, "zs/v1/h-a/telemetry/x/m0");
1204        assert_eq!(window[9].key, "zs/v1/h-a/telemetry/x/m9");
1205    }
1206
1207    /// #330: the ingest lock is held for the **copy**, not for the build.
1208    ///
1209    /// The measurement is a ratio rather than a wall-clock budget, because
1210    /// what the issue asserts is a complexity claim: the critical section is
1211    /// O(keys), the fold O(keys × chunks). Timed on the same table in the
1212    /// same build, the copy must therefore come out a small fraction of the
1213    /// fold — and it is the copy, and only the copy, that a network callback
1214    /// thread waits behind.
1215    #[test]
1216    fn the_tick_holds_the_ingest_lock_only_for_the_row_copy() {
1217        const KEYS: usize = 5_000;
1218        let core = MonitorCore::bounded(2, KEYS * 2);
1219        let now = Instant::now();
1220        core.with_stats_mut(|stats| {
1221            for i in 0..KEYS {
1222                // Eight chunks: the fold does eight `BTreeMap` descents per
1223                // key, the copy does one refcount bump.
1224                stats.record(
1225                    &format!(
1226                        "zs/v1/h-{:04}/telemetry/proc-{i}/group/sub/leaf/m{i}",
1227                        i % 97
1228                    ),
1229                    64,
1230                    None,
1231                    now,
1232                    None,
1233                    None,
1234                );
1235            }
1236        });
1237
1238        // Phase 1 — everything the lock is held for.
1239        let t0 = Instant::now();
1240        let (rows, _seq) = core.stats_rows();
1241        let copy = t0.elapsed();
1242        assert_eq!(rows.rows.len(), KEYS);
1243        let copied_rows = rows.rows.len();
1244
1245        // Phase 2 — everything that now happens with the lock released.
1246        let t1 = Instant::now();
1247        let snapshot = KeyTreeSnapshot::fold(rows);
1248        let fold = t1.elapsed();
1249        assert_eq!(snapshot.keys, KEYS);
1250
1251        // Measured at 5 000 keys of 8 chunks (debug): copy ~0.9 ms, fold
1252        // ~48 ms — a factor of ~55, and `benches/frame.rs` (`tree/rows_50k`
1253        // beside `tree/build_50k`) is where that number is tracked.
1254        //
1255        // The assertion here is deliberately NOT the ratio. A wall-clock
1256        // comparison in a unit test measures the scheduler as much as the
1257        // code, and CI runs this beside sixty other binaries; a test that
1258        // fails when the box is busy teaches people to re-run rather than to
1259        // read. What must hold structurally is that phase 1 hands phase 2
1260        // every key *without* having built anything — one row per key, no
1261        // tree — so folding under the lock again cannot pass unnoticed: the
1262        // rows would have to come back already folded, and `fold` consumes
1263        // them.
1264        assert_eq!(copied_rows, snapshot.keys, "one row in, one key out");
1265        assert!(
1266            copy < fold * 100,
1267            "a sane machine folds slower than it copies; copy {copy:?}, fold {fold:?}"
1268        );
1269    }
1270
1271    /// The split changes what the lock costs, never what the snapshot says:
1272    /// `build` (copy + fold) and the tick agree, key for key.
1273    #[test]
1274    fn the_split_fold_is_the_same_snapshot() {
1275        let core = MonitorCore::new(8);
1276        for i in 0..50 {
1277            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 4), None);
1278        }
1279        core.tick();
1280        let ticked = core.tree();
1281        let direct = core.with_stats(KeyTreeSnapshot::build);
1282        assert_eq!(ticked.keys, direct.keys);
1283        assert_eq!(ticked.root.subtree_count, direct.root.subtree_count);
1284        assert_eq!(ticked.root.subtree_bytes, direct.root.subtree_bytes);
1285        assert_eq!(
1286            ticked
1287                .node(&["zs", "v1", "h-a", "telemetry", "x"])
1288                .unwrap()
1289                .subtree_keys,
1290            50
1291        );
1292    }
1293
1294    /// #331: a retained-window read holds the ingest mutex for the chunk
1295    /// pointers and the open tail, and flattens the window afterwards.
1296    ///
1297    /// The same shape of measurement as the tick's (#330), for the same
1298    /// reason: the claim is that the under-lock half no longer scales with
1299    /// the window. It is asserted as a ratio against the flatten — the half
1300    /// that does scale — so a return to `ring.iter().cloned().collect()`
1301    /// under the guard fails it.
1302    #[test]
1303    fn a_retained_read_holds_the_ingest_lock_for_chunk_pointers_only() {
1304        const SAMPLES: usize = 40_000;
1305        let core = MonitorCore::new(2);
1306        let now = Instant::now();
1307        for i in 0..SAMPLES {
1308            core.ingest_at(
1309                Arc::new(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 8)),
1310                None,
1311                now,
1312                std::time::SystemTime::now(),
1313            );
1314        }
1315
1316        // Phase 1 — everything the network callback thread waits behind.
1317        let t0 = Instant::now();
1318        let parts = core
1319            .retain
1320            .lock()
1321            .expect("retain lock")
1322            .parts(Instant::now());
1323        let under_lock = t0.elapsed();
1324        let held_chunks = parts.chunks();
1325
1326        // Phase 2 — everything that now happens with the guard dropped.
1327        let t1 = Instant::now();
1328        let window = parts.flatten();
1329        let flatten = t1.elapsed();
1330
1331        assert_eq!(window.len(), SAMPLES, "the whole window, unchanged");
1332        assert_eq!(window[0].key, "zs/v1/h-a/telemetry/x/m0");
1333        // As above: the property is structural, not a stopwatch reading.
1334        // What the lock holds is chunk pointers — bounded by
1335        // `window / CHUNK + 1` regardless of how many samples the window
1336        // carries — and `parts` proves it by construction, so a read that
1337        // went back to cloning the ring would fail the count, not a race.
1338        assert!(
1339            held_chunks <= SAMPLES / crate::model::retain::CHUNK + 2,
1340            "the critical section holds chunk pointers, not samples: {held_chunks} chunks for {SAMPLES} samples"
1341        );
1342        let _ = (under_lock, flatten);
1343    }
1344
1345    /// RFC 09 §5.1 **O6** / v1.18 **R1**: the eviction populations stay
1346    /// separate numbers (#217). Broadcast lag ("could not keep up"),
1347    /// stats-table eviction ("chose to forget under the key bound") and
1348    /// retention eviction ("chose to forget under the window's byte budget")
1349    /// are three different facts about the same session, and each ledger
1350    /// balances on its own.
1351    #[tokio::test]
1352    async fn the_eviction_populations_are_never_folded() {
1353        const SAMPLES: usize = 100;
1354        let core = MonitorCore::bounded(2, 8);
1355        core.set_retention_budget(crate::model::retain::RetentionBudget {
1356            max_bytes: 1100,
1357            max_age: Duration::from_secs(3600),
1358        });
1359        let mut slow = core.events();
1360        for i in 0..SAMPLES {
1361            core.ingest(view(&format!("zs/v1/h-a/telemetry/x/m{i}"), 64), None);
1362        }
1363        let Some(StreamItem::Dropped(lagged)) = slow.recv().await else {
1364            panic!("the broadcast lagged");
1365        };
1366
1367        // Each population's own ledger balances — nothing crossed over.
1368        assert_eq!(core.dropped(), lagged, "lag counts only broadcast lag");
1369        let table_kept = core.with_stats(StatsTable::len);
1370        assert_eq!(
1371            table_kept as u64 + core.keys_evicted(),
1372            SAMPLES as u64,
1373            "every key is in the table or in its eviction count"
1374        );
1375        let r = core.retention();
1376        assert_eq!(
1377            r.retained as u64 + r.evicted,
1378            SAMPLES as u64,
1379            "every sample is in the ring or in its eviction count"
1380        );
1381        assert_eq!(r.expired, 0, "nothing aged out in this window");
1382        assert_eq!(core.keys_unwatched(), 0, "nothing was unwatched");
1383
1384        // And they are genuinely different numbers, not one figure worn
1385        // three ways.
1386        assert_ne!(r.evicted, core.keys_evicted());
1387        assert_ne!(r.evicted, core.dropped());
1388    }
1389}