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}