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