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, ZrecHeader};
54use crate::tape::ingest::{IngestRow, parse_row};
55
56/// The current `.zrec` format version, written into every header.
57pub const ZREC_VERSION: u32 = 1;
58
59/// RFC 3339 UTC "now", seconds precision — the header's provenance stamp.
60/// A hand-rolled civil-date conversion (Hinnant's days algorithm) beats a
61/// clock crate this crate needs for nothing else.
62pub fn rfc3339_now() -> String {
63    rfc3339_from_unix(
64        std::time::SystemTime::now()
65            .duration_since(std::time::UNIX_EPOCH)
66            .map(|d| d.as_secs())
67            .unwrap_or(0),
68    )
69}
70
71fn rfc3339_from_unix(secs: u64) -> String {
72    let (days, rem) = (secs / 86_400, secs % 86_400);
73    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
74    // Civil from days since 1970-01-01 (era-based, valid far past 2100).
75    let z = days as i64 + 719_468;
76    let era = z.div_euclid(146_097);
77    let doe = z.rem_euclid(146_097);
78    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
79    let y = yoe + era * 400;
80    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
81    let mp = (5 * doy + 2) / 153;
82    let d = doy - (153 * mp + 2) / 5 + 1;
83    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
84    let y = if mo <= 2 { y + 1 } else { y };
85    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
86}
87
88/// A `.zrec` writer over any byte sink: header first, then rows as they
89/// arrive, drop records in place. Wrap the sink in a `BufWriter` — the
90/// writer emits line-at-a-time and never buffers samples itself, so a
91/// capture streams in bounded memory.
92pub struct ZrecWriter<W: Write> {
93    out: W,
94    /// The capture epoch on this observer's monotonic clock: every row's
95    /// `t` is an offset from here. Stamped at construction, so a sample
96    /// received before the writer existed saturates to 0 rather than
97    /// underflowing.
98    epoch: Instant,
99    samples: u64,
100    dropped: u64,
101}
102
103impl<W: Write> ZrecWriter<W> {
104    /// Write the header line and hand back a row writer.
105    pub fn new(out: W, header: &ZrecHeader) -> Result<Self> {
106        ZrecWriter::new_at(out, header, Instant::now())
107    }
108
109    /// [`ZrecWriter::new`] with the capture epoch injected (#217).
110    ///
111    /// A live capture's epoch is "now" — nothing precedes the writer. A
112    /// **retained window** is the opposite: every sample was received before
113    /// the writer existed, and under `new` they would all saturate to `t: 0`,
114    /// erasing the pacing the ring preserved. Passing the window's own start
115    /// (its oldest sample's arrival) keeps each row's `t` the offset it
116    /// really had, so the file is indistinguishable from one recorded
117    /// deliberately at that moment.
118    pub fn new_at(mut out: W, header: &ZrecHeader, epoch: Instant) -> Result<Self> {
119        serde_json::to_writer(&mut out, header).map_err(|e| Error::Io {
120            path: std::path::PathBuf::new(),
121            source: e.into(),
122        })?;
123        out.write_all(b"\n").map_err(|e| Error::Io {
124            path: std::path::PathBuf::new(),
125            source: e,
126        })?;
127        Ok(ZrecWriter {
128            out,
129            epoch,
130            samples: 0,
131            dropped: 0,
132        })
133    }
134
135    /// Write one observed sample as a row.
136    ///
137    /// The payload rides lossless (`"bytes"`), the pacing offset is the
138    /// observer's arrival clock (`"t"`, µs since the capture epoch), and
139    /// the publisher's HLC — when one rode the sample — is carried
140    /// informatively (`"timestamp"`): replay re-stamps (RFC 09 §5.2). QoS
141    /// is stored as a profile *name* only when the wire's actual axes match
142    /// one (RFC 04 §3); axes matching no profile are not approximated —
143    /// a rule [`SampleRow::with_wire`] now enforces for every writer of the
144    /// dialect rather than for this one alone (#235).
145    ///
146    /// A capture carries **no** `origin`/`subject`: those are the observer's
147    /// reading of the key under a base it chose, and a file that outlives
148    /// the session must not freeze one deployment's interpretation into
149    /// somebody else's replay (RFC 09 §5.2 — keys are recorded whole and
150    /// never re-derived from the header's base).
151    pub fn write_sample(&mut self, view: &SampleView) -> Result<()> {
152        let t_us = u64::try_from(
153            view.received
154                .saturating_duration_since(self.epoch)
155                .as_micros(),
156        )
157        .unwrap_or(u64::MAX);
158        let mut row = SampleRow {
159            key: view.key.clone(),
160            t: Some(t_us),
161            ..SampleRow::default()
162        }
163        .with_wire(view);
164        // A tombstone has no payload to store: `delete` is the whole fact
165        // (RFC 04 §1.2), and an empty `bytes` would read as an empty put.
166        if view.kind != SampleKind::Delete {
167            row = row.with_payload_bytes(&view.payload.to_bytes());
168        }
169        if let Some(a) = &view.attachment {
170            row.attachment_b64 = Some(crate::tape::ingest::b64(&a.to_bytes()));
171        }
172        self.out
173            .write_all(row.to_line().as_bytes())
174            .map_err(|e| Error::Io {
175                path: std::path::PathBuf::new(),
176                source: e,
177            })?;
178        self.out.write_all(b"\n").map_err(|e| Error::Io {
179            path: std::path::PathBuf::new(),
180            source: e,
181        })?;
182        self.samples += 1;
183        Ok(())
184    }
185
186    /// Write a drop record where the gap happened (O6 on a file).
187    pub fn write_dropped(&mut self, n: u64) -> Result<()> {
188        serde_json::to_writer(&mut self.out, &serde_json::json!({ "dropped": n })).map_err(
189            |e| Error::Io {
190                path: std::path::PathBuf::new(),
191                source: e.into(),
192            },
193        )?;
194        self.out.write_all(b"\n").map_err(|e| Error::Io {
195            path: std::path::PathBuf::new(),
196            source: e,
197        })?;
198        self.dropped += n;
199        Ok(())
200    }
201
202    /// Samples and drops written so far — the progress line's numbers.
203    pub fn counts(&self) -> (u64, u64) {
204        (self.samples, self.dropped)
205    }
206
207    /// Flush and hand the sink back.
208    pub fn finish(mut self) -> Result<W> {
209        self.out.flush().map_err(|e| Error::Io {
210            path: std::path::PathBuf::new(),
211            source: e,
212        })?;
213        Ok(self.out)
214    }
215}
216
217/// How many lines a [`ZrecSink`] queues ahead of its writer.
218///
219/// Four times the monitor's default broadcast capacity (1024), on purpose:
220/// a burst the *bus* side can hold is a burst the disk side can hold too, so
221/// a momentary write stall spends the queue instead of manufacturing drops
222/// the bus never had. Past that the queue backpressures the drain — which is
223/// where a genuinely-too-slow disk belongs, surfacing as `Dropped(n)` like
224/// any other observer that could not keep up (RFC 13 §3 O6). Bounded, not
225/// unbounded, because a capture promises to stream in bounded memory.
226const SINK_QUEUE: usize = 4096;
227
228/// One line on its way to the disk. Serialization happens on the writer's
229/// thread, so what crosses the channel is the sample itself — a pointer
230/// move, not a copy.
231enum ZrecLine {
232    Sample(Arc<SampleView>),
233    Dropped(u64),
234}
235
236/// What the sink's async half can see of a writer that lives on the
237/// blocking pool.
238#[derive(Debug, Default)]
239struct SinkState {
240    samples: AtomicU64,
241    dropped: AtomicU64,
242    /// The writer's error, kept where the async half can name it: a `send`
243    /// that fails says only "the writer is gone", and the reason is what the
244    /// operator needs.
245    failure: std::sync::Mutex<Option<String>>,
246}
247
248/// A [`ZrecWriter`] running on the blocking pool behind a bounded channel
249/// (#332) — the async end of a capture.
250///
251/// Every byte of `.zrec` I/O, and every base64 and JSON encode that precedes
252/// it, happens on a blocking thread. The runtime side of a capture does
253/// nothing but move `Arc`s into a queue, so the drain stays available to the
254/// monitor's bounded broadcast and the drop records in the file mean what
255/// they say: samples the *bus* outran the observer with, not samples the
256/// observer's own writer stalled it out of.
257pub struct ZrecSink {
258    tx: tokio::sync::mpsc::Sender<ZrecLine>,
259    state: Arc<SinkState>,
260    writer: tokio::task::JoinHandle<Result<(u64, u64)>>,
261}
262
263impl ZrecSink {
264    /// Write `header` and hand back the sink. Awaits the header's own write,
265    /// so a sink that comes back is a file with a valid first line on it —
266    /// the failure an operator must hear about before a capture starts
267    /// "running".
268    ///
269    /// The capture epoch is stamped **here** rather than on the blocking
270    /// thread: every row's `t` is an offset from it, and it must not shift by
271    /// however long the pool took to pick the task up.
272    pub async fn spawn<W: Write + Send + 'static>(out: W, header: &ZrecHeader) -> Result<ZrecSink> {
273        ZrecSink::spawn_at(out, header, Instant::now()).await
274    }
275
276    /// [`ZrecSink::spawn`] with the capture epoch injected — the streaming
277    /// twin of [`ZrecWriter::new_at`], and the seam a test uses to write
278    /// deterministic offsets.
279    pub async fn spawn_at<W: Write + Send + 'static>(
280        out: W,
281        header: &ZrecHeader,
282        epoch: Instant,
283    ) -> Result<ZrecSink> {
284        let (tx, mut rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
285        let (ready, opened) = tokio::sync::oneshot::channel();
286        let state = Arc::new(SinkState::default());
287        let header = header.clone();
288        let task_state = Arc::clone(&state);
289        let writer = tokio::task::spawn_blocking(move || {
290            let mut writer = match ZrecWriter::new_at(out, &header, epoch) {
291                Ok(w) => {
292                    let _ = ready.send(None);
293                    w
294                }
295                Err(e) => {
296                    let _ = ready.send(Some(crate::one_line(&e)));
297                    return Err(e);
298                }
299            };
300            while let Some(line) = rx.blocking_recv() {
301                let wrote = match line {
302                    ZrecLine::Sample(view) => writer.write_sample(&view),
303                    ZrecLine::Dropped(n) => writer.write_dropped(n),
304                };
305                if let Err(e) = wrote {
306                    *task_state.failure.lock().expect("sink failure lock") =
307                        Some(crate::one_line(&e));
308                    return Err(e);
309                }
310            }
311            let counts = writer.counts();
312            writer.finish().map(|_| counts)
313        });
314        match opened.await {
315            Ok(None) => Ok(ZrecSink { tx, state, writer }),
316            // The writer names the file in its own message; this is the
317            // open failing, which is I/O against a path the caller gave.
318            Ok(Some(reason)) => Err(Error::Io {
319                path: std::path::PathBuf::new(),
320                source: std::io::Error::other(reason),
321            }),
322            Err(_) => Err(Error::Internal(
323                "the .zrec writer stopped before it opened".into(),
324            )),
325        }
326    }
327
328    /// Queue one sample. Awaits only the queue's capacity — never the disk.
329    pub async fn write_sample(&self, view: Arc<SampleView>) -> Result<()> {
330        self.send(ZrecLine::Sample(view)).await?;
331        self.state.samples.fetch_add(1, Ordering::Relaxed);
332        Ok(())
333    }
334
335    /// Queue a drop record at the position the gap happened (O6 on a file).
336    pub async fn write_dropped(&self, n: u64) -> Result<()> {
337        self.send(ZrecLine::Dropped(n)).await?;
338        self.state.dropped.fetch_add(n, Ordering::Relaxed);
339        Ok(())
340    }
341
342    async fn send(&self, line: ZrecLine) -> Result<()> {
343        if self.tx.send(line).await.is_ok() {
344            return Ok(());
345        }
346        // The writer is gone, which only happens because it failed: report
347        // *its* error rather than the channel's shadow of it.
348        let failure = self
349            .state
350            .failure
351            .lock()
352            .expect("sink failure lock")
353            .clone();
354        Err(Error::Internal(
355            failure.unwrap_or_else(|| "the .zrec writer stopped".to_string()),
356        ))
357    }
358
359    /// Samples and drops **accepted** so far — the progress line's numbers.
360    ///
361    /// Accepted, not yet written: the queue is what stands between the two,
362    /// and [`finish`](Self::finish) drains it, so the final counts are the
363    /// file's. A progress line that waited for the disk would be reporting
364    /// the disk, not the capture.
365    pub fn counts(&self) -> (u64, u64) {
366        (
367            self.state.samples.load(Ordering::Relaxed),
368            self.state.dropped.load(Ordering::Relaxed),
369        )
370    }
371
372    /// Close the queue, wait for the writer to drain it, flush, and report
373    /// what reached the file: (samples, dropped).
374    ///
375    /// This is where a write error surfaces if the capture did not already
376    /// trip over it. The counts come from the writer rather than the queue,
377    /// so a report built on them is a report about the file.
378    pub async fn finish(self) -> Result<(u64, u64)> {
379        let ZrecSink { tx, state, writer } = self;
380        drop(tx);
381        drop(state);
382        writer
383            .await
384            .map_err(|e| Error::Internal(format!("the .zrec writer panicked: {e}")))?
385    }
386}
387
388/// Bounds on a capture. Unset bounds mean "until the caller stops the
389/// loop" (Ctrl-C is the caller's `select!`, not this module's business —
390/// [`record()`](record) is cancel-safe between lines).
391#[derive(Debug, Clone, Copy, Default)]
392pub struct RecordBounds {
393    /// Stop after this many samples (drop records do not count).
394    pub max_samples: Option<u64>,
395    /// Stop after this long, measured from entering [`record()`](record).
396    pub max_duration: Option<Duration>,
397}
398
399/// Drain a monitor's event stream into a `.zrec` [`ZrecSink`] until a bound
400/// is hit or the stream ends. Samples and interleaved drops are recorded;
401/// liveliness and tick events are not part of the format. `on_progress` is
402/// called after every queued line with (samples, dropped) — throttle in
403/// the callback, not here.
404///
405/// **This loop never touches the disk** (#332): it moves `Arc`s into the
406/// sink's bounded queue and goes straight back to the stream, so the
407/// monitor's broadcast stays attended and a `{"dropped": n}` in the file
408/// means the bus outran the observer — not that the observer's own writer
409/// stalled its drain. A disk that is slower than the bus *on average* still
410/// backpressures through the queue and still drops, honestly.
411///
412/// Cancel-safe: dropping the future mid-`recv` loses nothing already
413/// queued (each line lands whole, in order); call
414/// [`ZrecSink::finish`] afterwards to drain and flush.
415pub async fn record(
416    events: &mut EventStream,
417    sink: &ZrecSink,
418    bounds: RecordBounds,
419    mut on_progress: impl FnMut(u64, u64),
420) -> Result<()> {
421    let deadline = bounds.max_duration.map(|d| Instant::now() + d);
422
423    loop {
424        let (samples, _) = sink.counts();
425        if bounds.max_samples.is_some_and(|max| samples >= max) {
426            return Ok(());
427        }
428        let item = match deadline {
429            Some(d) => {
430                let left = d.saturating_duration_since(Instant::now());
431                if left.is_zero() {
432                    return Ok(());
433                }
434                match tokio::time::timeout(left, events.recv()).await {
435                    Ok(item) => item,
436                    Err(_) => return Ok(()),
437                }
438            }
439            None => events.recv().await,
440        };
441        match item {
442            Some(StreamItem::Event(FleetEvent::Sample(view))) => {
443                sink.write_sample(view).await?;
444            }
445            Some(StreamItem::Dropped(n)) => {
446                sink.write_dropped(n).await?;
447            }
448            Some(_) => continue,
449            None => return Ok(()),
450        }
451        let (samples, dropped) = sink.counts();
452        on_progress(samples, dropped);
453    }
454}
455
456/// One `.zrec` line after the header.
457#[derive(Debug, Clone)]
458pub enum ZrecItem {
459    /// A publishable row, its pacing offset (absent on a hand-piped ndjson
460    /// row — replay treats that as "no delay"), and the capture-time
461    /// publisher HLC, informative only.
462    Sample {
463        row: IngestRow,
464        t_us: Option<u64>,
465        timestamp: Option<String>,
466    },
467    /// Samples the capture itself missed at this position (O6).
468    Dropped(u64),
469}
470
471/// A `.zrec` reader over any buffered byte source: header up front, then
472/// one item per line — bounded memory, like the writer.
473pub struct ZrecReader<R: BufRead> {
474    header: ZrecHeader,
475    lines: std::io::Lines<R>,
476    /// 1-based number of the last line handed out (the header is line 1).
477    line: u64,
478}
479
480impl<R: BufRead> ZrecReader<R> {
481    /// Parse the header line. A file without one is not a `.zrec` — plain
482    /// ndjson pipes replay through `zenctl pub --from ndjson`, which needs
483    /// no base contract because the operator is the pacing.
484    pub fn new(source: R) -> Result<Self> {
485        let mut lines = source.lines();
486        let first = lines
487            .next()
488            .ok_or_else(|| Error::malformed(".zrec", "empty file — no header line"))?
489            .map_err(|e| Error::Io {
490                path: std::path::PathBuf::new(),
491                source: e,
492            })?;
493        let header: ZrecHeader = serde_json::from_str(&first)
494            .map_err(|e| Error::malformed_with(".zrec line 1", "is not a header", e))?;
495        if header.zrec != ZREC_VERSION {
496            return Err(Error::malformed(
497                ".zrec",
498                format!(
499                    "unsupported version {} (this reader speaks {ZREC_VERSION})",
500                    header.zrec
501                ),
502            ));
503        }
504        Ok(ZrecReader {
505            header,
506            lines,
507            line: 1,
508        })
509    }
510
511    pub fn header(&self) -> &ZrecHeader {
512        &self.header
513    }
514
515    /// The next item, or `Err` naming the line and the reason — a malformed
516    /// row is counted by the caller, never silently skipped
517    /// ([`crate::tape::ingest`]'s rule). `None` ends the file.
518    #[allow(clippy::should_implement_trait)] // fallible, line-numbered next
519    pub fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
520        loop {
521            let line = match self.lines.next()? {
522                Ok(l) => l,
523                Err(e) => {
524                    self.line += 1;
525                    return Some(Err(format!("line {}: read: {e}", self.line)));
526                }
527            };
528            self.line += 1;
529            if line.trim().is_empty() {
530                continue;
531            }
532            // A drop record is `{"dropped": n}` — no key, not a row.
533            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line)
534                && v.get("key").is_none()
535                && let Some(n) = v.get("dropped").and_then(serde_json::Value::as_u64)
536            {
537                return Some(Ok(ZrecItem::Dropped(n)));
538            }
539            return Some(match parse_row(&line) {
540                Ok(row) => {
541                    let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
542                    Ok(ZrecItem::Sample {
543                        row,
544                        t_us: v.get("t").and_then(serde_json::Value::as_u64),
545                        timestamp: v
546                            .get("timestamp")
547                            .and_then(serde_json::Value::as_str)
548                            .map(str::to_string),
549                    })
550                }
551                Err(e) => Err(format!("line {}: {e}", self.line)),
552            });
553        }
554    }
555}
556
557/// A [`ZrecReader`] running on the blocking pool behind a bounded channel
558/// (#332) — the async end of a replay, and the mirror of [`ZrecSink`].
559///
560/// [`replay`] interleaves `sleep().await`s and network puts with its reads,
561/// so a blocking `BufRead` in that loop stalls the runtime on every line —
562/// on a cold page cache or a network filesystem, for as long as the read
563/// takes, mid-pacing. Here the file is read ahead on a blocking thread and
564/// the loop awaits parsed items; the queue is bounded, so a replay that
565/// pauses for pacing does not read the whole capture into memory.
566pub struct ZrecSource {
567    header: ZrecHeader,
568    rx: tokio::sync::mpsc::Receiver<std::result::Result<ZrecItem, String>>,
569}
570
571impl ZrecSource {
572    /// Parse the header, then read the rest ahead on the blocking pool.
573    ///
574    /// The header is awaited — a file that is not a `.zrec` is a refusal
575    /// before anything is scheduled, exactly as it was when the reader was
576    /// constructed inline.
577    pub async fn spawn<R: BufRead + Send + 'static>(source: R) -> Result<ZrecSource> {
578        let (tx, rx) = tokio::sync::mpsc::channel(SINK_QUEUE);
579        let (ready, opened) = tokio::sync::oneshot::channel();
580        tokio::task::spawn_blocking(move || {
581            let mut reader = match ZrecReader::new(source) {
582                Ok(r) => r,
583                Err(e) => {
584                    let _ = ready.send(Err(e));
585                    return;
586                }
587            };
588            if ready.send(Ok(reader.header().clone())).is_err() {
589                return;
590            }
591            // A receiver that went away ends the read: a dropped replay must
592            // not leave a thread reading a file nobody will look at.
593            while let Some(item) = reader.next() {
594                if tx.blocking_send(item).is_err() {
595                    return;
596                }
597            }
598        });
599        match opened.await {
600            Ok(header) => Ok(ZrecSource {
601                header: header?,
602                rx,
603            }),
604            Err(_) => Err(Error::Internal(
605                "the .zrec reader stopped before it opened".into(),
606            )),
607        }
608    }
609
610    pub fn header(&self) -> &ZrecHeader {
611        &self.header
612    }
613
614    /// The next item, or `Err` naming the line and the reason — a malformed
615    /// row is counted by the caller, never silently skipped. `None` ends the
616    /// file.
617    pub async fn next(&mut self) -> Option<std::result::Result<ZrecItem, String>> {
618        self.rx.recv().await
619    }
620}
621
622/// Where a replay's writes go.
623pub enum ReplayTarget<'a> {
624    /// No session at all: list what would be published, publish nothing.
625    /// The zero-puts guarantee is structural — there is nothing to put on.
626    DryRun,
627    /// Real puts through declared publishers on this session.
628    Bus {
629        session: &'a Session,
630        /// Registry slices for the retire gate's `ttl_s` awareness; `None`
631        /// classifies from the grammar alone.
632        slices: Option<&'a SliceSet>,
633    },
634}
635
636/// What one replay is.
637///
638/// `default_qos` is a [`QosProfile`] and not a profile *name*: the closed
639/// enum is RFC 04 §3's vocabulary, and a caller that hands over a string has
640/// only deferred the moment it is checked — this used to surface a bad
641/// `--qos` as a per-row "malformed" event partway through a replay, rather
642/// than as a refusal before anything published. A name recorded *in the
643/// capture* is still a string, because a file can carry anything; that check
644/// stays where it belongs, per row.
645pub struct ReplaySpec<'a> {
646    pub target: ReplayTarget<'a>,
647    /// Pacing scale: 2.0 replays twice as fast as captured.
648    pub speed: f64,
649    /// Replay recorded deletes that fall off the state class — the same
650    /// operator price as `zenctl retire` (RFC 04 §1.2, v1.12).
651    pub i_know: bool,
652    /// The profile a row that recorded none is published under.
653    pub default_qos: QosProfile,
654}
655
656/// Replay events, surfaced as they happen so a frontend can render them —
657/// the report at the end carries the counts.
658#[derive(Debug, Clone)]
659pub enum ReplayEvent<'a> {
660    /// Dry run: this row would publish.
661    WouldPut {
662        key: &'a str,
663        bytes: usize,
664        encoding: Option<&'a str>,
665    },
666    /// Dry run: this row would tombstone.
667    WouldRetire { key: &'a str },
668    /// A row that could not be parsed — counted, never skipped.
669    Malformed { reason: String },
670    /// A delete row the retire gate refused (RFC 04 §1.2 v1.12).
671    Refused { key: String, reason: String },
672    /// The capture itself missed this many samples here (O6): the replay
673    /// is a partial view of a partial view, and both halves are counted.
674    CaptureDropped(u64),
675}
676
677/// The publishers one [`replay`] has declared, and the promise that every way
678/// out of it undeclares them (#327).
679///
680/// The declarations used to live in a bare `HashMap`, so each of the loop's
681/// `?`s returned with the whole set still declared on the bus — contradicting
682/// this module's own "undeclared at the end" and the crate idiom stated at
683/// [`crate::bus::query::RepeatingQuery::undeclare`]: teardown is explicit and
684/// awaited, never left to `Drop`.
685///
686/// [`close`](Self::close) is that teardown, modelled on
687/// [`crate::Monitor::shutdown`]: **every** publisher is undeclared even when
688/// one fails, and the failures are reported together — a replay half torn down
689/// is worse than one torn down noisily.
690///
691/// `Drop` is the cancellation fallback, and the one path that cannot be
692/// awaited: a dropped `replay` future hands the remaining publishers to a task
693/// that undeclares them properly, rather than leaving zenoh to reclaim them
694/// behind everyone's back. Nothing reaches it on the normal paths — `close`
695/// leaves the set empty.
696#[derive(Default)]
697struct Publications(HashMap<String, crate::bus::write::Publication>);
698
699impl std::ops::Deref for Publications {
700    type Target = HashMap<String, crate::bus::write::Publication>;
701    fn deref(&self) -> &Self::Target {
702        &self.0
703    }
704}
705
706impl std::ops::DerefMut for Publications {
707    fn deref_mut(&mut self) -> &mut Self::Target {
708        &mut self.0
709    }
710}
711
712impl Publications {
713    /// Undeclare every publisher, acknowledged, joining what failed.
714    async fn close(mut self) -> Result<()> {
715        crate::bus::teardown::drain_undeclare(self.0.drain().collect(), |p| {
716            crate::bus::write::Publication::undeclare(p)
717        })
718        .await
719    }
720}
721
722impl Drop for Publications {
723    fn drop(&mut self) {
724        if self.0.is_empty() {
725            return;
726        }
727        // No runtime means nothing can be awaited at all; zenoh's own
728        // drop-undeclare is then the only teardown there is.
729        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
730            return;
731        };
732        let declared: Vec<(String, crate::bus::write::Publication)> = self.0.drain().collect();
733        runtime.spawn(async move {
734            for (key, publication) in declared {
735                if let Err(e) = publication.undeclare().await {
736                    tracing::warn!(key = %key, "undeclare after a cancelled replay: {e}");
737                }
738            }
739        });
740    }
741}
742
743/// Replay a `.zrec` onto a bus — or list what doing so would publish.
744///
745/// Pacing follows each row's `t` divided by `speed` (must be positive);
746/// a dry run lists instantly, because a preview that takes the capture's
747/// duration is a preview nobody runs. Delete rows pass
748/// [`crate::bus::write::check_retire`] under the **header's** base — the keys
749/// were captured under it, and classifying them under anything else would
750/// re-derive what O3 says must not be re-derived; `i_know` is the operator
751/// saying the off-state cleanup is meant. Publishers are declared once per
752/// distinct key and undeclared on **every** way out — a failed row tears the
753/// set down before it reports, and a cancelled replay hands the remainder to
754/// a drop guard that undeclares them properly (#327).
755pub async fn replay(
756    reader: &mut ZrecSource,
757    spec: ReplaySpec<'_>,
758    mut on_event: impl FnMut(ReplayEvent<'_>),
759) -> Result<ReplayReport> {
760    let ReplaySpec {
761        target,
762        speed,
763        i_know,
764        default_qos,
765    } = spec;
766    if !(speed.is_finite() && speed > 0.0) {
767        return Err(Error::unaskable(
768            "--speed",
769            format!("must be a positive number (got {speed})"),
770        ));
771    }
772    let base = reader.header().base.clone();
773    let mut report = ReplayReport {
774        header: reader.header().clone(),
775        dry_run: matches!(target, ReplayTarget::DryRun),
776        speed,
777        published: 0,
778        tombstones: 0,
779        malformed: 0,
780        refused: 0,
781        capture_dropped: 0,
782        first_errors: Vec::new(),
783    };
784    let record_err = |report: &mut ReplayReport, reason: String, refused: bool| {
785        if refused {
786            report.refused += 1;
787        } else {
788            report.malformed += 1;
789        }
790        if report.first_errors.len() < 3 {
791            report.first_errors.push(reason);
792        }
793    };
794    let mut publications = Publications::default();
795    let mut prev_t: Option<u64> = None;
796    // The one fatal error a row can raise, held rather than thrown: the
797    // publishers are undeclared first, and only then does it go back to the
798    // caller (#327).
799    let mut fatal: Option<Error> = None;
800    while let Some(item) = reader.next().await {
801        let (row, t_us) = match item {
802            Ok(ZrecItem::Sample { row, t_us, .. }) => (row, t_us),
803            Ok(ZrecItem::Dropped(n)) => {
804                report.capture_dropped += n;
805                on_event(ReplayEvent::CaptureDropped(n));
806                continue;
807            }
808            Err(reason) => {
809                on_event(ReplayEvent::Malformed {
810                    reason: reason.clone(),
811                });
812                record_err(&mut report, reason, false);
813                continue;
814            }
815        };
816        let slices = match &target {
817            ReplayTarget::Bus { slices, .. } => *slices,
818            ReplayTarget::DryRun => None,
819        };
820        if row.delete
821            && let Err(e) = crate::bus::write::check_retire(&base, &row.key, slices, i_know)
822        {
823            let reason = e.to_string();
824            on_event(ReplayEvent::Refused {
825                key: row.key.clone(),
826                reason: reason.clone(),
827            });
828            record_err(&mut report, format!("{}: {reason}", row.key), true);
829            continue;
830        }
831        match &target {
832            ReplayTarget::DryRun => {
833                if row.delete {
834                    on_event(ReplayEvent::WouldRetire { key: &row.key });
835                    report.tombstones += 1;
836                } else {
837                    on_event(ReplayEvent::WouldPut {
838                        key: &row.key,
839                        bytes: row.payload.len(),
840                        encoding: row.encoding.as_deref(),
841                    });
842                    report.published += 1;
843                }
844            }
845            ReplayTarget::Bus { session, .. } => {
846                // Original pacing, scaled — the observer's arrival clock is
847                // the only clock a capture has for "when" (RFC 09 §5.2).
848                if let (Some(prev), Some(t)) = (prev_t, t_us)
849                    && t > prev
850                {
851                    let delay = Duration::from_micros(t - prev).div_f64(speed);
852                    tokio::time::sleep(delay).await;
853                }
854                if t_us.is_some() {
855                    prev_t = t_us;
856                }
857                let publication = match publications.entry(row.key.clone()) {
858                    std::collections::hash_map::Entry::Occupied(e) => e.into_mut(),
859                    std::collections::hash_map::Entry::Vacant(e) => {
860                        // A row that recorded a profile name is judged
861                        // against the closed vocabulary — a name the capture
862                        // carries can be anything. A row that recorded none
863                        // falls to the spec's profile, which is already
864                        // typed and so cannot fail here.
865                        let qos = match &row.qos {
866                            None => default_qos,
867                            Some(name) => match zenkey::qos::QosProfile::from_name(name) {
868                                Some(qos) => qos,
869                                None => {
870                                    let reason = format!("unknown QoS profile {name:?}");
871                                    on_event(ReplayEvent::Malformed {
872                                        reason: reason.clone(),
873                                    });
874                                    record_err(&mut report, reason, false);
875                                    continue;
876                                }
877                            },
878                        };
879                        let publication = match crate::bus::write::declare_publication(
880                            session,
881                            &row.key,
882                            qos,
883                            row.encoding.as_deref(),
884                        )
885                        .await
886                        {
887                            Ok(p) => p,
888                            Err(e) => {
889                                fatal = Some(e);
890                                break;
891                            }
892                        };
893                        e.insert(publication)
894                    }
895                };
896                let delete = row.delete;
897                let sent = if delete {
898                    publication.retire().await
899                } else {
900                    publication.send(row.payload, row.attachment).await
901                };
902                match (sent, delete) {
903                    (Ok(()), true) => report.tombstones += 1,
904                    (Ok(()), false) => report.published += 1,
905                    (Err(e), _) => {
906                        fatal = Some(e);
907                        break;
908                    }
909                }
910            }
911        }
912    }
913    // Teardown first, on every path out — the row error is the one reported,
914    // but a failure to undeclare is never skipped for it.
915    let closed = publications.close().await;
916    if let Some(e) = fatal {
917        return Err(e);
918    }
919    closed?;
920    Ok(report)
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926
927    /// The provenance stamp is a real RFC 3339 instant, leap-era safe.
928    #[test]
929    fn the_wall_clock_formats_correctly() {
930        assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
931        assert_eq!(rfc3339_from_unix(951_782_400), "2000-02-29T00:00:00Z");
932        assert_eq!(rfc3339_from_unix(1_786_492_800), "2026-08-12T00:00:00Z");
933        assert!(!rfc3339_now().is_empty());
934    }
935
936    fn header() -> ZrecHeader {
937        ZrecHeader {
938            zrec: ZREC_VERSION,
939            selectors: vec!["v1/**".into()],
940            base: String::new(),
941            captured_at: "2026-08-12T00:00:00Z".into(),
942        }
943    }
944
945    /// A replay source over an in-memory capture. `Cursor<Vec<u8>>` because
946    /// the read happens on the blocking pool and so must own its bytes.
947    async fn source_of(body: &str) -> ZrecSource {
948        ZrecSource::spawn(std::io::Cursor::new(body.as_bytes().to_vec()))
949            .await
950            .expect("a .zrec header")
951    }
952
953    /// The header round-trips, and a versioned reader refuses what it
954    /// cannot speak rather than guessing.
955    #[test]
956    fn the_header_is_a_contract() {
957        let mut sink = Vec::new();
958        let writer = ZrecWriter::new(&mut sink, &header()).unwrap();
959        let _ = writer.finish().unwrap();
960        let reader = ZrecReader::new(sink.as_slice()).unwrap();
961        assert_eq!(reader.header(), &header());
962
963        let future = r#"{"zrec":99,"selectors":[],"base":"","captured_at":"x"}"#;
964        let err = ZrecReader::new(future.as_bytes())
965            .err()
966            .unwrap()
967            .to_string();
968        assert!(err.contains("version 99"), "{err}");
969
970        let not_zrec = r#"{"key":"v1/x","value":1}"#;
971        let err = ZrecReader::new(not_zrec.as_bytes())
972            .err()
973            .unwrap()
974            .to_string();
975        assert!(err.contains("header"), "{err}");
976    }
977
978    /// A retained window written through `new_at` keeps its real pacing
979    /// (#217): rows received *before* the writer existed carry their true
980    /// offsets from the injected epoch instead of saturating to `t: 0`.
981    #[test]
982    fn an_injected_epoch_preserves_a_window_written_after_the_fact() {
983        let epoch = Instant::now();
984        let view = |t_ms: u64| crate::bus::monitor::SampleView {
985            key: "v1/h-0123456789ab/state/p/a".into(),
986            payload: zenoh::bytes::ZBytes::from(vec![1u8]),
987            encoding: String::new(),
988            kind: SampleKind::Put,
989            timestamp: None,
990            stamped_by: None,
991            attachment: None,
992            priority: zenoh::qos::Priority::DEFAULT,
993            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
994            reliability: zenoh::qos::Reliability::DEFAULT,
995            express: false,
996            source: None,
997            received: epoch + Duration::from_millis(t_ms),
998        };
999        let mut sink = Vec::new();
1000        let mut w = ZrecWriter::new_at(&mut sink, &header(), epoch).unwrap();
1001        w.write_sample(&view(0)).unwrap();
1002        w.write_sample(&view(1500)).unwrap();
1003        let _ = w.finish().unwrap();
1004
1005        let mut reader = ZrecReader::new(sink.as_slice()).unwrap();
1006        let t_of = |item| match item {
1007            Some(Ok(ZrecItem::Sample { t_us, .. })) => t_us,
1008            other => panic!("expected a sample, got {other:?}"),
1009        };
1010        assert_eq!(t_of(reader.next()), Some(0));
1011        assert_eq!(
1012            t_of(reader.next()),
1013            Some(1_500_000),
1014            "the offset the ring preserved, not a saturated zero"
1015        );
1016    }
1017
1018    /// Drop records read back as drops, at their position (O6): the gap is
1019    /// part of the record, not a footnote.
1020    #[test]
1021    fn drops_are_interleaved_facts() {
1022        let body = format!(
1023            "{}\n{}\n{}\n{}\n",
1024            serde_json::to_string(&header()).unwrap(),
1025            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
1026            r#"{"dropped":7}"#,
1027            r#"{"key":"v1/h/state/p/a","t":1000,"bytes":"Ag=="}"#,
1028        );
1029        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
1030        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
1031        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Dropped(7)))));
1032        assert!(matches!(
1033            reader.next(),
1034            Some(Ok(ZrecItem::Sample {
1035                t_us: Some(1000),
1036                ..
1037            }))
1038        ));
1039        assert!(reader.next().is_none());
1040    }
1041
1042    /// A malformed line is an error naming its line number — counted by
1043    /// the caller, never a skip.
1044    #[test]
1045    fn malformed_lines_are_named_not_skipped() {
1046        let body = format!(
1047            "{}\nnot json\n{}\n",
1048            serde_json::to_string(&header()).unwrap(),
1049            r#"{"key":"v1/h/state/p/a","t":0,"bytes":"AQ=="}"#,
1050        );
1051        let mut reader = ZrecReader::new(body.as_bytes()).unwrap();
1052        let err = match reader.next() {
1053            Some(Err(e)) => e,
1054            other => panic!("expected a named error, got {other:?}"),
1055        };
1056        assert!(err.starts_with("line 2:"), "{err}");
1057        assert!(matches!(reader.next(), Some(Ok(ZrecItem::Sample { .. }))));
1058    }
1059
1060    /// A dry run performs zero puts by construction — there is no session —
1061    /// and still counts and classifies every row.
1062    #[tokio::test]
1063    async fn a_dry_run_lists_and_publishes_nothing() {
1064        let body = format!(
1065            "{}\n{}\n{}\n{}\n",
1066            serde_json::to_string(&header()).unwrap(),
1067            r#"{"key":"v1/h-0123456789ab/state/p/health","t":0,"bytes":"eyJvayI6dHJ1ZX0=","encoding":"application/json"}"#,
1068            r#"{"dropped":3}"#,
1069            r#"{"key":"v1/h-0123456789ab/state/p/health","t":500000,"delete":true}"#,
1070        );
1071        let mut reader = source_of(&body).await;
1072        let mut would = Vec::new();
1073        let report = replay(
1074            &mut reader,
1075            ReplaySpec {
1076                target: ReplayTarget::DryRun,
1077                speed: 1.0,
1078                i_know: false,
1079                default_qos: QosProfile::Refreshed,
1080            },
1081            |ev| {
1082                would.push(format!("{ev:?}"));
1083            },
1084        )
1085        .await
1086        .unwrap();
1087        assert!(report.dry_run);
1088        assert_eq!(report.published, 1);
1089        assert_eq!(report.tombstones, 1); // state-shaped: licensed without force
1090        assert_eq!(report.capture_dropped, 3);
1091        assert_eq!(report.malformed, 0);
1092        assert_eq!(would.len(), 3, "{would:?}");
1093    }
1094
1095    /// A recorded delete off the state class keeps its price on replay
1096    /// (RFC 04 §1.2 v1.12): refused without `i_know`, counted.
1097    #[tokio::test]
1098    async fn replayed_tombstones_pass_the_retire_gate() {
1099        let body = format!(
1100            "{}\n{}\n",
1101            serde_json::to_string(&header()).unwrap(),
1102            r#"{"key":"v1/h-0123456789ab/telemetry/p/temp","t":0,"delete":true}"#,
1103        );
1104        let mut reader = source_of(&body).await;
1105        let report = replay(
1106            &mut reader,
1107            ReplaySpec {
1108                target: ReplayTarget::DryRun,
1109                speed: 1.0,
1110                i_know: false,
1111                default_qos: QosProfile::Refreshed,
1112            },
1113            |_| {},
1114        )
1115        .await
1116        .unwrap();
1117        assert_eq!(report.refused, 1);
1118        assert_eq!(report.tombstones, 0);
1119        assert!(
1120            report.first_errors[0].contains("telemetry"),
1121            "{:?}",
1122            report.first_errors
1123        );
1124    }
1125
1126    /// Speed is a positive finite scale, stated rather than clamped.
1127    #[tokio::test]
1128    async fn speed_must_be_positive() {
1129        let body = serde_json::to_string(&header()).unwrap() + "\n";
1130        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
1131            let mut reader = source_of(&body).await;
1132            let err = replay(
1133                &mut reader,
1134                ReplaySpec {
1135                    target: ReplayTarget::DryRun,
1136                    speed: bad,
1137                    i_know: false,
1138                    default_qos: QosProfile::Refreshed,
1139                },
1140                |_| {},
1141            )
1142            .await
1143            .unwrap_err()
1144            .to_string();
1145            assert!(err.contains("speed"), "{err}");
1146        }
1147    }
1148
1149    /// A row the bus refuses is still reported — the teardown that now runs
1150    /// first does not swallow it (#327). The undeclared publishers left behind
1151    /// by the old `?` were invisible from the outside, which is why the drain
1152    /// itself is pinned in `bus::teardown`; what is observable here is that
1153    /// the failing row's own error is what comes back.
1154    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1155    async fn a_row_the_bus_refuses_tears_down_and_still_reports_itself() {
1156        let session = crate::bus::session::open(&[], &[], false)
1157            .await
1158            .expect("a standalone peer");
1159        let good = SampleRow {
1160            key: "v1/h-aaaaaaaaaaaa/state/demo/health".into(),
1161            ..SampleRow::default()
1162        }
1163        .with_payload_bytes(b"{}");
1164        // An empty chunk is not a key expression, so `declare_publication`
1165        // refuses it — the fatal row this replay dies on, after one good
1166        // publisher is already declared.
1167        let bad = SampleRow {
1168            key: "v1//nowhere".into(),
1169            ..SampleRow::default()
1170        }
1171        .with_payload_bytes(b"{}");
1172        let body = format!(
1173            "{}\n{}\n{}\n",
1174            serde_json::to_string(&header()).unwrap(),
1175            good.to_line(),
1176            bad.to_line(),
1177        );
1178
1179        let mut reader = source_of(&body).await;
1180        let err = replay(
1181            &mut reader,
1182            ReplaySpec {
1183                target: ReplayTarget::Bus {
1184                    session: &session,
1185                    slices: None,
1186                },
1187                speed: 1000.0,
1188                i_know: false,
1189                default_qos: QosProfile::Transition,
1190            },
1191            |_| {},
1192        )
1193        .await
1194        .expect_err("the bus refused the second row")
1195        .to_string();
1196        assert!(err.contains("nowhere"), "{err}");
1197
1198        session.close().await.expect("close the session");
1199    }
1200}