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}