Skip to main content

zenkey_fleet/tape/
record.rs

1//! `.zrec` capture and replay (issue #39; RFC 09 §5.2 documents the
2//! etiquette, this module is normative for the format).
3//!
4//! A `.zrec` file is newline-delimited JSON in the explorers' one row
5//! dialect — the same shape `echo --format ndjson` emits and
6//! [`crate::tape::ingest::parse_row`] reads back — upgraded with what a pipe does
7//! not need but a capture does: a versioned header line naming what was
8//! asked, a lossless `"bytes"` payload (a `"value"` is a rendering), a
9//! pacing offset `"t"` on the observer's arrival clock, and drop records
10//! interleaved **where the gap happened** (RFC 09 §5.1 O6, applied to a
11//! file — a capture taken while behind is a partial view and says so at
12//! the position of the loss).
13//!
14//! **Neither direction touches the disk from a runtime thread** (#332).
15//! [`ZrecWriter`] and [`ZrecReader`] are plain synchronous `std::io` — a
16//! capture is line-at-a-time base64 and JSON, which is CPU as well as I/O —
17//! and the async ends of the module, [`ZrecSink`] and [`ZrecSource`], run
18//! them on the blocking pool behind a bounded channel. The alternative,
19//! `AsyncWrite`/`AsyncBufRead` bounds on the two types, was rejected: the
20//! serialization would still run on a runtime worker, and the two callers
21//! that write a `.zrec` **without** a runtime at all — zengui's "save the
22//! retained window", this module's tests — would need a second, synchronous
23//! writer to stay honest. `bus/blob/transfer.rs`'s `tokio::fs` is the model
24//! for byte-shovelling; this is the model for a serialising loop.
25//!
26//! It matters because the thing being recorded is the thing the writer
27//! stalls: a blocking `write_all` per sample on the drain's own task left the
28//! monitor's bounded broadcast unattended, and `zenctl record` faithfully
29//! wrote `{"dropped": n}` records it had caused itself.
30//!
31//! Replay is publishing. Every replayed sample rides a declared publisher
32//! ([`crate::bus::write::declare_publication`], P7 — no ad-hoc puts), gets the
33//! *replaying* session's HLC (re-stamped deliberately: a preserved foreign
34//! HLC silently loses every RFC 04 §3.2 reconciliation), and a recorded
35//! delete passes the same class-conscious retire gate as a live one
36//! ([`crate::bus::write::check_retire`], RFC 04 §1.2 v1.12). The etiquette the
37//! CLI enforces on top — dry-run first, header-base refusal without an
38//! explicit override — is RFC 09 §5.2's.
39
40use std::collections::HashMap;
41use std::io::{BufRead, Write};
42use std::sync::Arc;
43use std::sync::atomic::{AtomicU64, Ordering};
44use std::time::{Duration, Instant};
45
46use crate::{Error, Result};
47use zenkey::qos::QosProfile;
48use zenoh::Session;
49use zenoh::sample::SampleKind;
50
51use crate::bus::monitor::{EventStream, FleetEvent, SampleView, StreamItem};
52use crate::model::registry::SliceSet;
53use crate::report::{ReplayReport, SampleRow, Transition, ZrecHeader};
54use crate::tape::ingest::{IngestRow, parse_row};
55
56/// The current `.zrec` format version, written into every header.
57///
58/// Version 2 (RFC 13 §4.1, v1.34; #218) adds the state preamble — rows
59/// marked `"preamble": true` at `t: 0` ahead of the first observed row —
60/// and the interleaved `{"trigger": …}` record. A version-1 file is a
61/// version-2 file with neither, which is why [`ZREC_READS`] names both.
62pub const ZREC_VERSION: u32 = 2;
63
64/// The versions [`ZrecReader`] speaks: the current one and every earlier
65/// one whose lines it still reads verbatim. A version outside this list is
66/// refused, never guessed at (RFC 13 §4.1's unknown-version rule) — and
67/// the list is what lets a version-2 reader read version 1 while a
68/// version-1 reader refuses version 2, both by the rule they already had.
69pub const ZREC_READS: [u32; 2] = [1, 2];
70
71/// Why a replayer skips a preamble row unless told otherwise — one sentence,
72/// spelled once, carried on every [`ReplayEvent::PreambleSkipped`].
73pub const PREAMBLE_SKIP_REASON: &str = "state at capture start; re-stamping it republishes a \
74     snapshot over live state (RFC 13 §4.2) — pass --seed-state to mean it";
75
76/// RFC 3339 UTC "now", seconds precision — the header's provenance stamp.
77/// A hand-rolled civil-date conversion (Hinnant's days algorithm) beats a
78/// clock crate this crate needs for nothing else.
79pub fn rfc3339_now() -> String {
80    rfc3339_from_unix(
81        std::time::SystemTime::now()
82            .duration_since(std::time::UNIX_EPOCH)
83            .map(|d| d.as_secs())
84            .unwrap_or(0),
85    )
86}
87
88/// RFC 3339 (`YYYY-MM-DDTHH:MM:SSZ`) of a Unix second count — the one
89/// formatter every timestamp this engine writes goes through.
90pub fn rfc3339_from_unix(secs: u64) -> String {
91    let (days, rem) = (secs / 86_400, secs % 86_400);
92    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
93    // Civil from days since 1970-01-01 (era-based, valid far past 2100).
94    let z = days as i64 + 719_468;
95    let era = z.div_euclid(146_097);
96    let doe = z.rem_euclid(146_097);
97    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
98    let y = yoe + era * 400;
99    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
100    let mp = (5 * doy + 2) / 153;
101    let d = doy - (153 * mp + 2) / 5 + 1;
102    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
103    let y = if mo <= 2 { y + 1 } else { y };
104    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
105}
106
107/// A `.zrec` writer over any byte sink: header first, then rows as they
108/// arrive, drop records in place. Wrap the sink in a `BufWriter` — the
109/// writer emits line-at-a-time and never buffers samples itself, so a
110/// capture streams in bounded memory.
111pub struct ZrecWriter<W: Write> {
112    out: W,
113    /// The capture epoch on this observer's monotonic clock: every row's
114    /// `t` is an offset from here. Stamped at construction, so a sample
115    /// received before the writer existed saturates to 0 rather than
116    /// underflowing.
117    epoch: Instant,
118    counts: SinkCounts,
119}
120
121/// What a writer or sink has put on the file so far, by kind — and the
122/// kinds are never folded (RFC 13 §4.1: a reader counts preamble rows
123/// apart from observed rows, so the writer does too).
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub struct SinkCounts {
126    /// Observed sample rows.
127    pub samples: u64,
128    /// Samples the capture missed, summed from the drop records it wrote.
129    pub dropped: u64,
130    /// Preamble rows (version 2).
131    pub preamble: u64,
132    /// Trigger records (version 2).
133    pub triggers: u64,
134}
135
136impl<W: Write> ZrecWriter<W> {
137    /// Write the header line and hand back a row writer.
138    pub fn new(out: W, header: &ZrecHeader) -> Result<Self> {
139        ZrecWriter::new_at(out, header, Instant::now())
140    }
141
142    /// [`ZrecWriter::new`] with the capture epoch injected (#217).
143    ///
144    /// A live capture's epoch is "now" — nothing precedes the writer. A
145    /// **retained window** is the opposite: every sample was received before
146    /// the writer existed, and under `new` they would all saturate to `t: 0`,
147    /// erasing the pacing the ring preserved. Passing the window's own start
148    /// (its oldest sample's arrival) keeps each row's `t` the offset it
149    /// really had, so the file is indistinguishable from one recorded
150    /// deliberately at that moment.
151    pub fn new_at(mut out: W, header: &ZrecHeader, epoch: Instant) -> Result<Self> {
152        serde_json::to_writer(&mut out, header).map_err(|e| Error::Io {
153            path: std::path::PathBuf::new(),
154            source: e.into(),
155        })?;
156        out.write_all(b"\n").map_err(|e| Error::Io {
157            path: std::path::PathBuf::new(),
158            source: e,
159        })?;
160        Ok(ZrecWriter {
161            out,
162            epoch,
163            counts: SinkCounts::default(),
164        })
165    }
166
167    fn line(&mut self, line: &str) -> Result<()> {
168        self.out.write_all(line.as_bytes()).map_err(|e| Error::Io {
169            path: std::path::PathBuf::new(),
170            source: e,
171        })?;
172        self.out.write_all(b"\n").map_err(|e| Error::Io {
173            path: std::path::PathBuf::new(),
174            source: e,
175        })
176    }
177
178    /// The row a sample becomes, minus its pacing: the wire facts, the
179    /// lossless payload unless it is a tombstone, the attachment.
180    fn row_of(view: &SampleView) -> SampleRow {
181        let mut row = SampleRow {
182            key: view.key.clone(),
183            ..SampleRow::default()
184        }
185        .with_wire(view);
186        // A tombstone has no payload to store: `delete` is the whole fact
187        // (RFC 04 §1.2), and an empty `bytes` would read as an empty put.
188        if view.kind != SampleKind::Delete {
189            row = row.with_payload_bytes(&view.payload.to_bytes());
190        }
191        if let Some(a) = &view.attachment {
192            row.attachment_b64 = Some(crate::tape::ingest::b64(&a.to_bytes()));
193        }
194        row
195    }
196
197    /// Write one observed sample as a row.
198    ///
199    /// The payload rides lossless (`"bytes"`), the pacing offset is the
200    /// observer's arrival clock (`"t"`, µs since the capture epoch), and
201    /// the publisher's HLC — when one rode the sample — is carried
202    /// informatively (`"timestamp"`): replay re-stamps (RFC 09 §5.2). QoS
203    /// is stored as a profile *name* only when the wire's actual axes match
204    /// one (RFC 04 §3); axes matching no profile are not approximated —
205    /// a rule [`SampleRow::with_wire`] now enforces for every writer of the
206    /// dialect rather than for this one alone (#235).
207    ///
208    /// A capture carries **no** `origin`/`subject`: those are the observer's
209    /// reading of the key under a base it chose, and a file that outlives
210    /// the session must not freeze one deployment's interpretation into
211    /// somebody else's replay (RFC 09 §5.2 — keys are recorded whole and
212    /// never re-derived from the header's base).
213    pub fn write_sample(&mut self, view: &SampleView) -> Result<()> {
214        let t_us = u64::try_from(
215            view.received
216                .saturating_duration_since(self.epoch)
217                .as_micros(),
218        )
219        .unwrap_or(u64::MAX);
220        let mut row = ZrecWriter::<W>::row_of(view);
221        row.t = Some(t_us);
222        self.line(&row.to_line())?;
223        self.counts.samples += 1;
224        Ok(())
225    }
226
227    /// Write one **preamble** row (version 2, RFC 13 §4.1; #218): a value
228    /// fetched at trigger time, written ahead of the first observed row.
229    ///
230    /// `t` is 0 — the row precedes the window, and a preamble is state, not
231    /// pacing — and `"preamble": true` marks it so a reader counts it apart
232    /// from observed rows (O6 applied to rows). The `timestamp` is the HLC
233    /// the fetched value carried, kept as provenance: it says when the
234    /// value was published, which is the one thing a pre-roll of `state`
235    /// deltas cannot say for itself.
236    pub fn write_preamble(&mut self, view: &SampleView) -> Result<()> {
237        let mut row = ZrecWriter::<W>::row_of(view);
238        row.t = Some(0);
239        row.preamble = Some(true);
240        self.line(&row.to_line())?;
241        self.counts.preamble += 1;
242        Ok(())
243    }
244
245    /// Write a drop record where the gap happened (O6 on a file).
246    pub fn write_dropped(&mut self, n: u64) -> Result<()> {
247        let line =
248            serde_json::to_string(&serde_json::json!({ "dropped": n })).map_err(|e| Error::Io {
249                path: std::path::PathBuf::new(),
250                source: e.into(),
251            })?;
252        self.line(&line)?;
253        self.counts.dropped += n;
254        Ok(())
255    }
256
257    /// Write the trigger record where the transition was observed (version
258    /// 2, RFC 13 §4.1): `{"trigger": {rule, from, to, at, evidence}}` — no
259    /// `key`, like a drop record — so a reader can say what fired and where
260    /// in the file it did.
261    pub fn write_trigger(&mut self, transition: &Transition) -> Result<()> {
262        let line =
263            serde_json::to_string(&serde_json::json!({ "trigger": transition })).map_err(|e| {
264                Error::Io {
265                    path: std::path::PathBuf::new(),
266                    source: e.into(),
267                }
268            })?;
269        self.line(&line)?;
270        self.counts.triggers += 1;
271        Ok(())
272    }
273
274    /// What has been written so far, by kind — the progress line's numbers.
275    pub fn counts(&self) -> SinkCounts {
276        self.counts
277    }
278
279    /// Flush and hand the sink back.
280    pub fn finish(mut self) -> Result<W> {
281        self.out.flush().map_err(|e| Error::Io {
282            path: std::path::PathBuf::new(),
283            source: e,
284        })?;
285        Ok(self.out)
286    }
287}
288
289/// How many lines a [`ZrecSink`] queues ahead of its writer.
290///
291/// Four times the monitor's default broadcast capacity (1024), on purpose:
292/// a burst the *bus* side can hold is a burst the disk side can hold too, so
293/// a momentary write stall spends the queue instead of manufacturing drops
294/// the bus never had. Past that the queue backpressures the drain — which is
295/// where a genuinely-too-slow disk belongs, surfacing as `Dropped(n)` like
296/// any other observer that could not keep up (RFC 13 §3 O6). Bounded, not
297/// unbounded, because a capture promises to stream in bounded memory.
298const SINK_QUEUE: usize = 4096;
299
300/// One line on its way to the disk. Serialization happens on the writer's
301/// thread, so what crosses the channel is the sample itself — a pointer
302/// move, not a copy.
303enum ZrecLine {
304    Sample(Arc<SampleView>),
305    Dropped(u64),
306    Preamble(Arc<SampleView>),
307    Trigger(Box<Transition>),
308}
309
310/// What the sink's async half can see of a writer that lives on the
311/// blocking pool.
312#[derive(Debug, Default)]
313struct SinkState {
314    samples: AtomicU64,
315    dropped: AtomicU64,
316    preamble: AtomicU64,
317    triggers: AtomicU64,
318    /// The writer's error, kept where the async half can name it: a `send`
319    /// that fails says only "the writer is gone", and the reason is what the
320    /// operator needs.
321    failure: std::sync::Mutex<Option<String>>,
322}
323
324/// A [`ZrecWriter`] running on the blocking pool behind a bounded channel
325/// (#332) — the async end of a capture.
326///
327/// Every byte of `.zrec` I/O, and every base64 and JSON encode that precedes
328/// it, happens on a blocking thread. The runtime side of a capture does
329/// nothing but move `Arc`s into a queue, so the drain stays available to the
330/// monitor's bounded broadcast and the drop records in the file mean what
331/// they say: samples the *bus* outran the observer with, not samples the
332/// observer's own writer stalled it out of.
333pub struct ZrecSink {
334    tx: tokio::sync::mpsc::Sender<ZrecLine>,
335    state: Arc<SinkState>,
336    writer: tokio::task::JoinHandle<Result<SinkCounts>>,
337}
338
339impl ZrecSink {
340    /// Write `header` and hand back the sink. Awaits the header's own write,
341    /// so a sink that comes back is a file with a valid first line on it —
342    /// the failure an operator must hear about before a capture starts
343    /// "running".
344    ///
345    /// The capture epoch is stamped **here** rather than on the blocking
346    /// thread: every row's `t` is an offset from it, and it must not shift by
347    /// however long the pool took to pick the task up.
348    pub async fn spawn<W: Write + Send + 'static>(out: W, header: &ZrecHeader) -> Result<ZrecSink> {
349        ZrecSink::spawn_at(out, header, Instant::now()).await
350    }
351
352    /// [`ZrecSink::spawn`] with the capture epoch injected — the streaming
353    /// twin of [`ZrecWriter::new_at`], and the seam a test uses to write
354    /// deterministic offsets.
355    pub async fn spawn_at<W: Write + Send + 'static>(
356        out: W,
357        header: &ZrecHeader,
358        epoch: Instant,
359    ) -> Result<ZrecSink> {
360        let (tx, mut rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
361        let (ready, opened) = tokio::sync::oneshot::channel();
362        let state = Arc::new(SinkState::default());
363        let header = header.clone();
364        let task_state = Arc::clone(&state);
365        let writer = tokio::task::spawn_blocking(move || {
366            let mut writer = match ZrecWriter::new_at(out, &header, epoch) {
367                Ok(w) => {
368                    let _ = ready.send(None);
369                    w
370                }
371                Err(e) => {
372                    let _ = ready.send(Some(crate::one_line(&e)));
373                    return Err(e);
374                }
375            };
376            while let Some(line) = rx.blocking_recv() {
377                let wrote = match line {
378                    ZrecLine::Sample(view) => writer.write_sample(&view),
379                    ZrecLine::Dropped(n) => writer.write_dropped(n),
380                    ZrecLine::Preamble(view) => writer.write_preamble(&view),
381                    ZrecLine::Trigger(t) => writer.write_trigger(&t),
382                };
383                if let Err(e) = wrote {
384                    *task_state.failure.lock().expect("sink failure lock") =
385                        Some(crate::one_line(&e));
386                    return Err(e);
387                }
388            }
389            let counts = writer.counts();
390            writer.finish().map(|_| counts)
391        });
392        match opened.await {
393            Ok(None) => Ok(ZrecSink { tx, state, writer }),
394            // The writer names the file in its own message; this is the
395            // open failing, which is I/O against a path the caller gave.
396            Ok(Some(reason)) => Err(Error::Io {
397                path: std::path::PathBuf::new(),
398                source: std::io::Error::other(reason),
399            }),
400            Err(_) => Err(Error::Internal(
401                "the .zrec writer stopped before it opened".into(),
402            )),
403        }
404    }
405
406    /// Queue one sample. Awaits only the queue's capacity — never the disk.
407    pub async fn write_sample(&self, view: Arc<SampleView>) -> Result<()> {
408        self.send(ZrecLine::Sample(view)).await?;
409        self.state.samples.fetch_add(1, Ordering::Relaxed);
410        Ok(())
411    }
412
413    /// Queue a drop record at the position the gap happened (O6 on a file).
414    pub async fn write_dropped(&self, n: u64) -> Result<()> {
415        self.send(ZrecLine::Dropped(n)).await?;
416        self.state.dropped.fetch_add(n, Ordering::Relaxed);
417        Ok(())
418    }
419
420    /// Queue one preamble row ([`ZrecWriter::write_preamble`]).
421    pub async fn write_preamble(&self, view: Arc<SampleView>) -> Result<()> {
422        self.send(ZrecLine::Preamble(view)).await?;
423        self.state.preamble.fetch_add(1, Ordering::Relaxed);
424        Ok(())
425    }
426
427    /// Queue the trigger record at this position
428    /// ([`ZrecWriter::write_trigger`]).
429    pub async fn write_trigger(&self, transition: Transition) -> Result<()> {
430        self.send(ZrecLine::Trigger(Box::new(transition))).await?;
431        self.state.triggers.fetch_add(1, Ordering::Relaxed);
432        Ok(())
433    }
434
435    async fn send(&self, line: ZrecLine) -> Result<()> {
436        if self.tx.send(line).await.is_ok() {
437            return Ok(());
438        }
439        // The writer is gone, which only happens because it failed: report
440        // *its* error rather than the channel's shadow of it.
441        let failure = self
442            .state
443            .failure
444            .lock()
445            .expect("sink failure lock")
446            .clone();
447        Err(Error::Internal(
448            failure.unwrap_or_else(|| "the .zrec writer stopped".to_string()),
449        ))
450    }
451
452    /// What has been **accepted** so far, by kind — the progress line's
453    /// numbers.
454    ///
455    /// Accepted, not yet written: the queue is what stands between the two,
456    /// and [`finish`](Self::finish) drains it, so the final counts are the
457    /// file's. A progress line that waited for the disk would be reporting
458    /// the disk, not the capture.
459    pub fn counts(&self) -> SinkCounts {
460        SinkCounts {
461            samples: self.state.samples.load(Ordering::Relaxed),
462            dropped: self.state.dropped.load(Ordering::Relaxed),
463            preamble: self.state.preamble.load(Ordering::Relaxed),
464            triggers: self.state.triggers.load(Ordering::Relaxed),
465        }
466    }
467
468    /// Close the queue, wait for the writer to drain it, flush, and report
469    /// what reached the file, by kind.
470    ///
471    /// This is where a write error surfaces if the capture did not already
472    /// trip over it. The counts come from the writer rather than the queue,
473    /// so a report built on them is a report about the file.
474    pub async fn finish(self) -> Result<SinkCounts> {
475        let ZrecSink { tx, state, writer } = self;
476        drop(tx);
477        drop(state);
478        writer
479            .await
480            .map_err(|e| Error::Internal(format!("the .zrec writer panicked: {e}")))?
481    }
482}
483
484/// Bounds on a capture. Unset bounds mean "until the caller stops the
485/// loop" (Ctrl-C is the caller's `select!`, not this module's business —
486/// [`record()`](record) is cancel-safe between lines).
487#[derive(Debug, Clone, Copy, Default)]
488pub struct RecordBounds {
489    /// Stop after this many samples (drop records do not count).
490    pub max_samples: Option<u64>,
491    /// Stop after this long, measured from entering [`record()`](record).
492    pub max_duration: Option<Duration>,
493}
494
495/// Drain a monitor's event stream into a `.zrec` [`ZrecSink`] until a bound
496/// is hit or the stream ends. Samples and interleaved drops are recorded;
497/// liveliness and tick events are not part of the format. `on_progress` is
498/// called after every queued line with (samples, dropped) — throttle in
499/// the callback, not here.
500///
501/// **This loop never touches the disk** (#332): it moves `Arc`s into the
502/// sink's bounded queue and goes straight back to the stream, so the
503/// monitor's broadcast stays attended and a `{"dropped": n}` in the file
504/// means the bus outran the observer — not that the observer's own writer
505/// stalled its drain. A disk that is slower than the bus *on average* still
506/// backpressures through the queue and still drops, honestly.
507///
508/// Cancel-safe: dropping the future mid-`recv` loses nothing already
509/// queued (each line lands whole, in order); call
510/// [`ZrecSink::finish`] afterwards to drain and flush.
511pub async fn record(
512    events: &mut EventStream,
513    sink: &ZrecSink,
514    bounds: RecordBounds,
515    mut on_progress: impl FnMut(u64, u64),
516) -> Result<()> {
517    let deadline = bounds.max_duration.map(|d| Instant::now() + d);
518
519    loop {
520        let samples = sink.counts().samples;
521        if bounds.max_samples.is_some_and(|max| samples >= max) {
522            return Ok(());
523        }
524        let item = match deadline {
525            Some(d) => {
526                let left = d.saturating_duration_since(Instant::now());
527                if left.is_zero() {
528                    return Ok(());
529                }
530                match tokio::time::timeout(left, events.recv()).await {
531                    Ok(item) => item,
532                    Err(_) => return Ok(()),
533                }
534            }
535            None => events.recv().await,
536        };
537        match item {
538            Some(StreamItem::Event(FleetEvent::Sample(view))) => {
539                sink.write_sample(view).await?;
540            }
541            Some(StreamItem::Dropped(n)) => {
542                sink.write_dropped(n).await?;
543            }
544            Some(_) => continue,
545            None => return Ok(()),
546        }
547        let counts = sink.counts();
548        on_progress(counts.samples, counts.dropped);
549    }
550}
551
552/// One `.zrec` line after the header.
553#[derive(Debug, Clone)]
554pub enum ZrecItem {
555    /// A publishable row, its pacing offset (absent on a hand-piped ndjson
556    /// row — replay treats that as "no delay"), and the capture-time
557    /// publisher HLC, informative only.
558    Sample {
559        row: IngestRow,
560        t_us: Option<u64>,
561        timestamp: Option<String>,
562        /// The publishing entity as the row spelled it (`zid:eid#sn`),
563        /// when `SourceInfo` rode the captured sample. Lifted for the
564        /// timeline (#216) so a replayed window classifies its stampers
565        /// exactly as the live one did; usually absent, RFC 09 §5.1 O7's
566        /// practical note.
567        source: Option<String>,
568    },
569    /// Samples the capture itself missed at this position (O6).
570    Dropped(u64),
571    /// A version-2 preamble row (RFC 13 §4.1; #218): state fetched at
572    /// trigger time, ahead of the first observed row. `timestamp` is the
573    /// HLC the fetched value carried — provenance, not pacing; the row's
574    /// `t` is 0 by construction and is not repeated here. A replayer
575    /// publishes these only when told to (§4.2); a pane replay seeds its
576    /// fold from them.
577    Preamble {
578        row: IngestRow,
579        timestamp: Option<String>,
580    },
581    /// The transition that fired a triggered capture, at the position it
582    /// was observed (version 2).
583    Trigger(Box<Transition>),
584}
585
586/// A `.zrec` reader over any buffered byte source: header up front, then
587/// one item per line — bounded memory, like the writer.
588pub struct ZrecReader<R: BufRead> {
589    header: ZrecHeader,
590    lines: std::io::Lines<R>,
591    /// 1-based number of the last line handed out (the header is line 1).
592    line: u64,
593}
594
595impl<R: BufRead> ZrecReader<R> {
596    /// Parse the header line. A file without one is not a `.zrec` — plain
597    /// ndjson pipes replay through `zenctl pub --from ndjson`, which needs
598    /// no base contract because the operator is the pacing.
599    pub fn new(source: R) -> Result<Self> {
600        let mut lines = source.lines();
601        let first = lines
602            .next()
603            .ok_or_else(|| Error::malformed(".zrec", "empty file — no header line"))?
604            .map_err(|e| Error::Io {
605                path: std::path::PathBuf::new(),
606                source: e,
607            })?;
608        let header: ZrecHeader = serde_json::from_str(&first)
609            .map_err(|e| Error::malformed_with(".zrec line 1", "is not a header", e))?;
610        if !ZREC_READS.contains(&header.zrec) {
611            return Err(Error::malformed(
612                ".zrec",
613                format!(
614                    "unsupported version {} (this reader speaks {ZREC_VERSION} and reads {})",
615                    header.zrec,
616                    ZREC_READS
617                        .iter()
618                        .filter(|v| **v != ZREC_VERSION)
619                        .map(u32::to_string)
620                        .collect::<Vec<_>>()
621                        .join(", ")
622                ),
623            ));
624        }
625        Ok(ZrecReader {
626            header,
627            lines,
628            line: 1,
629        })
630    }
631
632    pub fn header(&self) -> &ZrecHeader {
633        &self.header
634    }
635
636    /// The next item, or `Err` naming the line and the reason — a malformed
637    /// row is counted by the caller, never silently skipped
638    /// ([`crate::tape::ingest`]'s rule). `None` ends the file.
639    #[allow(clippy::should_implement_trait)] // fallible, line-numbered next
640    pub fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
641        loop {
642            let line = match self.lines.next()? {
643                Ok(l) => l,
644                Err(e) => {
645                    self.line += 1;
646                    return Some(Err(format!("line {}: read: {e}", self.line)));
647                }
648            };
649            self.line += 1;
650            if line.trim().is_empty() {
651                continue;
652            }
653            // A drop record is `{"dropped": n}` and a trigger record
654            // `{"trigger": {..}}` — no key, not rows (RFC 13 §4.1).
655            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
656                && v.get("key").is_none()
657            {
658                if let Some(n) = v.get("dropped").and_then(serde_json::Value::as_u64) {
659                    return Some(Ok(ZrecItem::Dropped(n)));
660                }
661                if let Some(t) = v.get("trigger") {
662                    return Some(
663                        serde_json::from_value::<Transition>(t.clone())
664                            .map(|t| ZrecItem::Trigger(Box::new(t)))
665                            .map_err(|e| format!("line {}: trigger record: {e}", self.line)),
666                    );
667                }
668            }
669            return Some(match parse_row(&line) {
670                Ok(row) => {
671                    let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
672                    let timestamp = v
673                        .get("timestamp")
674                        .and_then(serde_json::Value::as_str)
675                        .map(str::to_string);
676                    if v.get("preamble").and_then(serde_json::Value::as_bool) == Some(true) {
677                        Ok(ZrecItem::Preamble { row, timestamp })
678                    } else {
679                        Ok(ZrecItem::Sample {
680                            row,
681                            t_us: v.get("t").and_then(serde_json::Value::as_u64),
682                            timestamp,
683                            source: v
684                                .get("source")
685                                .and_then(serde_json::Value::as_str)
686                                .map(str::to_string),
687                        })
688                    }
689                }
690                Err(e) => Err(format!("line {}: {e}", self.line)),
691            });
692        }
693    }
694}
695
696/// A [`ZrecReader`] running on the blocking pool behind a bounded channel
697/// (#332) — the async end of a replay, and the mirror of [`ZrecSink`].
698///
699/// [`replay`] interleaves `sleep().await`s and network puts with its reads,
700/// so a blocking `BufRead` in that loop stalls the runtime on every line —
701/// on a cold page cache or a network filesystem, for as long as the read
702/// takes, mid-pacing. Here the file is read ahead on a blocking thread and
703/// the loop awaits parsed items; the queue is bounded, so a replay that
704/// pauses for pacing does not read the whole capture into memory.
705pub struct ZrecSource {
706    header: ZrecHeader,
707    rx: tokio::sync::mpsc::Receiver<std::result::Result<ZrecItem, String>>,
708}
709
710impl ZrecSource {
711    /// Parse the header, then read the rest ahead on the blocking pool.
712    ///
713    /// The header is awaited — a file that is not a `.zrec` is a refusal
714    /// before anything is scheduled, exactly as it was when the reader was
715    /// constructed inline.
716    pub async fn spawn<R: BufRead + Send + 'static>(source: R) -> Result<ZrecSource> {
717        let (tx, rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
718        let (ready, opened) = tokio::sync::oneshot::channel();
719        tokio::task::spawn_blocking(move || {
720            let mut reader = match ZrecReader::new(source) {
721                Ok(r) => r,
722                Err(e) => {
723                    let _ = ready.send(Err(e));
724                    return;
725                }
726            };
727            if ready.send(Ok(reader.header().clone())).is_err() {
728                return;
729            }
730            // A receiver that went away ends the read: a dropped replay must
731            // not leave a thread reading a file nobody will look at.
732            while let Some(item) = reader.next() {
733                if tx.blocking_send(item).is_err() {
734                    return;
735                }
736            }
737        });
738        match opened.await {
739            Ok(header) => Ok(ZrecSource {
740                header: header?,
741                rx,
742            }),
743            Err(_) => Err(Error::Internal(
744                "the .zrec reader stopped before it opened".into(),
745            )),
746        }
747    }
748
749    pub fn header(&self) -> &ZrecHeader {
750        &self.header
751    }
752
753    /// The next item, or `Err` naming the line and the reason — a malformed
754    /// row is counted by the caller, never silently skipped. `None` ends the
755    /// file.
756    pub async fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
757        self.rx.recv().await
758    }
759}
760
761/// Where a replay's writes go.
762pub enum ReplayTarget<'a> {
763    /// No session at all: list what would be published, publish nothing.
764    /// The zero-puts guarantee is structural — there is nothing to put on.
765    DryRun,
766    /// Real puts through declared publishers on this session.
767    Bus {
768        session: &'a Session,
769        /// Registry slices for the retire gate's `ttl_s` awareness; `None`
770        /// classifies from the grammar alone.
771        slices: Option<&'a SliceSet>,
772    },
773}
774
775/// What one replay is.
776///
777/// `default_qos` is a [`QosProfile`] and not a profile *name*: the closed
778/// enum is RFC 04 §3's vocabulary, and a caller that hands over a string has
779/// only deferred the moment it is checked — this used to surface a bad
780/// `--qos` as a per-row "malformed" event partway through a replay, rather
781/// than as a refusal before anything published. A name recorded *in the
782/// capture* is still a string, because a file can carry anything; that check
783/// stays where it belongs, per row.
784pub struct ReplaySpec<'a> {
785    pub target: ReplayTarget<'a>,
786    /// Pacing scale: 2.0 replays twice as fast as captured.
787    pub speed: f64,
788    /// Replay recorded deletes that fall off the state class — the same
789    /// operator price as `zenctl retire` (RFC 04 §1.2, v1.12).
790    pub i_know: bool,
791    /// The profile a row that recorded none is published under.
792    pub default_qos: QosProfile,
793    /// Publish the version-2 preamble rows too (`--seed-state`). Off, they
794    /// are skipped and counted, with the reason stated per row: re-stamped
795    /// state-at-capture-start republishes a snapshot over the live fleet
796    /// with no pacing between the rows (RFC 13 §4.2).
797    pub seed_state: bool,
798}
799
800/// Replay events, surfaced as they happen so a frontend can render them —
801/// the report at the end carries the counts.
802#[derive(Debug, Clone)]
803pub enum ReplayEvent<'a> {
804    /// Dry run: this row would publish.
805    WouldPut {
806        key: &'a str,
807        bytes: usize,
808        encoding: Option<&'a str>,
809    },
810    /// Dry run: this row would tombstone.
811    WouldRetire { key: &'a str },
812    /// A row that could not be parsed — counted, never skipped.
813    Malformed { reason: String },
814    /// A delete row the retire gate refused (RFC 04 §1.2 v1.12).
815    Refused { key: String, reason: String },
816    /// The capture itself missed this many samples here (O6): the replay
817    /// is a partial view of a partial view, and both halves are counted.
818    CaptureDropped(u64),
819    /// A version-2 preamble row this replay did **not** publish, and why
820    /// ([`PREAMBLE_SKIP_REASON`]) — the default without `seed_state`.
821    PreambleSkipped { key: &'a str, reason: &'static str },
822    /// The transition that fired the capture, at its position in the file.
823    /// Never published: a marker for the operator, not a row.
824    Trigger(&'a Transition),
825}
826
827/// The publishers one [`replay`] has declared, and the promise that every way
828/// out of it undeclares them (#327).
829///
830/// The declarations used to live in a bare `HashMap`, so each of the loop's
831/// `?`s returned with the whole set still declared on the bus — contradicting
832/// this module's own "undeclared at the end" and the crate idiom stated at
833/// [`crate::bus::query::RepeatingQuery::undeclare`]: teardown is explicit and
834/// awaited, never left to `Drop`.
835///
836/// [`close`](Self::close) is that teardown, modelled on
837/// [`crate::Monitor::shutdown`]: **every** publisher is undeclared even when
838/// one fails, and the failures are reported together — a replay half torn down
839/// is worse than one torn down noisily.
840///
841/// `Drop` is the cancellation fallback, and the one path that cannot be
842/// awaited: a dropped `replay` future hands the remaining publishers to a task
843/// that undeclares them properly, rather than leaving zenoh to reclaim them
844/// behind everyone's back. Nothing reaches it on the normal paths — `close`
845/// leaves the set empty.
846#[derive(Default)]
847struct Publications(HashMap<String, crate::bus::write::Publication>);
848
849impl std::ops::Deref for Publications {
850    type Target = HashMap<String, crate::bus::write::Publication>;
851    fn deref(&self) -> &Self::Target {
852        &self.0
853    }
854}
855
856impl std::ops::DerefMut for Publications {
857    fn deref_mut(&mut self) -> &mut Self::Target {
858        &mut self.0
859    }
860}
861
862impl Publications {
863    /// Undeclare every publisher, acknowledged, joining what failed.
864    async fn close(mut self) -> Result<()> {
865        crate::bus::teardown::drain_undeclare(self.0.drain().collect(), |p| {
866            crate::bus::write::Publication::undeclare(p)
867        })
868        .await
869    }
870}
871
872impl Drop for Publications {
873    fn drop(&mut self) {
874        if self.0.is_empty() {
875            return;
876        }
877        // No runtime means nothing can be awaited at all; zenoh's own
878        // drop-undeclare is then the only teardown there is.
879        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
880            return;
881        };
882        let declared: Vec<(String, crate::bus::write::Publication)> = self.0.drain().collect();
883        runtime.spawn(async move {
884            for (key, publication) in declared {
885                if let Err(e) = publication.undeclare().await {
886                    tracing::warn!(key = %key, "undeclare after a cancelled replay: {e}");
887                }
888            }
889        });
890    }
891}
892
893/// Replay a `.zrec` onto a bus — or list what doing so would publish.
894///
895/// Pacing follows each row's `t` divided by `speed` (must be positive);
896/// a dry run lists instantly, because a preview that takes the capture's
897/// duration is a preview nobody runs. Delete rows pass
898/// [`crate::bus::write::check_retire`] under the **header's** base — the keys
899/// were captured under it, and classifying them under anything else would
900/// re-derive what O3 says must not be re-derived; `i_know` is the operator
901/// saying the off-state cleanup is meant. Publishers are declared once per
902/// distinct key and undeclared on **every** way out — a failed row tears the
903/// set down before it reports, and a cancelled replay hands the remainder to
904/// a drop guard that undeclares them properly (#327).
905pub async fn replay(
906    reader: &mut ZrecSource,
907    spec: ReplaySpec<'_>,
908    mut on_event: impl FnMut(ReplayEvent<'_>),
909) -> Result<ReplayReport> {
910    let ReplaySpec {
911        target,
912        speed,
913        i_know,
914        default_qos,
915        seed_state,
916    } = spec;
917    if !(speed.is_finite() && speed > 0.0) {
918        return Err(Error::unaskable(
919            "--speed",
920            format!("must be a positive number (got {speed})"),
921        ));
922    }
923    let base = reader.header().base.clone();
924    let mut report = ReplayReport {
925        header: reader.header().clone(),
926        dry_run: matches!(target, ReplayTarget::DryRun),
927        speed,
928        published: 0,
929        tombstones: 0,
930        malformed: 0,
931        refused: 0,
932        capture_dropped: 0,
933        first_errors: Vec::new(),
934        preamble_skipped: 0,
935        preamble_seeded: 0,
936        triggers: 0,
937    };
938    let record_err = |report: &mut ReplayReport, reason: String, refused: bool| {
939        if refused {
940            report.refused += 1;
941        } else {
942            report.malformed += 1;
943        }
944        if report.first_errors.len() < 3 {
945            report.first_errors.push(reason);
946        }
947    };
948    let mut publications = Publications::default();
949    let mut prev_t: Option<u64> = None;
950    // The one fatal error a row can raise, held rather than thrown: the
951    // publishers are undeclared first, and only then does it go back to the
952    // caller (#327).
953    let mut fatal: Option<Error> = None;
954    while let Some(item) = reader.next().await {
955        let (row, t_us, seeding) = match item {
956            Ok(ZrecItem::Sample { row, t_us, .. }) => (row, t_us, false),
957            Ok(ZrecItem::Dropped(n)) => {
958                report.capture_dropped += n;
959                on_event(ReplayEvent::CaptureDropped(n));
960                continue;
961            }
962            Ok(ZrecItem::Trigger(t)) => {
963                report.triggers += 1;
964                on_event(ReplayEvent::Trigger(&t));
965                continue;
966            }
967            // A preamble row is state at capture start (RFC 13 §4.1). Without
968            // the opt-in it is skipped and *said* — a snapshot republished
969            // over a live fleet is the sharpest form of §4.2's hazard. With
970            // it, the row is a sample at `t: 0`: same retire gate, same
971            // publisher, no pacing (there is none to keep).
972            Ok(ZrecItem::Preamble { row, .. }) => {
973                if !seed_state {
974                    report.preamble_skipped += 1;
975                    on_event(ReplayEvent::PreambleSkipped {
976                        key: &row.key,
977                        reason: PREAMBLE_SKIP_REASON,
978                    });
979                    continue;
980                }
981                (row, Some(0), true)
982            }
983            Err(reason) => {
984                on_event(ReplayEvent::Malformed {
985                    reason: reason.clone(),
986                });
987                record_err(&mut report, reason, false);
988                continue;
989            }
990        };
991        let slices = match &target {
992            ReplayTarget::Bus { slices, .. } => *slices,
993            ReplayTarget::DryRun => None,
994        };
995        if row.delete
996            && let Err(e) = crate::bus::write::check_retire(&base, &row.key, slices, i_know)
997        {
998            let reason = e.to_string();
999            on_event(ReplayEvent::Refused {
1000                key: row.key.clone(),
1001                reason: reason.clone(),
1002            });
1003            record_err(&mut report, format!("{}: {reason}", row.key), true);
1004            continue;
1005        }
1006        // A seeded preamble row is counted as what it is, never as an
1007        // observed row (O6 applied to rows); the dry-run listing still names
1008        // it as the put it would be.
1009        let count_put = |report: &mut ReplayReport, delete: bool| match (seeding, delete) {
1010            (true, _) => report.preamble_seeded += 1,
1011            (false, true) => report.tombstones += 1,
1012            (false, false) => report.published += 1,
1013        };
1014        match &target {
1015            ReplayTarget::DryRun => {
1016                if row.delete {
1017                    on_event(ReplayEvent::WouldRetire { key: &row.key });
1018                } else {
1019                    on_event(ReplayEvent::WouldPut {
1020                        key: &row.key,
1021                        bytes: row.payload.len(),
1022                        encoding: row.encoding.as_deref(),
1023                    });
1024                }
1025                count_put(&mut report, row.delete);
1026            }
1027            ReplayTarget::Bus { session, .. } => {
1028                // Original pacing, scaled — the observer's arrival clock is
1029                // the only clock a capture has for "when" (RFC 09 §5.2).
1030                if let (Some(prev), Some(t)) = (prev_t, t_us)
1031                    && t > prev
1032                {
1033                    let delay = Duration::from_micros(t - prev).div_f64(speed);
1034                    tokio::time::sleep(delay).await;
1035                }
1036                if t_us.is_some() {
1037                    prev_t = t_us;
1038                }
1039                let publication = match publications.entry(row.key.clone()) {
1040                    std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
1041                    std::collections::hash_map::Entry::Vacant(e) => {
1042                        // A row that recorded a profile name is judged
1043                        // against the closed vocabulary — a name the capture
1044                        // carries can be anything. A row that recorded none
1045                        // falls to the spec's profile, which is already
1046                        // typed and so cannot fail here.
1047                        let qos = match &row.qos {
1048                            None => default_qos,
1049                            Some(name) => match zenkey::qos::QosProfile::from_name(name) {
1050                                Some(qos) => qos,
1051                                None => {
1052                                    let reason = format!("unknown QoS profile {name:?}");
1053                                    on_event(ReplayEvent::Malformed {
1054                                        reason: reason.clone(),
1055                                    });
1056                                    record_err(&mut report, reason, false);
1057                                    continue;
1058                                }
1059                            },
1060                        };
1061                        let publication = match crate::bus::write::declare_publication(
1062                            session,
1063                            &row.key,
1064                            qos,
1065                            row.encoding.as_deref(),
1066                        )
1067                        .await
1068                        {
1069                            Ok(p) => p,
1070                            Err(e) => {
1071                                fatal = Some(e);
1072                                break;
1073                            }
1074                        };
1075                        e.insert(publication)
1076                    }
1077                };
1078                let delete = row.delete;
1079                let sent = if delete {
1080                    publication.retire().await
1081                } else {
1082                    publication.send(row.payload, row.attachment).await
1083                };
1084                match sent {
1085                    Ok(()) => count_put(&mut report, delete),
1086                    Err(e) => {
1087                        fatal = Some(e);
1088                        break;
1089                    }
1090                }
1091            }
1092        }
1093    }
1094    // Teardown first, on every path out — the row error is the one reported,
1095    // but a failure to undeclare is never skipped for it.
1096    let closed = publications.close().await;
1097    if let Some(e) = fatal {
1098        return Err(e);
1099    }
1100    closed?;
1101    Ok(report)
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106    use super::*;
1107
1108    /// The provenance stamp is a real RFC 3339 instant, leap-era safe.
1109    #[test]
1110    fn the_wall_clock_formats_correctly() {
1111        assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
1112        assert_eq!(rfc3339_from_unix(951_782_400), "2000-02-29T00:00:00Z");
1113        assert_eq!(rfc3339_from_unix(1_786_492_800), "2026-08-12T00:00:00Z");
1114        assert!(!rfc3339_now().is_empty());
1115    }
1116
1117    fn header() -> ZrecHeader {
1118        ZrecHeader {
1119            zrec: ZREC_VERSION,
1120            selectors: vec!["v1/**".into()],
1121            base: String::new(),
1122            captured_at: "2026-08-12T00:00:00Z".into(),
1123            preamble: None,
1124            pre_roll: None,
1125        }
1126    }
1127
1128    /// A replay source over an in-memory capture. `Cursor<Vec<u8>>` because
1129    /// the read happens on the blocking pool and so must own its bytes.
1130    async fn source_of(body: &str) -> ZrecSource {
1131        ZrecSource::spawn(std::io::Cursor::new(body.as_bytes().to_vec()))
1132            .await
1133            .expect("a .zrec header")
1134    }
1135
1136    /// The header round-trips, and a versioned reader refuses what it
1137    /// cannot speak rather than guessing.
1138    #[test]
1139    fn the_header_is_a_contract() {
1140        let mut sink = Vec::new();
1141        let writer = ZrecWriter::new(&mut sink, &header()).unwrap();
1142        let _ = writer.finish().unwrap();
1143        let reader = ZrecReader::new(sink.as_slice()).unwrap();
1144        assert_eq!(reader.header(), &header());
1145
1146        // The version after this one is refused, never guessed at (RFC 13
1147        // §4.1's unknown-version rule) — and the refusal says what this
1148        // reader does speak.
1149        let future = r#"{"zrec":3,"selectors":[],"base":"","captured_at":"x"}"#;
1150        let err = ZrecReader::new(future.as_bytes())
1151            .err()
1152            .unwrap()
1153            .to_string();
1154        assert!(err.contains("version 3"), "{err}");
1155        assert!(err.contains("speaks 2 and reads 1"), "{err}");
1156
1157        let not_zrec = r#"{"key":"v1/x","value":1}"#;
1158        let err = ZrecReader::new(not_zrec.as_bytes())
1159            .err()
1160            .unwrap()
1161            .to_string();
1162        assert!(err.contains("header"), "{err}");
1163    }
1164
1165    /// A version-1 file — no `preamble`, no `pre_roll`, plain rows and drop
1166    /// records — reads under the version-2 reader exactly as it did (RFC 13
1167    /// §4.1: a version-2 reader MUST read version 1), and the header comes
1168    /// back with the two blocks absent rather than defaulted.
1169    #[test]
1170    fn a_version_one_body_reads_under_the_version_two_reader() {
1171        let body = concat!(
1172            r#"{"zrec":1,"selectors":["v1/**"],"base":"","captured_at":"2026-08-12T00:00:00Z"}"#,
1173            "\n",
1174            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
1175            "\n",
1176            r#"{"dropped":2}"#,
1177            "\n",
1178            r#"{"key":"v1/h/state/p/a","t":1000,"delete":true}"#,
1179            "\n",
1180        );
1181        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
1182        assert_eq!(reader.header().zrec, 1);
1183        assert_eq!(reader.header().preamble, None);
1184        assert_eq!(reader.header().pre_roll, None);
1185        assert!(matches!(
1186            reader.next(),
1187            Some(Ok(ZrecItem::Sample { t_us: Some(0), .. }))
1188        ));
1189        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Dropped(2)))));
1190        assert!(matches!(
1191            reader.next(),
1192            Some(Ok(ZrecItem::Sample {
1193                t_us: Some(1000),
1194                ..
1195            }))
1196        ));
1197        assert!(reader.next().is_none());
1198    }
1199
1200    /// The version-2 lines read back as what they are: a preamble row is
1201    /// `Preamble` (at `t: 0`, keeping its HLC as provenance), a trigger
1202    /// record is the same `Transition` that was written — and a version-2
1203    /// header round-trips both blocks.
1204    #[test]
1205    fn version_two_lines_read_back_by_kind() {
1206        use crate::report::{CondState, PreRollInfo, PreambleInfo, PreambleSemantics};
1207        let epoch = Instant::now();
1208        let header = ZrecHeader {
1209            preamble: Some(PreambleInfo {
1210                count: 1,
1211                collected_over_s: 0.25,
1212                selectors: vec!["v1/*/state/**".into()],
1213                semantics: PreambleSemantics::AbsentFromWindow,
1214                incomplete: 0,
1215                failed: vec!["v1/*/telemetry/**".into()],
1216            }),
1217            pre_roll: Some(PreRollInfo {
1218                asked_s: 30.0,
1219                covered_s: 12.5,
1220                watched: vec!["v1/**".into()],
1221                evicted: 0,
1222                expired: 40,
1223            }),
1224            ..header()
1225        };
1226        let stamp = zenoh::time::Timestamp::new(
1227            zenoh::time::NTP64::from(Duration::from_secs(1_700_000_000)),
1228            zenoh::time::TimestampId::try_from([7u8; 16]).unwrap(),
1229        );
1230        let fetched = crate::bus::monitor::SampleView {
1231            key: "v1/h-0123456789ab/state/p/config".into(),
1232            payload: zenoh::bytes::ZBytes::from(vec![9u8]),
1233            encoding: String::new(),
1234            kind: SampleKind::Put,
1235            timestamp: Some(stamp),
1236            stamped_by: None,
1237            attachment: None,
1238            priority: zenoh::qos::Priority::DEFAULT,
1239            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
1240            reliability: zenoh::qos::Reliability::DEFAULT,
1241            express: false,
1242            source: None,
1243            // Received "after" the epoch: a preamble row still writes t: 0.
1244            received: epoch + Duration::from_secs(5),
1245        };
1246        let fired = Transition {
1247            rule: "silent-for v1/h-0123456789ab/state/p/health 0.7".into(),
1248            from: Some(CondState::Ok),
1249            to: CondState::Firing,
1250            at: "2026-09-06T00:00:00Z".into(),
1251            evidence: "no sample for 0.7s, on a drop-free observer".into(),
1252        };
1253
1254        let mut sink = Vec::new();
1255        let mut w = ZrecWriter::new_at(&mut sink, &header, epoch).unwrap();
1256        w.write_preamble(&fetched).unwrap();
1257        w.write_trigger(&fired).unwrap();
1258        assert_eq!(
1259            w.counts(),
1260            SinkCounts {
1261                samples: 0,
1262                dropped: 0,
1263                preamble: 1,
1264                triggers: 1
1265            },
1266            "the kinds are counted apart"
1267        );
1268        let _ = w.finish().unwrap();
1269        let text = String::from_utf8(sink.clone()).unwrap();
1270        assert!(text.contains(r#""preamble":true"#), "{text}");
1271        assert!(text.contains(r#""t":0"#), "{text}");
1272        assert!(text.contains(r#"{"trigger":{"#), "{text}");
1273
1274        let mut reader = ZrecReader::new(sink.as_slice()).unwrap();
1275        assert_eq!(reader.header(), &header);
1276        match reader.next() {
1277            Some(Ok(ZrecItem::Preamble { row, timestamp })) => {
1278                assert_eq!(row.key, fetched.key);
1279                assert_eq!(row.payload, vec![9u8]);
1280                assert_eq!(timestamp.as_deref(), Some(stamp.to_string().as_str()));
1281            }
1282            other => panic!("expected a preamble row, got {other:?}"),
1283        }
1284        match reader.next() {
1285            Some(Ok(ZrecItem::Trigger(t))) => assert_eq!(*t, fired),
1286            other => panic!("expected the trigger record, got {other:?}"),
1287        }
1288        assert!(reader.next().is_none());
1289    }
1290
1291    /// A version-2 capture as the CLI replays it: without `--seed-state`
1292    /// the preamble row is skipped and *said* (RFC 13 §4.2's hazard), the
1293    /// trigger is a marker and never a put, and the observed row still
1294    /// counts; with it, the preamble row is a would-be put counted as
1295    /// seeded, apart from the observed rows.
1296    #[tokio::test]
1297    async fn a_dry_run_skips_the_preamble_by_default_and_seeds_it_on_request() {
1298        let body = format!(
1299            "{}\n{}\n{}\n{}\n",
1300            serde_json::to_string(&header()).unwrap(),
1301            r#"{"key":"v1/h-0123456789ab/state/p/config","t":0,"preamble":true,"bytes":"CQ=="}"#,
1302            r#"{"key":"v1/h-0123456789ab/state/p/health","t":250000,"bytes":"eyJvayI6dHJ1ZX0="}"#,
1303            r#"{"trigger":{"rule":"silent-for k 0.7","from":"ok","to":"firing","at":"x","evidence":"e"}}"#,
1304        );
1305        let spec = |seed_state| ReplaySpec {
1306            target: ReplayTarget::DryRun,
1307            speed: 1.0,
1308            i_know: false,
1309            default_qos: QosProfile::Refreshed,
1310            seed_state,
1311        };
1312
1313        let mut reader = source_of(&body).await;
1314        let mut skipped = Vec::new();
1315        let mut triggers = 0;
1316        let report = replay(&mut reader, spec(false), |ev| match ev {
1317            ReplayEvent::PreambleSkipped { key, reason } => {
1318                skipped.push((key.to_string(), reason));
1319            }
1320            ReplayEvent::Trigger(t) => {
1321                assert_eq!(t.to, crate::report::CondState::Firing);
1322                triggers += 1;
1323            }
1324            _ => {}
1325        })
1326        .await
1327        .unwrap();
1328        assert_eq!(report.preamble_skipped, 1);
1329        assert_eq!(report.preamble_seeded, 0);
1330        assert_eq!(report.published, 1);
1331        assert_eq!(report.triggers, 1);
1332        assert_eq!(triggers, 1);
1333        assert_eq!(skipped.len(), 1);
1334        assert_eq!(skipped[0].0, "v1/h-0123456789ab/state/p/config");
1335        assert!(skipped[0].1.contains("--seed-state"), "{}", skipped[0].1);
1336        assert!(skipped[0].1.contains("RFC 13 §4.2"), "{}", skipped[0].1);
1337
1338        let mut reader = source_of(&body).await;
1339        let mut would_put = 0;
1340        let report = replay(&mut reader, spec(true), |ev| {
1341            if matches!(ev, ReplayEvent::WouldPut { .. }) {
1342                would_put += 1;
1343            }
1344        })
1345        .await
1346        .unwrap();
1347        assert_eq!(report.preamble_seeded, 1);
1348        assert_eq!(report.preamble_skipped, 0);
1349        assert_eq!(report.published, 1, "the observed row, not the seed");
1350        assert_eq!(would_put, 2, "both rows are listed as puts");
1351    }
1352
1353    /// A retained window written through `new_at` keeps its real pacing
1354    /// (#217): rows received *before* the writer existed carry their true
1355    /// offsets from the injected epoch instead of saturating to `t: 0`.
1356    #[test]
1357    fn an_injected_epoch_preserves_a_window_written_after_the_fact() {
1358        let epoch = Instant::now();
1359        let view = |t_ms: u64| crate::bus::monitor::SampleView {
1360            key: "v1/h-0123456789ab/state/p/a".into(),
1361            payload: zenoh::bytes::ZBytes::from(vec![1u8]),
1362            encoding: String::new(),
1363            kind: SampleKind::Put,
1364            timestamp: None,
1365            stamped_by: None,
1366            attachment: None,
1367            priority: zenoh::qos::Priority::DEFAULT,
1368            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
1369            reliability: zenoh::qos::Reliability::DEFAULT,
1370            express: false,
1371            source: None,
1372            received: epoch + Duration::from_millis(t_ms),
1373        };
1374        let mut sink = Vec::new();
1375        let mut w = ZrecWriter::new_at(&mut sink, &header(), epoch).unwrap();
1376        w.write_sample(&view(0)).unwrap();
1377        w.write_sample(&view(1500)).unwrap();
1378        let _ = w.finish().unwrap();
1379
1380        let mut reader = ZrecReader::new(sink.as_slice()).unwrap();
1381        let t_of = |item| match item {
1382            Some(Ok(ZrecItem::Sample { t_us, .. })) => t_us,
1383            other => panic!("expected a sample, got {other:?}"),
1384        };
1385        assert_eq!(t_of(reader.next()), Some(0));
1386        assert_eq!(
1387            t_of(reader.next()),
1388            Some(1_500_000),
1389            "the offset the ring preserved, not a saturated zero"
1390        );
1391    }
1392
1393    /// Drop records read back as drops, at their position (O6): the gap is
1394    /// part of the record, not a footnote.
1395    #[test]
1396    fn drops_are_interleaved_facts() {
1397        let body = format!(
1398            "{}\n{}\n{}\n{}\n",
1399            serde_json::to_string(&header()).unwrap(),
1400            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
1401            r#"{"dropped":7}"#,
1402            r#"{"key":"v1/h/state/p/a","t":1000,"bytes":"Ag=="}"#,
1403        );
1404        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
1405        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
1406        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Dropped(7)))));
1407        assert!(matches!(
1408            reader.next(),
1409            Some(Ok(ZrecItem::Sample {
1410                t_us: Some(1000),
1411                ..
1412            }))
1413        ));
1414        assert!(reader.next().is_none());
1415    }
1416
1417    /// A malformed line is an error naming its line number — counted by
1418    /// the caller, never a skip.
1419    #[test]
1420    fn malformed_lines_are_named_not_skipped() {
1421        let body = format!(
1422            "{}\nnot json\n{}\n",
1423            serde_json::to_string(&header()).unwrap(),
1424            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
1425        );
1426        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
1427        let err = match reader.next() {
1428            Some(Err(e)) => e,
1429            other => panic!("expected a named error, got {other:?}"),
1430        };
1431        assert!(err.starts_with("line 2:"), "{err}");
1432        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
1433    }
1434
1435    /// A dry run performs zero puts by construction — there is no session —
1436    /// and still counts and classifies every row.
1437    #[tokio::test]
1438    async fn a_dry_run_lists_and_publishes_nothing() {
1439        let body = format!(
1440            "{}\n{}\n{}\n{}\n",
1441            serde_json::to_string(&header()).unwrap(),
1442            r#"{"key":"v1/h-0123456789ab/state/p/health","t":0,"bytes":"eyJvayI6dHJ1ZX0=","encoding":"application/json"}"#,
1443            r#"{"dropped":3}"#,
1444            r#"{"key":"v1/h-0123456789ab/state/p/health","t":500000,"delete":true}"#,
1445        );
1446        let mut reader = source_of(&body).await;
1447        let mut would = Vec::new();
1448        let report = replay(
1449            &mut reader,
1450            ReplaySpec {
1451                target: ReplayTarget::DryRun,
1452                speed: 1.0,
1453                i_know: false,
1454                default_qos: QosProfile::Refreshed,
1455                seed_state: false,
1456            },
1457            |ev| {
1458                would.push(format!("{ev:?}"));
1459            },
1460        )
1461        .await
1462        .unwrap();
1463        assert!(report.dry_run);
1464        assert_eq!(report.published, 1);
1465        assert_eq!(report.tombstones, 1); // state-shaped: licensed without force
1466        assert_eq!(report.capture_dropped, 3);
1467        assert_eq!(report.malformed, 0);
1468        assert_eq!(would.len(), 3, "{would:?}");
1469    }
1470
1471    /// A recorded delete off the state class keeps its price on replay
1472    /// (RFC 04 §1.2 v1.12): refused without `i_know`, counted.
1473    #[tokio::test]
1474    async fn replayed_tombstones_pass_the_retire_gate() {
1475        let body = format!(
1476            "{}\n{}\n",
1477            serde_json::to_string(&header()).unwrap(),
1478            r#"{"key":"v1/h-0123456789ab/telemetry/p/temp","t":0,"delete":true}"#,
1479        );
1480        let mut reader = source_of(&body).await;
1481        let report = replay(
1482            &mut reader,
1483            ReplaySpec {
1484                target: ReplayTarget::DryRun,
1485                speed: 1.0,
1486                i_know: false,
1487                default_qos: QosProfile::Refreshed,
1488                seed_state: false,
1489            },
1490            |_| {},
1491        )
1492        .await
1493        .unwrap();
1494        assert_eq!(report.refused, 1);
1495        assert_eq!(report.tombstones, 0);
1496        assert!(
1497            report.first_errors[0].contains("telemetry"),
1498            "{:?}",
1499            report.first_errors
1500        );
1501    }
1502
1503    /// Speed is a positive finite scale, stated rather than clamped.
1504    #[tokio::test]
1505    async fn speed_must_be_positive() {
1506        let body = serde_json::to_string(&header()).unwrap() + "\n";
1507        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1508            let mut reader = source_of(&body).await;
1509            let err = replay(
1510                &mut reader,
1511                ReplaySpec {
1512                    target: ReplayTarget::DryRun,
1513                    speed: bad,
1514                    i_know: false,
1515                    default_qos: QosProfile::Refreshed,
1516                    seed_state: false,
1517                },
1518                |_| {},
1519            )
1520            .await
1521            .unwrap_err()
1522            .to_string();
1523            assert!(err.contains("speed"), "{err}");
1524        }
1525    }
1526
1527    /// A row the bus refuses is still reported — the teardown that now runs
1528    /// first does not swallow it (#327). The undeclared publishers left behind
1529    /// by the old `?` were invisible from the outside, which is why the drain
1530    /// itself is pinned in `bus::teardown`; what is observable here is that
1531    /// the failing row's own error is what comes back.
1532    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1533    async fn a_row_the_bus_refuses_tears_down_and_still_reports_itself() {
1534        let session = crate::bus::session::open(&[], &[], false)
1535            .await
1536            .expect("a standalone peer");
1537        let good = SampleRow {
1538            key: "v1/h-aaaaaaaaaaaa/state/demo/health".into(),
1539            ..SampleRow::default()
1540        }
1541        .with_payload_bytes(b"{}");
1542        // An empty chunk is not a key expression, so `declare_publication`
1543        // refuses it — the fatal row this replay dies on, after one good
1544        // publisher is already declared.
1545        let bad = SampleRow {
1546            key: "v1//nowhere".into(),
1547            ..SampleRow::default()
1548        }
1549        .with_payload_bytes(b"{}");
1550        let body = format!(
1551            "{}\n{}\n{}\n",
1552            serde_json::to_string(&header()).unwrap(),
1553            good.to_line(),
1554            bad.to_line(),
1555        );
1556
1557        let mut reader = source_of(&body).await;
1558        let err = replay(
1559            &mut reader,
1560            ReplaySpec {
1561                target: ReplayTarget::Bus {
1562                    session: &session,
1563                    slices: None,
1564                },
1565                speed: 1000.0,
1566                i_know: false,
1567                default_qos: QosProfile::Transition,
1568                seed_state: false,
1569            },
1570            |_| {},
1571        )
1572        .await
1573        .expect_err("the bus refused the second row")
1574        .to_string();
1575        assert!(err.contains("nowhere"), "{err}");
1576
1577        session.close().await.expect("close the session");
1578    }
1579}