Skip to main content

zenkey_fleet/
seed.rs

1//! Correct state seeding — RFC 04 §3.2 as an engine helper (issue #42; the
2//! repo's old #20).
3//!
4//! The discipline is normative and subtle, and every state-showing pane
5//! would otherwise reimplement (or skip) it:
6//!
7//! - the subscriber is declared **before** any seed GET — "GET-then-subscribe
8//!   is forbidden: a transition published in the gap is silently dropped, and
9//!   a dropped delete is a resurrected key";
10//! - seed replies and live samples **merge per key by HLC timestamp** (LWW,
11//!   RFC 04 §1.2) — a stale seed must never overwrite a newer live sample;
12//! - the two seed paths differ in **coverage** and both are needed: the
13//!   history GET (`<selector>/@adv/**`) reaches *live* publishers' caches
14//!   (dies with the publisher), the plain GET on the selector is answered by
15//!   *router storages* — the crashed-producer case a UI must include. "What
16//!   no consumer may do: assume a plain GET reaches publisher caches, or
17//!   that a history query reaches storages."
18//!
19//! Both seed paths are queries **this module issues itself** rather than
20//! zenoh-ext's `history()` replay: the boundary ([`SeedItem::SeedComplete`])
21//! must not fire until every seed path has resolved, and only a query we own
22//! has an awaitable end. Coverage is **reported, never assumed**:
23//! [`SeedCoverage`] says which paths ran and what each yielded, and its
24//! zeros are observations, not verdicts.
25
26use std::collections::HashMap;
27use std::sync::{Arc, Mutex};
28use std::time::Duration;
29
30use anyhow::{Result, anyhow};
31use zenoh::Session;
32
33use crate::sub::SampleView;
34
35/// Which seed paths to run. Default: both — per-path opt-out exists because
36/// a deployment may *know* it has no storages (or no advanced publishers),
37/// not because skipping is free.
38#[derive(Debug, Clone, Copy)]
39pub struct SeedPolicy {
40    /// Query live publishers' `@adv` caches (`<selector>/@adv/**`) — the
41    /// same rung `fetch_value` uses; reaches only publishers that are alive.
42    pub history: bool,
43    /// GET the selector itself (answered by router storages — the only path
44    /// that still has state from *crashed* producers).
45    pub storage: bool,
46    /// Bound on each seed GET (they run concurrently, so this bounds the
47    /// whole seed phase too).
48    pub timeout: Duration,
49}
50
51impl Default for SeedPolicy {
52    fn default() -> Self {
53        SeedPolicy {
54            history: true,
55            storage: true,
56            timeout: Duration::from_secs(3),
57        }
58    }
59}
60
61/// What each seed path contributed.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
63pub struct SeedCoverage {
64    /// Replies from the `@adv` cache query (`None` = the history path was
65    /// disabled; `Some(0)` = ran and no cache answered — an observation,
66    /// not a verdict).
67    pub history_replies: Option<usize>,
68    /// Replies from the storage GET (same `None`/`Some(0)` reading).
69    pub storage_replies: Option<usize>,
70    /// Samples suppressed by the merge — not newer than what was already
71    /// seen for their key. The honesty counter: a seed that arrived late
72    /// and lost (or duplicated the other path) is counted, never silently
73    /// absorbed.
74    pub superseded: u64,
75}
76
77/// One delivery from a seeded subscription.
78#[derive(Debug, Clone)]
79pub enum SeedItem {
80    /// A sample that survived the per-key LWW merge — seed and live alike.
81    Sample(SampleView),
82    /// Both seed paths have resolved; everything after this is live-only.
83    /// Consumers that render "loading" state key off this boundary.
84    SeedComplete(SeedCoverage),
85}
86
87/// A subscription whose first phase is a correctly-merged seed.
88pub struct SeededSubscriber {
89    rx: tokio::sync::mpsc::UnboundedReceiver<SeedItem>,
90    // Held for lifetime: dropping undeclares.
91    _subscriber: zenoh::pubsub::Subscriber<()>,
92    task: tokio::task::JoinHandle<()>,
93}
94
95impl std::fmt::Debug for SeededSubscriber {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("SeededSubscriber").finish_non_exhaustive()
98    }
99}
100
101impl Drop for SeededSubscriber {
102    fn drop(&mut self) {
103        self.task.abort();
104    }
105}
106
107impl SeededSubscriber {
108    /// `None` when the subscription ended.
109    pub async fn recv(&mut self) -> Option<SeedItem> {
110        self.rx.recv().await
111    }
112}
113
114/// The shared LWW merge: one entry per key, latest HLC wins; stamped beats
115/// unstamped; unstamped-vs-unstamped passes through (nothing to compare — a
116/// deployment without timestamping has opted out of LWW, RFC 04 §4, and
117/// suppressing would be guessing).
118///
119/// `pub(crate)`: [`crate::Monitor::watch_seeded`] runs the same merge over
120/// its seed phase (issue #92) — one discipline, not two.
121pub(crate) struct Merge {
122    latest: Mutex<HashMap<String, Option<zenoh::time::Timestamp>>>,
123    superseded: std::sync::atomic::AtomicU64,
124}
125
126impl Merge {
127    pub(crate) fn new() -> Merge {
128        Merge {
129            latest: Mutex::new(HashMap::new()),
130            superseded: std::sync::atomic::AtomicU64::new(0),
131        }
132    }
133
134    pub(crate) fn superseded(&self) -> u64 {
135        self.superseded.load(std::sync::atomic::Ordering::Relaxed)
136    }
137
138    pub(crate) fn admit(&self, view: &SampleView) -> bool {
139        let mut latest = self.latest.lock().expect("merge lock");
140        let entry = latest.entry(view.key.clone()).or_insert(None);
141        let admit = match (&entry, &view.timestamp) {
142            (None, _) => true,
143            (Some(_), None) => false, // stamped state beats an unstamped echo
144            (Some(prev), Some(ts)) => ts > prev,
145        };
146        if admit {
147            if view.timestamp.is_some() || entry.is_none() {
148                *entry = view.timestamp;
149            }
150        } else {
151            self.superseded
152                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
153        }
154        admit
155    }
156}
157
158pub(crate) fn view_of(sample: &zenoh::sample::Sample) -> SampleView {
159    SampleView {
160        key: sample.key_expr().as_str().to_string(),
161        payload: sample.payload().clone(),
162        encoding: sample.encoding().to_string(),
163        kind: sample.kind(),
164        timestamp: sample.timestamp().copied(),
165        attachment: sample.attachment().cloned(),
166        priority: sample.priority(),
167        congestion_control: sample.congestion_control(),
168        reliability: sample.reliability(),
169        express: sample.express(),
170        source: sample.source_info().map(|si| crate::sub::SampleSource {
171            zid: si.source_id().zid(),
172            eid: si.source_id().eid(),
173            sn: si.source_sn(),
174        }),
175        // Arrival, not production: a seed reply is *received* now, however old
176        // the value it carries is. The HLC above is the only thing that speaks
177        // for when it was produced, and it is often absent.
178        received: std::time::Instant::now(),
179    }
180}
181
182/// Run one seed GET; every reply passes the merge; admitted samples go to
183/// `deliver`; returns the reply count.
184pub(crate) async fn seed_get(
185    session: &Session,
186    selector: &str,
187    timeout: Duration,
188    merge: &Merge,
189    mut deliver: impl FnMut(SampleView),
190) -> usize {
191    let mut n = 0usize;
192    if let Ok(replies) = session
193        .get(selector)
194        .target(zenoh::query::QueryTarget::All)
195        .consolidation(zenoh::query::ConsolidationMode::None)
196        // Cache replies arrive on the sample's own key, outside an
197        // `@adv`-suffixed selector — without Any they are dropped.
198        .accept_replies(zenoh::query::ReplyKeyExpr::Any)
199        .timeout(timeout)
200        .await
201    {
202        while let Ok(reply) = replies.recv_async().await {
203            let Ok(sample) = reply.result() else { continue };
204            n += 1;
205            let view = view_of(sample);
206            if merge.admit(&view) {
207                deliver(view);
208            }
209        }
210    }
211    n
212}
213
214/// The history-path selector for a data selector (the `fetch_value` cache
215/// rung, applied to a whole subtree).
216pub(crate) fn cache_selector(selector: &str) -> String {
217    format!("{selector}/@adv/**")
218}
219
220/// Subscribe with a correct seed phase (RFC 04 §3.2).
221///
222/// Order of operations is the contract: the subscriber is declared first;
223/// the seed GETs (history `@adv` + storage) run after, concurrently; every
224/// delivery — cached, stored, or live — passes one per-key LWW merge, so a
225/// transition published in the seed window lands exactly once and a stale
226/// seed cannot resurrect or regress a key. Deletes ride through as tombstone
227/// samples ([`zenoh::sample::SampleKind::Delete`]) subject to the same merge — never
228/// dropped. [`SeedItem::SeedComplete`] is sent only once **both** paths have
229/// resolved.
230pub async fn seed_subscribe(
231    session: &Session,
232    selector: &str,
233    policy: SeedPolicy,
234) -> Result<SeededSubscriber> {
235    let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<SeedItem>();
236    let merge = Arc::new(Merge::new());
237
238    // 1) The subscriber, FIRST — anything published from here on is caught.
239    let subscriber = session
240        .declare_subscriber(selector.to_string())
241        .callback({
242            let tx = tx.clone();
243            let merge = Arc::clone(&merge);
244            move |sample| {
245                let view = view_of(&sample);
246                if merge.admit(&view) {
247                    let _ = tx.send(SeedItem::Sample(view));
248                }
249            }
250        })
251        .await
252        .map_err(|e| anyhow!("seeded subscribe {selector}: {e}"))?;
253
254    // 2) The seed GETs, AFTER — and the completion boundary once both
255    //    (or their opt-outs) resolve.
256    let task = {
257        let session = session.clone();
258        let selector = selector.to_string();
259        let merge = Arc::clone(&merge);
260        tokio::spawn(async move {
261            let history = async {
262                if policy.history {
263                    let sel = cache_selector(&selector);
264                    Some(
265                        seed_get(&session, &sel, policy.timeout, &merge, |view| {
266                            let _ = tx.send(SeedItem::Sample(view));
267                        })
268                        .await,
269                    )
270                } else {
271                    None
272                }
273            };
274            let storage = async {
275                if policy.storage {
276                    Some(
277                        seed_get(&session, &selector, policy.timeout, &merge, |view| {
278                            let _ = tx.send(SeedItem::Sample(view));
279                        })
280                        .await,
281                    )
282                } else {
283                    None
284                }
285            };
286            let (history_replies, storage_replies) = tokio::join!(history, storage);
287            let _ = tx.send(SeedItem::SeedComplete(SeedCoverage {
288                history_replies,
289                storage_replies,
290                superseded: merge.superseded(),
291            }));
292        })
293    };
294
295    Ok(SeededSubscriber {
296        rx,
297        _subscriber: subscriber,
298        task,
299    })
300}