Skip to main content

zenkey_fleet/report/
timeline.rs

1//! The fleet timeline (#216): one merged ordering of a window's samples,
2//! partitioned into lanes, with the clock it was ordered on stated **per
3//! report** and the stamper stated **per lane**.
4//!
5//! Every row a script reads carries `order_by`, so a line cut out of an
6//! ndjson stream still says which axis its `pos` is a position on. The
7//! shapes here are what [`crate::model::timeline`] computes and what
8//! `zenctl timeline` emits; the reasoning about *why* the two axes never
9//! mix lives with the computation.
10
11use std::collections::BTreeSet;
12
13use serde::Serialize;
14
15/// Which clock a report was ordered on — the envelope's `order_by`, and
16/// every row's.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
18#[serde(rename_all = "snake_case")]
19pub enum OrderLabel {
20    /// The observer's monotonic clock, µs since the window epoch. Every
21    /// sample has one.
22    Arrival,
23    /// The sample's HLC. Only stamped samples have one; the rest are
24    /// excluded and counted, never defaulted.
25    Hlc,
26}
27
28/// What ordering on the HLC axis can claim (RFC 09 §5.1 O7).
29///
30/// zenoh updates a node's HLC on receive only when the node *has* one
31/// (`treat_timestamp!` in `net/routing/dispatcher/pubsub.rs`), and
32/// `net/runtime/mod.rs` builds one only where `timestamping.enabled` is
33/// true for the node's whatami — routers default true, peers and clients
34/// false. So one stamper's values are that node's monotonic clock, ordered
35/// by what it forwarded; two stampers' values are two wall clocks unless
36/// both passed through a common timestamping node, which an observer
37/// cannot see.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39#[serde(tag = "claim", rename_all = "snake_case")]
40pub enum HlcClaim {
41    /// Every stamped sample in the window was stamped by one node: the
42    /// order is that node's happened-before.
43    HappensBefore { stamper: String },
44    /// More than one stamper: the order compares clocks that were never
45    /// synchronised by anything this observer can vouch for.
46    SkewedWallClock { stampers: BTreeSet<String> },
47    /// No sample in the window carried an HLC: the axis is empty, and an
48    /// empty axis claims nothing (O4).
49    NoStampedSamples,
50}
51
52/// Which axis the report is ordered on, and what that axis is.
53#[derive(Debug, Clone, PartialEq, Serialize)]
54#[serde(tag = "axis", rename_all = "snake_case")]
55pub enum AxisLabel {
56    Arrival {
57        /// The one spelling of the arrival clock.
58        clock: &'static str,
59    },
60    Hlc {
61        #[serde(flatten)]
62        claim: HlcClaim,
63    },
64}
65
66/// The arrival clock, spelled once.
67pub const ARRIVAL_CLOCK: &str = "observer monotonic, µs since window start";
68
69/// Who a lane belongs to.
70///
71/// Lanes are keyed by origin and producer because that is the unit a
72/// fleet publishes as (RFC 03 §1). The two remaining variants are the two
73/// ways a sample can fail to belong: its key says nothing under this base,
74/// or it carries no HLC and therefore exists on the arrival axis only.
75#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum LaneId {
78    /// A conforming `v1/<origin>/<class>/<producer>/…` key. `producer` is
79    /// absent under a service origin and under `@blob` (RFC 03 §1.5).
80    Origin {
81        origin: String,
82        #[serde(skip_serializing_if = "Option::is_none")]
83        producer: Option<String>,
84    },
85    /// The key does not parse as a v1 key under the base — not under it,
86    /// or under it and something else. Kept, never dropped (O1).
87    Foreign,
88    /// No HLC rode the sample. This lane has a place on the arrival axis
89    /// and **no place on the HLC axis** — the type system in
90    /// [`crate::model::timeline`] enforces that, not a warning.
91    Unstamped,
92}
93
94impl LaneId {
95    /// The lane as a human label — the table's group heading.
96    pub fn label(&self) -> String {
97        match self {
98            LaneId::Origin {
99                origin,
100                producer: Some(p),
101            } => format!("{origin}/{p}"),
102            LaneId::Origin {
103                origin,
104                producer: None,
105            } => origin.clone(),
106            LaneId::Foreign => "foreign (not a v1 key under this base)".into(),
107            LaneId::Unstamped => "unstamped (arrival axis only)".into(),
108        }
109    }
110}
111
112/// Who stamped a sample, as the wire spells it — the serialized form of
113/// [`crate::StampProvenance`], stamper identity carried beside it.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
115#[serde(rename_all = "snake_case")]
116pub enum Provenance {
117    SelfStamped,
118    Foreign,
119    Unattributable,
120}
121
122/// How many of a lane's stamped samples fell in each provenance class
123/// (#213): three populations, never one number.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
125pub struct ProvenanceCounts {
126    pub self_stamped: usize,
127    pub foreign: usize,
128    pub unattributable: usize,
129}
130
131/// One lane's account of itself.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
133pub struct LaneSummary {
134    pub lane: LaneId,
135    /// Samples placed on this report's axis in this lane.
136    pub samples: usize,
137    /// Arrival offset of the lane's first and last placed sample, µs.
138    pub first_t_us: u64,
139    pub last_t_us: u64,
140    /// Every stamper seen in the lane — one is the happened-before case,
141    /// more is the skew case, none is the unstamped lane.
142    pub stampers: BTreeSet<String>,
143    pub provenance: ProvenanceCounts,
144}
145
146/// The per-`SampleSource` sequence-number lane.
147///
148/// `Unavailable` is a structural state, never an empty vector: zenoh 1.9
149/// and 1.10 deliver no `SourceInfo` to a subscriber, so an empty lane would
150/// read as "nobody skipped a number" when the truth is "nobody numbered
151/// anything we could see" (O4).
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(tag = "state", rename_all = "snake_case")]
154pub enum SnLaneReport {
155    Unavailable { reason: &'static str },
156    Present { sources: usize, samples: usize },
157}
158
159/// The fixed reason the sequence-number lane is unavailable on this zenoh.
160pub const SN_UNAVAILABLE_REASON: &str = "zenoh 1.9/1.10 deliver no SourceInfo to subscribers \
161     (eclipse-zenoh/zenoh#2563); `tests/stamper.rs` pins it";
162
163/// Where the window came from — the same projection runs on both.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165#[serde(tag = "kind", rename_all = "snake_case")]
166pub enum TimelineSource {
167    Live,
168    Zrec { path: String },
169}
170
171/// Put or delete — a tombstone is a fact of its own (RFC 04 §1.2).
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum RowKind {
175    Put,
176    Delete,
177}
178
179/// What kind of break interrupted the sequence.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
181#[serde(rename_all = "snake_case")]
182pub enum BreakKind {
183    /// Samples the bounded observer missed while behind (O6).
184    Dropped,
185    /// Distinct samples a consumer merged into fewer.
186    Coalesced,
187}
188
189/// One line of the merged ordering — **every** variant carries `order_by`.
190#[derive(Debug, Clone, PartialEq, Serialize)]
191#[serde(tag = "row", rename_all = "snake_case")]
192pub enum TimelineEntry {
193    Sample {
194        order_by: OrderLabel,
195        /// Position in the merged ordering on `order_by`'s axis, 0-based.
196        pos: usize,
197        lane: LaneId,
198        key: String,
199        /// Arrival, µs since the window epoch — carried on both axes, so a
200        /// reorder is visible on the HLC listing as a non-monotonic `t_us`.
201        t_us: u64,
202        /// The HLC as `<ntp64>/<stamper>` — the `.zrec` spelling, so it
203        /// round-trips (`uhlc::Timestamp: FromStr`).
204        #[serde(skip_serializing_if = "Option::is_none")]
205        hlc: Option<String>,
206        #[serde(skip_serializing_if = "Option::is_none")]
207        stamped_by: Option<String>,
208        #[serde(skip_serializing_if = "Option::is_none")]
209        provenance: Option<Provenance>,
210        kind: RowKind,
211    },
212    Break {
213        order_by: OrderLabel,
214        pos: usize,
215        #[serde(skip_serializing_if = "Option::is_none")]
216        lane: Option<LaneId>,
217        kind: BreakKind,
218        n: u64,
219    },
220}
221
222/// The report `zenctl timeline` emits and a pane renders.
223#[derive(Debug, Clone, PartialEq, Serialize)]
224pub struct TimelineReport {
225    pub order_by: OrderLabel,
226    #[serde(flatten)]
227    pub axis: AxisLabel,
228    /// The selectors watched — coverage is exactly this list (O5).
229    pub scopes: Vec<String>,
230    /// The passive window, seconds. `None` for a `.zrec`: the file's span
231    /// is in its rows, and no window was asked for.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub window_s: Option<f64>,
234    pub source: TimelineSource,
235    pub lanes: Vec<LaneSummary>,
236    pub sn_lane: SnLaneReport,
237    /// Samples that carried no HLC and were therefore **not placed** on the
238    /// HLC axis. Always 0 on the arrival axis, where they have a lane.
239    #[serde(skip_serializing_if = "is_zero_usize")]
240    pub unstamped_excluded: usize,
241    /// Samples the observer missed while behind, totalled (O6).
242    pub dropped: u64,
243    #[serde(skip_serializing_if = "is_zero_u64")]
244    pub coalesced: u64,
245    /// Keys the bounded statistics table retired during the window (O6).
246    pub keys_evicted: u64,
247    pub rows: Vec<TimelineEntry>,
248}
249
250fn is_zero_usize(n: &usize) -> bool {
251    *n == 0
252}
253
254fn is_zero_u64(n: &u64) -> bool {
255    *n == 0
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use serde_json::json;
262
263    fn lane() -> LaneId {
264        LaneId::Origin {
265            origin: "h-3fa9c2d41b7e".into(),
266            producer: Some("sysinfo".into()),
267        }
268    }
269
270    /// The happened-before claim, flattened into the envelope beside
271    /// `order_by`; the per-row `order_by` on both row kinds.
272    #[test]
273    fn a_happens_before_report_is_pinned() {
274        let report = TimelineReport {
275            order_by: OrderLabel::Hlc,
276            axis: AxisLabel::Hlc {
277                claim: HlcClaim::HappensBefore {
278                    stamper: "33".into(),
279                },
280            },
281            scopes: vec!["v1/**".into()],
282            window_s: Some(10.0),
283            source: TimelineSource::Live,
284            lanes: vec![LaneSummary {
285                lane: lane(),
286                samples: 1,
287                first_t_us: 5,
288                last_t_us: 5,
289                stampers: ["33".to_string()].into_iter().collect(),
290                provenance: ProvenanceCounts {
291                    unattributable: 1,
292                    ..Default::default()
293                },
294            }],
295            sn_lane: SnLaneReport::Unavailable {
296                reason: SN_UNAVAILABLE_REASON,
297            },
298            unstamped_excluded: 2,
299            dropped: 0,
300            coalesced: 0,
301            keys_evicted: 0,
302            rows: vec![TimelineEntry::Sample {
303                order_by: OrderLabel::Hlc,
304                pos: 0,
305                lane: lane(),
306                key: "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu".into(),
307                t_us: 5,
308                hlc: Some("100/33".into()),
309                stamped_by: Some("33".into()),
310                provenance: Some(Provenance::Unattributable),
311                kind: RowKind::Put,
312            }],
313        };
314        assert_eq!(
315            serde_json::to_value(&report).unwrap(),
316            json!({
317                "order_by": "hlc",
318                "axis": "hlc",
319                "claim": "happens_before",
320                "stamper": "33",
321                "scopes": ["v1/**"],
322                "window_s": 10.0,
323                "source": {"kind": "live"},
324                "lanes": [{
325                    "lane": {"kind": "origin", "origin": "h-3fa9c2d41b7e", "producer": "sysinfo"},
326                    "samples": 1,
327                    "first_t_us": 5,
328                    "last_t_us": 5,
329                    "stampers": ["33"],
330                    "provenance": {"self_stamped": 0, "foreign": 0, "unattributable": 1}
331                }],
332                "sn_lane": {
333                    "state": "unavailable",
334                    "reason": "zenoh 1.9/1.10 deliver no SourceInfo to subscribers (eclipse-zenoh/zenoh#2563); `tests/stamper.rs` pins it"
335                },
336                "unstamped_excluded": 2,
337                "dropped": 0,
338                "keys_evicted": 0,
339                "rows": [{
340                    "row": "sample",
341                    "order_by": "hlc",
342                    "pos": 0,
343                    "lane": {"kind": "origin", "origin": "h-3fa9c2d41b7e", "producer": "sysinfo"},
344                    "key": "v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
345                    "t_us": 5,
346                    "hlc": "100/33",
347                    "stamped_by": "33",
348                    "provenance": "unattributable",
349                    "kind": "put"
350                }]
351            })
352        );
353    }
354
355    /// The skew claim, the arrival axis, the unstamped lane, a break row,
356    /// and the zero fields that vanish.
357    #[test]
358    fn an_arrival_report_with_a_break_is_pinned_and_the_skew_claim_spells_its_stampers() {
359        let report = TimelineReport {
360            order_by: OrderLabel::Arrival,
361            axis: AxisLabel::Arrival {
362                clock: ARRIVAL_CLOCK,
363            },
364            scopes: vec!["v1/**".into()],
365            window_s: None,
366            source: TimelineSource::Zrec {
367                path: "bus.zrec".into(),
368            },
369            lanes: vec![],
370            sn_lane: SnLaneReport::Present {
371                sources: 1,
372                samples: 3,
373            },
374            unstamped_excluded: 0,
375            dropped: 7,
376            coalesced: 0,
377            keys_evicted: 0,
378            rows: vec![
379                TimelineEntry::Sample {
380                    order_by: OrderLabel::Arrival,
381                    pos: 0,
382                    lane: LaneId::Unstamped,
383                    key: "plain/key".into(),
384                    t_us: 1,
385                    hlc: None,
386                    stamped_by: None,
387                    provenance: None,
388                    kind: RowKind::Delete,
389                },
390                TimelineEntry::Break {
391                    order_by: OrderLabel::Arrival,
392                    pos: 1,
393                    lane: None,
394                    kind: BreakKind::Dropped,
395                    n: 7,
396                },
397            ],
398        };
399        assert_eq!(
400            serde_json::to_value(&report).unwrap(),
401            json!({
402                "order_by": "arrival",
403                "axis": "arrival",
404                "clock": "observer monotonic, µs since window start",
405                "scopes": ["v1/**"],
406                "source": {"kind": "zrec", "path": "bus.zrec"},
407                "lanes": [],
408                "sn_lane": {"state": "present", "sources": 1, "samples": 3},
409                "dropped": 7,
410                "keys_evicted": 0,
411                "rows": [
412                    {
413                        "row": "sample",
414                        "order_by": "arrival",
415                        "pos": 0,
416                        "lane": {"kind": "unstamped"},
417                        "key": "plain/key",
418                        "t_us": 1,
419                        "kind": "delete"
420                    },
421                    {"row": "break", "order_by": "arrival", "pos": 1, "kind": "dropped", "n": 7}
422                ]
423            })
424        );
425        let skew = AxisLabel::Hlc {
426            claim: HlcClaim::SkewedWallClock {
427                stampers: ["33".to_string(), "44".to_string()].into_iter().collect(),
428            },
429        };
430        assert_eq!(
431            serde_json::to_value(&skew).unwrap(),
432            json!({"axis": "hlc", "claim": "skewed_wall_clock", "stampers": ["33", "44"]})
433        );
434        let empty = AxisLabel::Hlc {
435            claim: HlcClaim::NoStampedSamples,
436        };
437        assert_eq!(
438            serde_json::to_value(&empty).unwrap(),
439            json!({"axis": "hlc", "claim": "no_stamped_samples"})
440        );
441    }
442}