Skip to main content

zenkey_fleet/bus/
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 crate::Result;
31use zenoh::Session;
32
33use crate::bus::monitor::SampleView;
34use crate::report::SeedCoverage;
35
36/// Which seed paths to run. Default: both — per-path opt-out exists because
37/// a deployment may *know* it has no storages (or no advanced publishers),
38/// not because skipping is free.
39#[derive(Debug, Clone, Copy)]
40pub struct SeedPolicy {
41    /// Query live publishers' `@adv` caches (`<selector>/@adv/**`) — the
42    /// same rung `fetch_value` uses; reaches only publishers that are alive.
43    pub history: bool,
44    /// GET the selector itself (answered by router storages — the only path
45    /// that still has state from *crashed* producers).
46    pub storage: bool,
47    /// Bound on each seed GET (they run concurrently, so this bounds the
48    /// whole seed phase too).
49    pub timeout: Duration,
50}
51
52impl Default for SeedPolicy {
53    fn default() -> Self {
54        SeedPolicy {
55            history: true,
56            storage: true,
57            timeout: Duration::from_secs(3),
58        }
59    }
60}
61
62/// One delivery from a seeded subscription.
63#[derive(Debug, Clone)]
64pub enum SeedItem {
65    /// A sample that survived the per-key LWW merge — seed and live alike.
66    Sample(SampleView),
67    /// How many samples this consumer just missed: the delivery channel is
68    /// bounded, and a receiver that fell behind is told the count rather
69    /// than handed a silently thinned stream (RFC 09 §5.1 O6 — the mirror
70    /// of [`crate::StreamItem::Dropped`]). Merge suppressions are *not* in
71    /// this number; they ride [`SeedCoverage::superseded`].
72    Dropped(u64),
73    /// Both seed paths have resolved; everything after this is live-only.
74    /// Consumers that render "loading" state key off this boundary. Never
75    /// dropped: the boundary is sent with backpressure, not best-effort.
76    SeedComplete(SeedCoverage),
77}
78
79/// The delivery channel's bound — the same figure as the monitor's default
80/// broadcast capacity ([`crate::MonitorSpec::default`]), for the same
81/// reason: bound it to what a consumer can drain, and surface the lag.
82const SEED_CAPACITY: usize = 1024;
83
84/// The sending half of the bounded seed channel: samples are best-effort
85/// (`try_send`) with every refusal counted, so a slow consumer costs a
86/// stated drop, never unbounded memory (deep-review D5).
87#[derive(Clone)]
88struct SeedSender {
89    tx: tokio::sync::mpsc::Sender<SeedItem>,
90    dropped: Arc<std::sync::atomic::AtomicU64>,
91}
92
93impl SeedSender {
94    fn send_sample(&self, view: SampleView) {
95        use tokio::sync::mpsc::error::TrySendError;
96        match self.tx.try_send(SeedItem::Sample(view)) {
97            Ok(()) => {}
98            // The bound refused it: count the drop (O6).
99            Err(TrySendError::Full(_)) => {
100                self.dropped
101                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
102            }
103            // No receiver any more — nothing is observing, nothing to count.
104            Err(TrySendError::Closed(_)) => {}
105        }
106    }
107
108    /// The boundary, with backpressure: waits for room rather than dropping
109    /// — a lost `SeedComplete` would leave every consumer "loading" forever.
110    async fn send_boundary(&self, coverage: SeedCoverage) {
111        let _ = self.tx.send(SeedItem::SeedComplete(coverage)).await;
112    }
113}
114
115/// The receiving half: surfaces the accumulated drop count as a
116/// [`SeedItem::Dropped`] before the next item, like the monitor's lagging
117/// broadcast receiver does.
118struct SeedReceiver {
119    rx: tokio::sync::mpsc::Receiver<SeedItem>,
120    dropped: Arc<std::sync::atomic::AtomicU64>,
121}
122
123impl SeedReceiver {
124    async fn recv(&mut self) -> Option<SeedItem> {
125        let missed = self.dropped.swap(0, std::sync::atomic::Ordering::Relaxed);
126        if missed > 0 {
127            return Some(SeedItem::Dropped(missed));
128        }
129        self.rx.recv().await
130    }
131}
132
133/// The bounded seed channel, drop-accounted on both halves.
134fn seed_channel(capacity: usize) -> (SeedSender, SeedReceiver) {
135    let (tx, rx) = tokio::sync::mpsc::channel::<SeedItem>(capacity);
136    let dropped = Arc::new(std::sync::atomic::AtomicU64::new(0));
137    (
138        SeedSender {
139            tx,
140            dropped: Arc::clone(&dropped),
141        },
142        SeedReceiver { rx, dropped },
143    )
144}
145
146/// A subscription whose first phase is a correctly-merged seed.
147pub struct SeededSubscriber {
148    rx: SeedReceiver,
149    // Held for lifetime: dropping undeclares.
150    _subscriber: zenoh::pubsub::Subscriber<()>,
151    task: tokio::task::JoinHandle<()>,
152}
153
154impl std::fmt::Debug for SeededSubscriber {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("SeededSubscriber").finish_non_exhaustive()
157    }
158}
159
160impl Drop for SeededSubscriber {
161    fn drop(&mut self) {
162        self.task.abort();
163    }
164}
165
166impl SeededSubscriber {
167    /// `None` when the subscription ended. A consumer that fell behind the
168    /// bounded channel is handed [`SeedItem::Dropped`] with the count of
169    /// samples it missed before the stream resumes (O6).
170    pub async fn recv(&mut self) -> Option<SeedItem> {
171        self.rx.recv().await
172    }
173}
174
175/// The shared LWW merge: one entry per key, latest HLC wins; stamped beats
176/// unstamped; unstamped-vs-unstamped passes through (nothing to compare — a
177/// deployment without timestamping has opted out of LWW, RFC 04 §4, and
178/// suppressing would be guessing).
179///
180/// `pub(crate)`: [`crate::Monitor::watch_seeded`] runs the same merge over
181/// its seed phase (issue #92) — one discipline, not two.
182pub(crate) struct Merge {
183    latest: Mutex<HashMap<String, Option<zenoh::time::Timestamp>>>,
184    superseded: std::sync::atomic::AtomicU64,
185}
186
187impl Merge {
188    pub(crate) fn new() -> Merge {
189        Merge {
190            latest: Mutex::new(HashMap::new()),
191            superseded: std::sync::atomic::AtomicU64::new(0),
192        }
193    }
194
195    pub(crate) fn superseded(&self) -> u64 {
196        self.superseded.load(std::sync::atomic::Ordering::Relaxed)
197    }
198
199    pub(crate) fn admit(&self, view: &SampleView) -> bool {
200        let mut latest = self.latest.lock().expect("merge lock");
201        let entry = latest.entry(view.key.clone()).or_insert(None);
202        let admit = match (&entry, &view.timestamp) {
203            (None, _) => true,
204            (Some(_), None) => false, // stamped state beats an unstamped echo
205            (Some(prev), Some(ts)) => ts > prev,
206        };
207        if admit {
208            if view.timestamp.is_some() || entry.is_none() {
209                *entry = view.timestamp;
210            }
211        } else {
212            self.superseded
213                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
214        }
215        admit
216    }
217}
218
219pub(crate) fn view_of(sample: &zenoh::sample::Sample) -> SampleView {
220    SampleView::of(sample)
221}
222
223/// Run one seed GET; every reply passes the merge; admitted samples go to
224/// `deliver`; returns the reply count.
225pub(crate) async fn seed_get(
226    session: &Session,
227    selector: &str,
228    timeout: Duration,
229    merge: &Merge,
230    mut deliver: impl FnMut(SampleView),
231) -> usize {
232    let mut n = 0usize;
233
234    // `accept_any`: cache replies arrive on the sample's own key, outside an
235    // `@adv`-suffixed selector — without it they are dropped.
236    let opts = crate::bus::query::GetOpts::new(timeout).accept_any();
237    if let Ok(replies) = crate::bus::query::disciplined_get(session, selector, &opts).await {
238        while let Ok(reply) = replies.recv_async().await {
239            let Ok(sample) = reply.result() else { continue };
240            n += 1;
241            let view = view_of(sample);
242            if merge.admit(&view) {
243                deliver(view);
244            }
245        }
246    }
247    n
248}
249
250/// The history-path selector for a data selector (the `fetch_value` cache
251/// rung, applied to a whole subtree).
252pub(crate) fn cache_selector(selector: &str) -> String {
253    format!("{selector}/@adv/**")
254}
255
256/// Subscribe with a correct seed phase (RFC 04 §3.2).
257///
258/// Order of operations is the contract: the subscriber is declared first;
259/// the seed GETs (history `@adv` + storage) run after, concurrently; every
260/// delivery — cached, stored, or live — passes one per-key LWW merge, so a
261/// transition published in the seed window lands exactly once and a stale
262/// seed cannot resurrect or regress a key. Deletes ride through as tombstone
263/// samples ([`zenoh::sample::SampleKind::Delete`]) subject to the same merge — never
264/// dropped. [`SeedItem::SeedComplete`] is sent only once **both** paths have
265/// resolved.
266pub async fn seed_subscribe(
267    session: &Session,
268    selector: &str,
269    policy: SeedPolicy,
270) -> Result<SeededSubscriber> {
271    let (tx, rx) = seed_channel(SEED_CAPACITY);
272
273    let merge = Arc::new(Merge::new());
274
275    // 1) The subscriber, FIRST — anything published from here on is caught.
276    let subscriber = crate::bus::teardown::declared(
277        "seeded subscribe",
278        selector,
279        session.declare_subscriber(selector.to_string()).callback({
280            let tx = tx.clone();
281            let merge = Arc::clone(&merge);
282            move |sample| {
283                let view = view_of(&sample);
284                if merge.admit(&view) {
285                    tx.send_sample(view);
286                }
287            }
288        }),
289    )
290    .await?;
291
292    // 2) The seed GETs, AFTER — and the completion boundary once both
293    //    (or their opt-outs) resolve.
294    let task = {
295        let session = session.clone();
296        let selector = selector.to_string();
297        let merge = Arc::clone(&merge);
298        tokio::spawn(async move {
299            let history = async {
300                if policy.history {
301                    let sel = cache_selector(&selector);
302                    Some(
303                        seed_get(&session, &sel, policy.timeout, &merge, |view| {
304                            tx.send_sample(view);
305                        })
306                        .await,
307                    )
308                } else {
309                    None
310                }
311            };
312            let storage = async {
313                if policy.storage {
314                    Some(
315                        seed_get(&session, &selector, policy.timeout, &merge, |view| {
316                            tx.send_sample(view);
317                        })
318                        .await,
319                    )
320                } else {
321                    None
322                }
323            };
324            let (history_replies, storage_replies) = tokio::join!(history, storage);
325            tx.send_boundary(SeedCoverage {
326                history_replies,
327                storage_replies,
328                superseded: merge.superseded(),
329            })
330            .await;
331        })
332    };
333
334    Ok(SeededSubscriber {
335        rx,
336        _subscriber: subscriber,
337        task,
338    })
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn view(key: &str) -> SampleView {
346        SampleView {
347            key: key.to_string(),
348            payload: zenoh::bytes::ZBytes::from(vec![0u8; 1]),
349            encoding: "zenoh/bytes".to_string(),
350            kind: zenoh::sample::SampleKind::Put,
351            timestamp: None,
352            stamped_by: None,
353            attachment: None,
354            priority: zenoh::qos::Priority::DEFAULT,
355            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
356            reliability: zenoh::qos::Reliability::DEFAULT,
357            express: false,
358            source: None,
359            received: std::time::Instant::now(),
360        }
361    }
362
363    /// Deep-review D5: the seed channel is bounded, and what the bound
364    /// refuses is counted and surfaced as [`SeedItem::Dropped`] before the
365    /// stream resumes — the O6 honesty every other delivery surface in this
366    /// crate already has. The boundary rides with backpressure and is never
367    /// among the dropped.
368    #[tokio::test]
369    async fn a_slow_seed_consumer_is_told_what_it_missed() {
370        let (tx, mut rx) = seed_channel(4);
371        for i in 0..10 {
372            tx.send_sample(view(&format!("k/{i}")));
373        }
374        // 4 fit; 6 were refused by the bound.
375        let Some(SeedItem::Dropped(n)) = rx.recv().await else {
376            panic!("expected the dropped count first");
377        };
378        assert_eq!(n, 6, "every refusal is counted, exactly once");
379        for i in 0..4 {
380            let Some(SeedItem::Sample(v)) = rx.recv().await else {
381                panic!("expected the retained samples");
382            };
383            assert_eq!(v.key, format!("k/{i}"), "the retained head is in order");
384        }
385        // The count was handed over, not double-reported.
386        tx.send_sample(view("k/late"));
387        let Some(SeedItem::Sample(v)) = rx.recv().await else {
388            panic!("the stream resumes");
389        };
390        assert_eq!(v.key, "k/late");
391
392        // The boundary waits for room instead of dropping (a lost boundary
393        // is a consumer stuck on "loading" forever).
394        for i in 0..4 {
395            tx.send_sample(view(&format!("b/{i}")));
396        }
397        let boundary = tokio::spawn(async move {
398            tx.send_boundary(SeedCoverage {
399                history_replies: Some(0),
400                storage_replies: Some(0),
401                superseded: 0,
402            })
403            .await;
404        });
405        let mut seen_boundary = false;
406        while let Some(item) = rx.recv().await {
407            if let SeedItem::SeedComplete(c) = item {
408                assert_eq!(c.superseded, 0);
409                seen_boundary = true;
410                break;
411            }
412        }
413        assert!(seen_boundary, "the boundary is never among the dropped");
414        boundary.await.expect("boundary task");
415    }
416}