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}