Skip to main content

zenkey_fleet/tape/
ingest.rs

1//! The row shape the explorers emit and read back (#125): one schema, both
2//! directions.
3//!
4//! [`SampleRow`] is the only writer and [`parse_row`] the only reader, which
5//! is what makes the pipe symmetric. It was not, until #235: the claim lived
6//! in this doc comment while three hand-built writers — `.zrec`
7//! ([`mod@crate::tape::record`]), `zenctl echo --format ndjson`, and zengui's
8//! echo export — each assembled the object with `serde_json::json!` and two
9//! of them disagreed with this reader. `echo` wrote the zenoh *wire axes*
10//! under `"qos"`, where [`parse_row`] resolves a profile *name*, so every
11//! row of `echo --format ndjson | pub --from ndjson` was counted
12//! malformed; zengui wrote a payload byte *count* under `"bytes"`, which has
13//! meant base64 of the wire payload since RFC 09 §5.2. Both carried a doc
14//! comment asserting conformance. One struct is the repair — a dialect with
15//! one writer cannot drift from itself.
16//!
17//! A row names its key and payload, and optionally its encoding, QoS
18//! profile, tombstone-ness, and attachment. Unknown fields are ignored
19//! (rows carry observer-side extras like `type`/`typed`); a row that
20//! cannot be published is an error *naming the reason*, and callers MUST
21//! count those rather than silently skipping (zenoh-cli logs-and-drops;
22//! we count).
23//!
24//! A row MAY carry its payload lossless as `"bytes"` (base64 of the exact
25//! wire bytes — the `.zrec` dialect, RFC 09 §5.2), which wins over
26//! `"value"`: a `value` is a decoded *rendering* and does not round-trip a
27//! binary payload. Same for `"attachment_b64"` over `"attachment"`.
28
29use crate::report::SampleRow;
30
31impl SampleRow {
32    /// The identity fields every writer shares: the wire key, and what the
33    /// convention could make of it under this base.
34    ///
35    /// A key that does not parse still yields a row — O1: a key that does
36    /// not parse is a fact, not an error — it simply carries no `origin`
37    /// or `subject`.
38    pub fn of_key(key: &str, base: &str) -> SampleRow {
39        let parsed = zenkey::grammar::parse_full(base, key);
40        SampleRow {
41            key: key.to_string(),
42            origin: parsed.as_ref().map(|p| p.origin.chunk().to_string()),
43            subject: parsed.as_ref().map(|p| p.subject.join("/")),
44            ..SampleRow::default()
45        }
46    }
47
48    /// The wire facts a [`crate::SampleView`] carries, filled in.
49    ///
50    /// Payload and attachment are left to the caller: an observer renders
51    /// them (`value`), a capture stores them (`bytes`), and which one a
52    /// writer owes is the difference between the two dialect halves.
53    pub fn with_wire(mut self, view: &crate::SampleView) -> SampleRow {
54        self.delete = view.kind == zenoh::sample::SampleKind::Delete;
55        if !view.encoding.is_empty() {
56            self.encoding = Some(view.encoding.clone());
57        }
58        self.timestamp = view.timestamp.map(|t| t.to_string());
59        // The profile name only where the axes actually match one, exactly
60        // as `.zrec` has always done it: a name the reader cannot resolve
61        // is worse than no name (#235).
62        self.qos = zenkey::qos::QosProfile::ALL
63            .into_iter()
64            .find(|p| view.qos_matches(*p))
65            .map(|p| p.name().to_string());
66        self
67    }
68
69    /// The lossless payload, base64 — what a capture owes and a live
70    /// rendering does not (RFC 09 §5.2).
71    pub fn with_payload_bytes(mut self, payload: &[u8]) -> SampleRow {
72        self.bytes = Some(b64(payload));
73        self
74    }
75
76    /// One line of ndjson, newline excluded.
77    pub fn to_line(&self) -> String {
78        serde_json::to_string(self).expect("a sample row serializes")
79    }
80}
81
82/// One publishable row.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct IngestRow {
85    /// Full wire key — explorers are un-namespaced, so rows carry what was
86    /// (or will be) on the wire.
87    pub key: String,
88    /// The payload bytes: a JSON `value` re-serializes compactly, a string
89    /// `value` publishes its raw bytes (the same asymmetry `structural`
90    /// introduced on the way out, undone).
91    pub payload: Vec<u8>,
92    /// The row's declared encoding, when it carries one.
93    pub encoding: Option<String>,
94    /// The row's QoS profile name (RFC 04 §3), when it carries one.
95    pub qos: Option<String>,
96    /// A tombstone row (RFC 04 §1.2): publish a delete, not the payload.
97    pub delete: bool,
98    /// The row's attachment, when it carries one (#117), same value rules
99    /// as the payload.
100    pub attachment: Option<Vec<u8>>,
101}
102
103fn value_bytes(v: &serde_json::Value) -> Vec<u8> {
104    match v {
105        serde_json::Value::String(s) => s.clone().into_bytes(),
106        other => serde_json::to_vec(other).unwrap_or_default(),
107    }
108}
109
110/// Base64 for the wire-payload fields, beside the decoder that reads them
111/// back. `.zrec` writes through here too, so one alphabet is spelled once.
112pub(crate) fn b64(bytes: &[u8]) -> String {
113    use base64::Engine as _;
114    base64::engine::general_purpose::STANDARD.encode(bytes)
115}
116
117/// Decode a base64 field, naming the field in the error.
118fn b64_bytes(
119    obj: &serde_json::Map<String, serde_json::Value>,
120    field: &str,
121) -> Result<Option<Vec<u8>>, String> {
122    use base64::Engine as _;
123
124    match obj.get(field) {
125        None | Some(serde_json::Value::Null) => Ok(None),
126        Some(serde_json::Value::String(s)) => base64::engine::general_purpose::STANDARD
127            .decode(s)
128            .map(Some)
129            .map_err(|e| format!("\"{field}\" is not base64: {e}")),
130        Some(_) => Err(format!("\"{field}\" is not a base64 string")),
131    }
132}
133
134/// Parse one ndjson line into a publishable row.
135pub fn parse_row(line: &str) -> Result<IngestRow, String> {
136    let v: serde_json::Value =
137        serde_json::from_str(line).map_err(|e| format!("not a JSON object: {e}"))?;
138    let obj = v.as_object().ok_or("not a JSON object")?;
139    let key = obj
140        .get("key")
141        .and_then(|k| k.as_str())
142        .ok_or("no \"key\" field")?
143        .to_string();
144    if key.is_empty() {
145        return Err("empty \"key\"".into());
146    }
147    let delete = obj.get("delete").and_then(|d| d.as_bool()).unwrap_or(false);
148    // The lossless bytes win over the rendering (`.zrec` rows carry both
149    // clocks and neither payload shape lies — RFC 09 §5.2).
150    let payload = match (b64_bytes(obj, "bytes")?, obj.get("value")) {
151        (Some(raw), _) => raw,
152        (None, Some(serde_json::Value::Null) | None) if delete => Vec::new(),
153        (None, Some(serde_json::Value::Null) | None) => {
154            return Err("no \"value\" or \"bytes\" field (and not a delete row)".into());
155        }
156        (None, Some(v)) => value_bytes(v),
157    };
158    let attachment = match b64_bytes(obj, "attachment_b64")? {
159        Some(raw) => Some(raw),
160        None => obj.get("attachment").map(value_bytes),
161    };
162    Ok(IngestRow {
163        key,
164        payload,
165        encoding: obj
166            .get("encoding")
167            .and_then(|e| e.as_str())
168            .filter(|e| !e.is_empty())
169            .map(str::to_string),
170        qos: obj.get("qos").and_then(|q| q.as_str()).map(str::to_string),
171        delete,
172        attachment,
173    })
174}
175
176/// One line of an explorer ndjson **stream**, as the input side reads it.
177///
178/// A stream interleaves its samples with tagged non-sample rows — `{"row":
179/// "dropped",…}` where the bus outran the observer (RFC 09 §5.1 O6),
180/// `{"row":"seed",…}` at the seed boundary — and a consumer that wants the
181/// samples must tell those apart from a row it cannot parse: metadata is
182/// *skipped and counted as skipped*, a malformed row is an error naming the
183/// reason. Before this split, echo's own honesty lines poisoned the
184/// `echo | pub --from ndjson` round trip the dialect exists for (#235).
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum StreamLine {
187    /// A publishable sample row.
188    Sample(IngestRow),
189    /// A tagged non-sample row; the value is the `"row"` tag (`"dropped"`,
190    /// `"seed"`, …). Stream metadata — skip it, count the skip.
191    Meta(String),
192}
193
194/// Parse one line of an explorer stream: [`parse_row`], with the stream's
195/// tagged meta rows told apart from its samples.
196///
197/// The `"row"` key is the explorers' kind tag (zenctl's `render::Row`
198/// convention: every non-sample line of a heterogeneous stream carries one).
199/// A sample row never carries the tag today; `"sample"` is reserved so a
200/// future writer that tags its samples still round-trips.
201pub fn parse_stream_line(line: &str) -> Result<StreamLine, String> {
202    if let Ok(serde_json::Value::Object(obj)) = serde_json::from_str::<serde_json::Value>(line)
203        && let Some(tag) = obj.get("row").and_then(serde_json::Value::as_str)
204        && tag != "sample"
205    {
206        return Ok(StreamLine::Meta(tag.to_string()));
207    }
208    parse_row(line).map(StreamLine::Sample)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    /// The parser's leniency, over a hand-written row: JSON values
216    /// re-serialize compactly, string values publish their raw bytes, and
217    /// observer-side extras are ignored.
218    ///
219    /// This was called `an_echo_row_reads_back` and its row was typed by
220    /// hand, so it proved nothing about what `echo` emitted — which is how
221    /// #235 shipped. `a_row_this_crate_wrote_is_a_row_this_crate_reads`
222    /// below makes the claim this name used to.
223    #[test]
224    fn a_hand_written_row_reads_back_with_its_extras_ignored() {
225        let row = parse_row(
226            r#"{"key":"v1/h-1/state/p/health","origin":"h-1","type":"Health","typed":true,
227                "encoding":"application/json","timestamp":null,"delete":false,
228                "value":{"status":"ok"}}"#,
229        )
230        .unwrap();
231        assert_eq!(row.key, "v1/h-1/state/p/health");
232        assert_eq!(row.payload, br#"{"status":"ok"}"#);
233        assert_eq!(row.encoding.as_deref(), Some("application/json"));
234        assert!(!row.delete);
235
236        let text = parse_row(r#"{"key":"k","value":"just words"}"#).unwrap();
237        assert_eq!(text.payload, b"just words");
238    }
239
240    /// A tombstone row needs no value; a non-delete row without one is a
241    /// counted error, never a silent skip.
242    #[test]
243    fn tombstones_and_malformed_rows_are_told_apart() {
244        let del = parse_row(r#"{"key":"k","delete":true,"value":null}"#).unwrap();
245        assert!(del.delete);
246        assert!(del.payload.is_empty());
247
248        let err = parse_row(r#"{"key":"k"}"#).unwrap_err();
249        assert!(err.contains("value"), "{err}");
250        let err = parse_row(r#"{"value":1}"#).unwrap_err();
251        assert!(err.contains("key"), "{err}");
252        let err = parse_row("not json").unwrap_err();
253        assert!(err.contains("JSON"), "{err}");
254    }
255
256    /// The `.zrec` dialect: `bytes` is the exact wire payload and wins over
257    /// the `value` rendering; `attachment_b64` likewise. Bad base64 is a
258    /// counted error, not a skip (RFC 09 §5.2).
259    #[test]
260    fn lossless_bytes_win_over_the_rendering() {
261        let row = parse_row(
262            r#"{"key":"k","t":125000,"bytes":"AAEC/w==","value":"lossy render",
263                "attachment_b64":"3q0=","encoding":"application/octet-stream"}"#,
264        )
265        .unwrap();
266        assert_eq!(row.payload, vec![0x00, 0x01, 0x02, 0xff]);
267        assert_eq!(row.attachment.as_deref(), Some([0xde, 0xad].as_ref()));
268
269        // A bytes-only row needs no value at all.
270        let row = parse_row(r#"{"key":"k","bytes":"aGk="}"#).unwrap();
271        assert_eq!(row.payload, b"hi");
272
273        let err = parse_row(r#"{"key":"k","bytes":"not base64!"}"#).unwrap_err();
274        assert!(err.contains("base64"), "{err}");
275        let err = parse_row(r#"{"key":"k","bytes":7}"#).unwrap_err();
276        assert!(err.contains("base64"), "{err}");
277    }
278
279    /// A `SampleView` built from the wire, so `with_wire`'s rules are
280    /// exercised rather than restated.
281    fn view(payload: &[u8], profile: Option<zenkey::qos::QosProfile>) -> crate::SampleView {
282        use zenkey::qos::QosProfile;
283        // Axes that match no profile, unless one was asked for: the case
284        // `.zrec` always handled and `echo` did not (#235).
285        let p = profile.unwrap_or(QosProfile::Sampled);
286        crate::SampleView {
287            key: "v1/h-3fa9c2d41b7e/state/sysinfo/health".into(),
288            payload: zenoh::bytes::ZBytes::from(payload.to_vec()),
289            encoding: "application/json".into(),
290            kind: zenoh::sample::SampleKind::Put,
291            timestamp: None,
292            stamped_by: None,
293            attachment: None,
294            priority: if profile.is_some() {
295                p.priority()
296            } else {
297                zenoh::qos::Priority::Background
298            },
299            congestion_control: p.congestion_control(),
300            reliability: p.reliability(),
301            express: p.express(),
302            source: None,
303            received: std::time::Instant::now(),
304        }
305    }
306
307    /// The bug #235 was: `qos` is the field the reader resolves through
308    /// `QosProfile::from_name`, so anything a writer puts there must be a
309    /// name — never the wire axes, which match no profile by design.
310    #[test]
311    fn the_qos_field_only_ever_carries_a_name_the_reader_can_resolve() {
312        let named = SampleRow::of_key("k", "")
313            .with_wire(&view(b"{}", Some(zenkey::qos::QosProfile::Alert)));
314        assert_eq!(named.qos.as_deref(), Some("alert"));
315        assert!(
316            zenkey::qos::QosProfile::from_name(named.qos.as_deref().unwrap()).is_some(),
317            "whatever lands in `qos` must resolve, or every row of the pipe is malformed"
318        );
319
320        // Axes matching no declared profile are not approximated: the field
321        // is absent, which the reader treats as "no profile stated" and the
322        // publisher's own ladder then resolves.
323        let unnamed = SampleRow::of_key("k", "").with_wire(&view(b"{}", None));
324        assert_eq!(
325            unnamed.qos, None,
326            "axes matching no profile are omitted, never spelled into `qos`"
327        );
328    }
329
330    /// The round trip the README advertises and RFC 09 §5.2 rests on, over
331    /// a row this crate wrote rather than one a test hand-typed — which is
332    /// exactly what `an_echo_row_reads_back` above could not check.
333    #[test]
334    fn a_row_this_crate_wrote_is_a_row_this_crate_reads() {
335        let v = view(
336            br#"{"status":"ok"}"#,
337            Some(zenkey::qos::QosProfile::Refreshed),
338        );
339
340        // The observer's dialect: a rendering under `value`, the wire axes
341        // under their own key, the profile name under `qos`.
342        let mut observed = SampleRow::of_key(&v.key, "").with_wire(&v);
343        observed.value = Some(serde_json::json!({"status": "ok"}));
344        observed.qos_axes = Some("data/drop/reliable".into());
345        observed.payload_bytes = Some(15);
346        let back = parse_row(&observed.to_line()).expect("the observer's row reads back");
347        assert_eq!(back.payload, br#"{"status":"ok"}"#);
348        assert_eq!(back.qos.as_deref(), Some("refreshed"));
349        assert_eq!(back.encoding.as_deref(), Some("application/json"));
350
351        // The capture dialect: the lossless bytes, which win over any
352        // rendering (RFC 09 §5.2).
353        let captured = SampleRow {
354            key: v.key.clone(),
355            t: Some(1_250),
356            ..SampleRow::default()
357        }
358        .with_wire(&v)
359        .with_payload_bytes(&v.payload.to_bytes());
360        let back = parse_row(&captured.to_line()).expect("the capture's row reads back");
361        assert_eq!(back.payload, br#"{"status":"ok"}"#);
362        assert_eq!(back.qos.as_deref(), Some("refreshed"));
363    }
364
365    /// A writer that does not hold a fact omits it. Asserted as a whole
366    /// document, because `json["absent"]` is `Null` and a field-by-field
367    /// check cannot tell absent from null-when-unknown (RFC 09 §5.1 O4) —
368    /// the same reason `report_contract.rs` compares whole documents.
369    #[test]
370    fn an_unheld_fact_is_absent_from_the_row_rather_than_null() {
371        let row = SampleRow {
372            key: "demo/foreign".into(),
373            delete: true,
374            ..SampleRow::default()
375        };
376        assert_eq!(
377            serde_json::to_value(&row).unwrap(),
378            serde_json::json!({"key": "demo/foreign", "delete": true}),
379            "a key that did not parse carries no origin/subject, an unstamped \
380             sample carries no timestamp, and an undecoded one carries no type"
381        );
382    }
383
384    /// The stream reader's three-way split: a tagged non-sample row is Meta
385    /// (skipped, not malformed), an untagged sample row is a Sample, and a
386    /// line that is neither is still an error naming the reason.
387    #[test]
388    fn tagged_meta_rows_are_skipped_not_malformed() {
389        assert_eq!(
390            parse_stream_line(r#"{"dropped":7,"row":"dropped"}"#).unwrap(),
391            StreamLine::Meta("dropped".into())
392        );
393        assert_eq!(
394            parse_stream_line(r#"{"row":"seed","seed_complete":{"superseded":0}}"#).unwrap(),
395            StreamLine::Meta("seed".into())
396        );
397        // A tag whose value is not a string is not the convention's tag: the
398        // line falls through to the row parser and errors like any other.
399        assert!(parse_stream_line(r#"{"row":7}"#).is_err());
400        assert!(matches!(
401            parse_stream_line(r#"{"key":"k","value":1}"#).unwrap(),
402            StreamLine::Sample(r) if r.key == "k"
403        ));
404        let err = parse_stream_line(r#"{"key":"k"}"#).unwrap_err();
405        assert!(err.contains("value"), "{err}");
406    }
407
408    /// Attachments ride the same value rules (#117).
409    #[test]
410    fn attachments_ride_rows() {
411        let row =
412            parse_row(r#"{"key":"k","value":1,"attachment":{"who":"me"},"qos":"alert"}"#).unwrap();
413        assert_eq!(row.attachment.as_deref(), Some(br#"{"who":"me"}"#.as_ref()));
414        assert_eq!(row.qos.as_deref(), Some("alert"));
415    }
416}