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 subscription **is** a stream (#343) — the seed phase and the live
176/// phase are one sequence, which is the whole point of the type.
177///
178/// A direct impl rather than an adapter, because the channel underneath is an
179/// `mpsc::Receiver` with a real `poll_recv`: no boxing, no self-reference, and
180/// the [`SeedItem::Dropped`] preamble is the same one [`recv`](
181/// SeededSubscriber::recv) applies, so a consumer that switched from `recv`
182/// to `next` sees identical items in an identical order.
183impl futures_core::Stream for SeededSubscriber {
184    type Item = SeedItem;
185
186    fn poll_next(
187        self: std::pin::Pin<&mut Self>,
188        cx: &mut std::task::Context<'_>,
189    ) -> std::task::Poll<Option<SeedItem>> {
190        // Every field is `Unpin`, so the projection needs no unsafe — worth
191        // stating because this type has a `Drop` impl, which is what stops
192        // the derive from being available.
193        let this = self.get_mut();
194        // What the bounded channel refused, before what it kept (O6).
195        let missed = this
196            .rx
197            .dropped
198            .swap(0, std::sync::atomic::Ordering::Relaxed);
199        if missed > 0 {
200            return std::task::Poll::Ready(Some(SeedItem::Dropped(missed)));
201        }
202        this.rx.rx.poll_recv(cx)
203    }
204}
205
206/// The shared LWW merge: one entry per key, latest HLC wins; stamped beats
207/// unstamped; unstamped-vs-unstamped passes through (nothing to compare — a
208/// deployment without timestamping has opted out of LWW, RFC 04 §4, and
209/// suppressing would be guessing).
210///
211/// `pub(crate)`: [`crate::Monitor::watch_seeded`] runs the same merge over
212/// its seed phase (issue #92) — one discipline, not two.
213pub(crate) struct Merge {
214    latest: Mutex<HashMap<String, Option<zenoh::time::Timestamp>>>,
215    superseded: std::sync::atomic::AtomicU64,
216}
217
218impl Merge {
219    pub(crate) fn new() -> Merge {
220        Merge {
221            latest: Mutex::new(HashMap::new()),
222            superseded: std::sync::atomic::AtomicU64::new(0),
223        }
224    }
225
226    pub(crate) fn superseded(&self) -> u64 {
227        self.superseded.load(std::sync::atomic::Ordering::Relaxed)
228    }
229
230    pub(crate) fn admit(&self, view: &SampleView) -> bool {
231        let mut latest = self.latest.lock().expect("merge lock");
232        let entry = latest.entry(view.key.clone()).or_insert(None);
233        let admit = match (&entry, &view.timestamp) {
234            (None, _) => true,
235            (Some(_), None) => false, // stamped state beats an unstamped echo
236            (Some(prev), Some(ts)) => ts > prev,
237        };
238        if admit {
239            if view.timestamp.is_some() || entry.is_none() {
240                *entry = view.timestamp;
241            }
242        } else {
243            self.superseded
244                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
245        }
246        admit
247    }
248}
249
250pub(crate) fn view_of(sample: &zenoh::sample::Sample) -> SampleView {
251    SampleView::of(sample)
252}
253
254/// Run one seed GET; every reply passes the merge; admitted samples go to
255/// `deliver`; returns the reply count.
256pub(crate) async fn seed_get(
257    session: &Session,
258    selector: &str,
259    timeout: Duration,
260    merge: &Merge,
261    mut deliver: impl FnMut(SampleView),
262) -> usize {
263    let mut n = 0usize;
264
265    // `accept_any`: cache replies arrive on the sample's own key, outside an
266    // `@adv`-suffixed selector — without it they are dropped.
267    let opts = crate::bus::query::GetOpts::new(timeout).accept_any();
268    if let Ok(replies) = crate::bus::query::disciplined_get(session, selector, &opts).await {
269        while let Ok(reply) = replies.recv_async().await {
270            let Ok(sample) = reply.result() else { continue };
271            n += 1;
272            let view = view_of(sample);
273            if merge.admit(&view) {
274                deliver(view);
275            }
276        }
277    }
278    n
279}
280
281/// The history-path selector for a data selector (the `fetch_value` cache
282/// rung, applied to a whole subtree).
283pub(crate) fn cache_selector(selector: &str) -> String {
284    format!("{selector}/@adv/**")
285}
286
287/// Subscribe with a correct seed phase (RFC 04 §3.2).
288///
289/// Order of operations is the contract: the subscriber is declared first;
290/// the seed GETs (history `@adv` + storage) run after, concurrently; every
291/// delivery — cached, stored, or live — passes one per-key LWW merge, so a
292/// transition published in the seed window lands exactly once and a stale
293/// seed cannot resurrect or regress a key. Deletes ride through as tombstone
294/// samples ([`zenoh::sample::SampleKind::Delete`]) subject to the same merge — never
295/// dropped. [`SeedItem::SeedComplete`] is sent only once **both** paths have
296/// resolved.
297pub async fn seed_subscribe(
298    session: &Session,
299    selector: &str,
300    policy: SeedPolicy,
301) -> Result<SeededSubscriber> {
302    let (tx, rx) = seed_channel(SEED_CAPACITY);
303
304    let merge = Arc::new(Merge::new());
305
306    // 1) The subscriber, FIRST — anything published from here on is caught.
307    let subscriber = crate::bus::teardown::declared(
308        "seeded subscribe",
309        selector,
310        session.declare_subscriber(selector.to_string()).callback({
311            let tx = tx.clone();
312            let merge = Arc::clone(&merge);
313            move |sample| {
314                let view = view_of(&sample);
315                if merge.admit(&view) {
316                    tx.send_sample(view);
317                }
318            }
319        }),
320    )
321    .await?;
322
323    // 2) The seed GETs, AFTER — and the completion boundary once both
324    //    (or their opt-outs) resolve.
325    let task = {
326        let session = session.clone();
327        let selector = selector.to_string();
328        let merge = Arc::clone(&merge);
329        tokio::spawn(async move {
330            let history = async {
331                if policy.history {
332                    let sel = cache_selector(&selector);
333                    Some(
334                        seed_get(&session, &sel, policy.timeout, &merge, |view| {
335                            tx.send_sample(view);
336                        })
337                        .await,
338                    )
339                } else {
340                    None
341                }
342            };
343            let storage = async {
344                if policy.storage {
345                    Some(
346                        seed_get(&session, &selector, policy.timeout, &merge, |view| {
347                            tx.send_sample(view);
348                        })
349                        .await,
350                    )
351                } else {
352                    None
353                }
354            };
355            let (history_replies, storage_replies) = tokio::join!(history, storage);
356            tx.send_boundary(SeedCoverage {
357                history_replies,
358                storage_replies,
359                superseded: merge.superseded(),
360            })
361            .await;
362        })
363    };
364
365    Ok(SeededSubscriber {
366        rx,
367        _subscriber: subscriber,
368        task,
369    })
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn view(key: &str) -> SampleView {
377        SampleView {
378            key: key.to_string(),
379            payload: zenoh::bytes::ZBytes::from(vec![0u8; 1]),
380            encoding: "zenoh/bytes".to_string(),
381            kind: zenoh::sample::SampleKind::Put,
382            timestamp: None,
383            stamped_by: None,
384            attachment: None,
385            priority: zenoh::qos::Priority::DEFAULT,
386            congestion_control: zenoh::qos::CongestionControl::DEFAULT,
387            reliability: zenoh::qos::Reliability::DEFAULT,
388            express: false,
389            source: None,
390            received: std::time::Instant::now(),
391        }
392    }
393
394    /// Deep-review D5: the seed channel is bounded, and what the bound
395    /// refuses is counted and surfaced as [`SeedItem::Dropped`] before the
396    /// stream resumes — the O6 honesty every other delivery surface in this
397    /// crate already has. The boundary rides with backpressure and is never
398    /// among the dropped.
399    #[tokio::test]
400    async fn a_slow_seed_consumer_is_told_what_it_missed() {
401        let (tx, mut rx) = seed_channel(4);
402        for i in 0..10 {
403            tx.send_sample(view(&format!("k/{i}")));
404        }
405        // 4 fit; 6 were refused by the bound.
406        let Some(SeedItem::Dropped(n)) = rx.recv().await else {
407            panic!("expected the dropped count first");
408        };
409        assert_eq!(n, 6, "every refusal is counted, exactly once");
410        for i in 0..4 {
411            let Some(SeedItem::Sample(v)) = rx.recv().await else {
412                panic!("expected the retained samples");
413            };
414            assert_eq!(v.key, format!("k/{i}"), "the retained head is in order");
415        }
416        // The count was handed over, not double-reported.
417        tx.send_sample(view("k/late"));
418        let Some(SeedItem::Sample(v)) = rx.recv().await else {
419            panic!("the stream resumes");
420        };
421        assert_eq!(v.key, "k/late");
422
423        // The boundary waits for room instead of dropping (a lost boundary
424        // is a consumer stuck on "loading" forever).
425        for i in 0..4 {
426            tx.send_sample(view(&format!("b/{i}")));
427        }
428        let boundary = tokio::spawn(async move {
429            tx.send_boundary(SeedCoverage {
430                history_replies: Some(0),
431                storage_replies: Some(0),
432                superseded: 0,
433            })
434            .await;
435        });
436        let mut seen_boundary = false;
437        while let Some(item) = rx.recv().await {
438            if let SeedItem::SeedComplete(c) = item {
439                assert_eq!(c.superseded, 0);
440                seen_boundary = true;
441                break;
442            }
443        }
444        assert!(seen_boundary, "the boundary is never among the dropped");
445        boundary.await.expect("boundary task");
446    }
447}