Skip to main content

zenkey_fleet/model/
snapshot.rs

1//! A snapshot's rows from values in hand (RFC 13 §4.4; #219): the per-key
2//! last-writer-wins fold, and the projections that turn one kept reply into
3//! the row's facets — holder, registration, verdict, stamper.
4//!
5//! Nothing here takes a session. [`crate::bus::query::snapshot_get`] brings
6//! the replies and the roster back; this module says what they mean, which
7//! is what lets the same projections be unit-tested against hand-built
8//! views and, later, run over a file.
9
10use std::collections::BTreeMap;
11
12use crate::bus::monitor::{SampleView, StampProvenance};
13use crate::model::facts::{KeyFacts, KeyShape, Registration};
14use crate::report::{AnsweredBy, Holder, RegistrationWire, StamperWire};
15
16/// The replier's zenoh id, when the reply named one.
17pub type Replier = Option<zenoh::config::ZenohId>;
18
19/// Fold every reply to one kept value per key, last-writer-wins, and count
20/// what lost.
21///
22/// **The rule is `pick_latest`'s** (the fetch ladder's,
23/// [`crate::fetch_value`]), restated: a newer HLC wins; a stamped value beats
24/// an unstamped one; between two unstamped values, or two carrying the same
25/// stamp, the first seen is kept. RFC 04 §1.2's reconciliation is by HLC,
26/// and an untimestamped reply cannot be reconciled at all — keeping the
27/// first is the honest tie-break, and the loser is counted in `superseded`
28/// rather than silently forgotten (O6 applied to a fold).
29pub fn fold_latest(
30    values: Vec<(SampleView, Replier)>,
31) -> (BTreeMap<String, (SampleView, Replier)>, u64) {
32    let mut kept: BTreeMap<String, (SampleView, Replier)> = BTreeMap::new();
33    let mut superseded = 0u64;
34    for (view, replier) in values {
35        match kept.get(&view.key) {
36            None => {
37                kept.insert(view.key.clone(), (view, replier));
38            }
39            Some((cur, _)) => {
40                let newer = match (cur.timestamp, view.timestamp) {
41                    (Some(a), Some(b)) => b > a,
42                    (None, Some(_)) => true,
43                    _ => false,
44                };
45                superseded += 1;
46                if newer {
47                    kept.insert(view.key.clone(), (view, replier));
48                }
49            }
50        }
51    }
52    (kept, superseded)
53}
54
55/// Who holds a value — evidence, not inference (RFC 13 §4.4).
56///
57/// `roster` is the liveliness roster as [`crate::roster`] returns it
58/// (origin → producers), or `None` when it was not asked. The order of the
59/// tests is the order of the questions: was the roster asked at all; does
60/// the key name an origin; did that origin hold a token; and, only for a
61/// live origin, whether the replier was the stamping entity.
62pub fn holder_of(
63    base: &str,
64    key: &str,
65    view: &SampleView,
66    replier: Replier,
67    roster: Option<&BTreeMap<String, Vec<String>>>,
68) -> Holder {
69    let Some(roster) = roster else {
70        return Holder::Unattributed {
71            reason: "roster not asked".into(),
72        };
73    };
74    let facts = KeyFacts::project(base, key);
75    let origin = match &facts.shape {
76        KeyShape::V1(f) => f.origin.clone(),
77        KeyShape::NotUnderBase => {
78            return Holder::Unattributed {
79                reason: "the key is not under the stated base, so it names no origin here".into(),
80            };
81        }
82        KeyShape::Unparsed { reason } => {
83            return Holder::Unattributed {
84                reason: format!("the key names no origin: {reason}"),
85            };
86        }
87    };
88    if !roster.contains_key(&origin) {
89        return Holder::StorageOnly { origin };
90    }
91    Holder::Live {
92        origin,
93        answered_by: answered_by(view, replier),
94    }
95}
96
97/// Whether the replier was the stamping entity: both ids known and equal is
98/// `Stamper`, both known and different is `Other`, anything less is
99/// `Unknown` (O4 — a missing id is not a mismatch).
100fn answered_by(view: &SampleView, replier: Replier) -> AnsweredBy {
101    let stamper = match view.stamped_by {
102        Some(StampProvenance::SelfStamped) => {
103            view.source.map(|s| zenoh::time::TimestampId::from(s.zid))
104        }
105        Some(StampProvenance::Foreign { stamper })
106        | Some(StampProvenance::Unattributable { stamper }) => Some(stamper),
107        None => None,
108    };
109    match (stamper, replier) {
110        (Some(s), Some(r)) if s == zenoh::time::TimestampId::from(r) => AnsweredBy::Stamper,
111        (Some(_), Some(_)) => AnsweredBy::Other,
112        _ => AnsweredBy::Unknown,
113    }
114}
115
116/// O2's rung for a projected (and, when a registry was loaded, resolved)
117/// key. [`Registration::Unknown`] is `registry_not_loaded`, not
118/// `unregistered` — "not asked" is not "answered no" (O4).
119pub fn registration_of(facts: &KeyFacts) -> RegistrationWire {
120    match &facts.shape {
121        KeyShape::NotUnderBase => RegistrationWire::NotUnderBase,
122        KeyShape::Unparsed { .. } => RegistrationWire::NotV1,
123        KeyShape::V1(_) => match &facts.registration {
124            Registration::Unknown => RegistrationWire::RegistryNotLoaded,
125            Registration::NoSliceForProducer => RegistrationWire::NoSliceForProducer,
126            Registration::Unregistered => RegistrationWire::Unregistered,
127            Registration::Registered(_) => RegistrationWire::Registered,
128            Registration::NotApplicable => RegistrationWire::NotADataClass,
129        },
130    }
131}
132
133/// The three-valued verdict on the wire, with the not-validated reason as a
134/// stable token rather than its prose.
135#[cfg(feature = "decode")]
136pub fn verdict_of(verdict: &zenkey::schema::validate::Verdict) -> crate::report::VerdictWire {
137    use crate::report::VerdictWire;
138    use zenkey::schema::validate::{NotValidated, Verdict};
139    match verdict {
140        Verdict::Valid => VerdictWire::Valid,
141        Verdict::Invalid(violations) => VerdictWire::Invalid {
142            violations: violations.clone(),
143        },
144        Verdict::NotValidated(reason) => VerdictWire::NotValidated {
145            reason: match reason {
146                NotValidated::NoSchema => "no_schema",
147                NotValidated::NoRegistry => "no_registry",
148                NotValidated::FeatureOff => "feature_off",
149                NotValidated::KindUnsupported => "kind_unsupported",
150                NotValidated::Undecodable => "undecodable",
151                NotValidated::BadSchema => "bad_schema",
152            }
153            .into(),
154        },
155    }
156}
157
158/// O7's classification of a stamp, on the wire.
159pub fn stamper_of(provenance: &StampProvenance) -> StamperWire {
160    match provenance {
161        StampProvenance::SelfStamped => StamperWire::SelfStamped,
162        StampProvenance::Foreign { stamper } => StamperWire::Foreign {
163            id: stamper.to_string(),
164        },
165        StampProvenance::Unattributable { stamper } => StamperWire::Unattributable {
166            id: stamper.to_string(),
167        },
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::time::{Duration, Instant};
175
176    fn stamp(secs: u64, id: zenoh::time::TimestampId) -> zenoh::time::Timestamp {
177        zenoh::time::Timestamp::new(zenoh::time::NTP64::from(Duration::from_secs(secs)), id)
178    }
179
180    fn view(key: &str, payload: &[u8], timestamp: Option<zenoh::time::Timestamp>) -> SampleView {
181        SampleView {
182            key: key.to_string(),
183            payload: zenoh::bytes::ZBytes::from(payload.to_vec()),
184            encoding: String::new(),
185            kind: zenoh::sample::SampleKind::Put,
186            stamped_by: timestamp.map(|t| StampProvenance::Unattributable {
187                stamper: *t.get_id(),
188            }),
189            timestamp,
190            attachment: None,
191            priority: zenoh::qos::Priority::DEFAULT,
192            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
193            reliability: zenoh::qos::Reliability::DEFAULT,
194            express: false,
195            source: None,
196            received: Instant::now(),
197        }
198    }
199
200    const KEY: &str = "v1/h-3fa9c2d41b7e/state/sysinfo/health";
201
202    /// The fold keeps the newest stamp, lets a stamp beat no stamp, keeps
203    /// the first of two unstamped — and counts every loser.
204    #[test]
205    fn the_fold_is_last_writer_wins_and_counts_what_lost() {
206        let id = zenoh::time::TimestampId::rand();
207        let (kept, superseded) = fold_latest(vec![
208            (view(KEY, b"old", Some(stamp(10, id))), None),
209            (view(KEY, b"new", Some(stamp(20, id))), None),
210            (view(KEY, b"stale", Some(stamp(5, id))), None),
211            (view(KEY, b"unstamped", None), None),
212        ]);
213        assert_eq!(superseded, 3);
214        assert_eq!(kept[KEY].0.payload.to_bytes().as_ref(), b"new");
215
216        let (kept, superseded) = fold_latest(vec![
217            (view(KEY, b"first", None), None),
218            (view(KEY, b"second", None), None),
219        ]);
220        assert_eq!(superseded, 1);
221        assert_eq!(
222            kept[KEY].0.payload.to_bytes().as_ref(),
223            b"first",
224            "two unstamped values cannot be reconciled; the first seen stands"
225        );
226
227        let (kept, superseded) = fold_latest(vec![
228            (view(KEY, b"unstamped", None), None),
229            (view(KEY, b"stamped", Some(stamp(1, id))), None),
230        ]);
231        assert_eq!(superseded, 1);
232        assert_eq!(kept[KEY].0.payload.to_bytes().as_ref(), b"stamped");
233    }
234
235    /// The holder ladder, every rung: roster not asked, no origin, origin
236    /// not alive, alive with the three replier answers.
237    #[test]
238    fn the_holder_is_evidence_at_every_rung() {
239        let v = view(KEY, b"{}", None);
240        assert_eq!(
241            holder_of("", KEY, &v, None, None),
242            Holder::Unattributed {
243                reason: "roster not asked".into()
244            }
245        );
246        let roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
247        assert!(matches!(
248            holder_of("", "not/a/v1/key", &v, None, Some(&roster)),
249            Holder::Unattributed { .. }
250        ));
251        assert!(matches!(
252            holder_of("acme", KEY, &v, None, Some(&roster)),
253            Holder::Unattributed { reason } if reason.contains("not under the stated base")
254        ));
255        assert_eq!(
256            holder_of("", KEY, &v, None, Some(&roster)),
257            Holder::StorageOnly {
258                origin: "h-3fa9c2d41b7e".into()
259            }
260        );
261
262        let mut roster = roster;
263        roster.insert("h-3fa9c2d41b7e".into(), vec!["sysinfo".into()]);
264        assert_eq!(
265            holder_of("", KEY, &v, None, Some(&roster)),
266            Holder::Live {
267                origin: "h-3fa9c2d41b7e".into(),
268                answered_by: AnsweredBy::Unknown,
269            },
270            "unstamped and no replier: nothing to compare (O4)"
271        );
272
273        let stamper = zenoh::config::ZenohId::default();
274        let stamped = view(KEY, b"{}", Some(stamp(1, stamper.into())));
275        assert_eq!(
276            holder_of("", KEY, &stamped, Some(stamper), Some(&roster)),
277            Holder::Live {
278                origin: "h-3fa9c2d41b7e".into(),
279                answered_by: AnsweredBy::Stamper,
280            }
281        );
282        let other = view(KEY, b"{}", Some(stamp(1, zenoh::time::TimestampId::rand())));
283        assert_eq!(
284            holder_of("", KEY, &other, Some(stamper), Some(&roster)),
285            Holder::Live {
286                origin: "h-3fa9c2d41b7e".into(),
287                answered_by: AnsweredBy::Other,
288            }
289        );
290        assert_eq!(
291            holder_of("", KEY, &stamped, None, Some(&roster)),
292            Holder::Live {
293                origin: "h-3fa9c2d41b7e".into(),
294                answered_by: AnsweredBy::Unknown,
295            },
296            "a stamp with no replier id is unknown, not other"
297        );
298    }
299
300    /// `Registration::Unknown` is *registry not loaded*, never
301    /// *unregistered* (O4); the two non-v1 shapes have their own rungs.
302    #[test]
303    fn registration_keeps_not_loaded_apart_from_unregistered() {
304        assert_eq!(
305            registration_of(&KeyFacts::project("", KEY)),
306            RegistrationWire::RegistryNotLoaded
307        );
308        assert_eq!(
309            registration_of(&KeyFacts::project("acme", KEY)),
310            RegistrationWire::NotUnderBase
311        );
312        assert_eq!(
313            registration_of(&KeyFacts::project("", "plain/zenoh/key")),
314            RegistrationWire::NotV1
315        );
316        assert_eq!(
317            registration_of(&KeyFacts::project(
318                "",
319                "v1/h-3fa9c2d41b7e/@rpc/sysinfo/ping"
320            )),
321            RegistrationWire::NotADataClass
322        );
323    }
324}