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}