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}