zenkey_fleet/query.rs
1//! The fan-in query discipline (RFC 05 §2.1) — moved verbatim from
2//! zenctl's `bus.rs`; this stays the single chokepoint for fleet GETs.
3
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use zenkey::grammar::with_base;
8use zenkey::{RegistrySlice, parse_slice};
9use zenoh::Session;
10use zenoh::qos::Priority;
11use zenoh::query::{ConsolidationMode, QueryTarget};
12
13/// How a producer answered a procedure call.
14///
15/// `Clone` is a refcount bump on the payload, not a copy — which is what lets
16/// a GUI hold an answer in widget state without paying for it.
17#[derive(Debug, Clone)]
18pub enum Answer {
19 /// A value reply — RFC 05 §3: "a reply always indicates success".
20 /// Carried as zenoh's refcounted buffer: cloning is a refcount bump,
21 /// and consumers decode via `reader()`/`to_bytes()` (a `Cow` — it
22 /// copies only when the payload arrived fragmented). Report §14's
23 /// zero-copy discipline: the old `to_bytes().to_vec()` double copy per
24 /// reply is retired.
25 Value(zenoh::bytes::ZBytes),
26 /// An error reply (`reply_err`), carrying the `{error, message}` envelope
27 /// when it parses. RFC 05 §3: "an error always indicates failure".
28 Error { name: String, message: String },
29}
30
31/// One host's answer, attributed to the origin that actually replied.
32#[derive(Debug, Clone)]
33pub struct FleetAnswer {
34 pub origin: String,
35 /// The reply's **own** key expression — what `origin` was derived from, and
36 /// the concrete key a follow-up must be addressed to.
37 ///
38 /// Empty for an error reply, which zenoh gives no sample and therefore no
39 /// key. Carried because `origin` is lossy by design: the attribution helper goes
40 /// through the grammar and yields `"?"` for any key that does not parse
41 /// under `base`, and a caller that must still *name* the responder (RFC 09
42 /// §5.1 O1 — a non-conforming key is a fact) has nowhere else to look.
43 pub key: String,
44 /// The reply's declared encoding, when it carried one.
45 ///
46 /// A caller that speaks a specific wire (`@blob`'s postcard replies, say)
47 /// needs to tell "answered in a dialect we do not speak" from "did not
48 /// answer": the first is an observation, the second is silence, and RFC 09
49 /// §5.1 O4 forbids rendering them alike.
50 pub encoding: Option<String>,
51 /// The reply's attachment, when it carried one (refcounted, like the
52 /// payload). `None` on an error reply is the only truth available:
53 /// zenoh's `ReplyError` carries no attachment — a fact about the wire,
54 /// not an unobserved field.
55 pub attachment: Option<zenoh::bytes::ZBytes>,
56 pub answer: Answer,
57}
58
59/// Call a procedure and collect **every** reply, attributed by origin.
60///
61/// The three things RFC 05 §2.1 requires, in the one place they cannot be
62/// forgotten:
63///
64/// 1. **target = All.** The default `BestMatching` short-circuits to a single
65/// queryable the moment any matching one is declared `complete` — "one
66/// storage config away from silently collapsing the fleet to one reply".
67/// 2. **consolidation = None.** Default consolidation keeps one reply *per
68/// reply key*; belt-and-braces against a producer that wrongly echoes the
69/// wildcard selector instead of replying on its own concrete key.
70/// 3. **Attribution by the reply's own key**, never by the key we asked on —
71/// that is what makes `*`-origin fan-out legible.
72///
73/// Silence is deliberately *not* interpreted here (RFC 05 §3.1: "no reply" is
74/// not one condition). Callers that need a verdict join this against the
75/// liveliness roster; see `cmd::doctor`.
76pub async fn fleet_get(
77 session: &Session,
78 base: &str,
79 key: &str,
80 payload: Option<Vec<u8>>,
81 timeout: Duration,
82) -> Result<Vec<FleetAnswer>> {
83 fleet_get_at(session, base, key, payload, timeout, Priority::DEFAULT).await
84}
85
86/// [`fleet_get`] with the query's **priority** stated (RFC 04 §3, RFC 07 §2.6).
87///
88/// Replies inherit the *query's* QoS — a server-side setter is a no-op — so a
89/// bulk plane's priority can only be decided here. RFC 07 §2.6 makes that a
90/// caller obligation rather than a suggestion: `@blob` GETs MUST ride at
91/// [`Priority::DataLow`], or one operator fetching a debug bundle starves the
92/// telemetry and alerts sharing the link.
93///
94/// A sibling rather than a sixth parameter on [`fleet_get`]: every existing
95/// call site would pass the same value and read worse for it, and naming the
96/// bulk case makes "who issues bulk GETs?" a grep — the same argument that
97/// keeps `BlobProbePrefix` a distinct type instead of a `Key`.
98///
99/// [`fleet_get`] delegates here with [`Priority::DEFAULT`], which is
100/// `Priority::Data` — byte-identical to setting nothing, which is what it did
101/// before this function existed.
102pub async fn fleet_get_at(
103 session: &Session,
104 base: &str,
105 key: &str,
106 payload: Option<Vec<u8>>,
107 timeout: Duration,
108 priority: Priority,
109) -> Result<Vec<FleetAnswer>> {
110 fleet_get_inner(session, base, key, payload, None, timeout, priority).await
111}
112
113/// [`fleet_get`] with a query **attachment** riding beside the body (#126) —
114/// the RPC caller's variant, a named sibling for the same reason
115/// [`fleet_get_at`] is one: "who sends attachments on queries?" stays a grep.
116///
117/// The attachment is verbatim, never schema-encoded — the encode ladder is
118/// for bodies; an attachment is outside the registry's vocabulary (#117),
119/// on a query exactly as on a publish.
120pub async fn fleet_get_call(
121 session: &Session,
122 base: &str,
123 key: &str,
124 payload: Option<Vec<u8>>,
125 attachment: Option<Vec<u8>>,
126 timeout: Duration,
127) -> Result<Vec<FleetAnswer>> {
128 fleet_get_inner(
129 session,
130 base,
131 key,
132 payload,
133 attachment,
134 timeout,
135 Priority::DEFAULT,
136 )
137 .await
138}
139
140async fn fleet_get_inner(
141 session: &Session,
142 base: &str,
143 key: &str,
144 payload: Option<Vec<u8>>,
145 attachment: Option<Vec<u8>>,
146 timeout: Duration,
147 priority: Priority,
148) -> Result<Vec<FleetAnswer>> {
149 let mut builder = session
150 .get(key)
151 .target(QueryTarget::All)
152 .consolidation(ConsolidationMode::None)
153 .priority(priority)
154 .timeout(timeout);
155 if let Some(body) = payload {
156 builder = builder.payload(body);
157 }
158 if let Some(att) = attachment {
159 builder = builder.attachment(att);
160 }
161 let replies = builder
162 .await
163 .map_err(|e| anyhow::anyhow!("{e}"))
164 .with_context(|| format!("query failed: {key}"))?;
165 Ok(collect_answers(base, replies).await)
166}
167
168/// Drain a reply channel into attributed answers — the shared back half of
169/// [`fleet_get`] and [`RepeatingQuery`]: one implementation of reply-key
170/// attribution and the RFC 05 §3 error envelope, however the query was issued.
171async fn collect_answers(
172 base: &str,
173 replies: zenoh::handlers::FifoChannelHandler<zenoh::query::Reply>,
174) -> Vec<FleetAnswer> {
175 let mut out = Vec::new();
176 while let Ok(reply) = replies.recv_async().await {
177 out.push(answer_of(base, reply));
178 }
179 out
180}
181
182/// One reply, attributed — the per-reply half of [`collect_answers`], shared
183/// with the timed drain in [`RepeatingQuery::fetch_timed`] so attribution and
184/// the RFC 05 §3 error envelope have exactly one implementation.
185fn answer_of(base: &str, reply: zenoh::query::Reply) -> FleetAnswer {
186 match reply.result() {
187 Ok(sample) => FleetAnswer {
188 origin: origin_of(base, sample.key_expr().as_str()),
189 key: sample.key_expr().as_str().to_string(),
190 encoding: Some(sample.encoding().to_string()),
191 attachment: sample.attachment().cloned(),
192 answer: Answer::Value(sample.payload().clone()),
193 },
194 Err(err) => {
195 // The error envelope is `{ "error": "<name>", "message": "…" }`
196 // (RFC 05 §3), with reserved names like `error/not-found`. If it
197 // does not parse we still surface the bytes — an unreadable
198 // refusal is still a refusal.
199 let bytes = err.payload().to_bytes();
200 let (name, message) = match serde_json::from_slice::<serde_json::Value>(&bytes) {
201 Ok(v) => (
202 v.get("error")
203 .and_then(|e| e.as_str())
204 .unwrap_or("error/unparsed")
205 .to_string(),
206 v.get("message")
207 .and_then(|m| m.as_str())
208 .unwrap_or_default()
209 .to_string(),
210 ),
211 Err(_) => (
212 "error/unparsed".to_string(),
213 String::from_utf8_lossy(&bytes).to_string(),
214 ),
215 };
216 // An error reply has no sample, so no concrete key to attribute
217 // by; zenoh does not surface the responder here.
218 FleetAnswer {
219 origin: "?".to_string(),
220 key: String::new(),
221 encoding: None,
222 attachment: None,
223 answer: Answer::Error { name, message },
224 }
225 }
226 }
227}
228
229/// A **declared** querier carrying the same RFC 05 §2.1 discipline as
230/// [`fleet_get`] (target `All`, consolidation `None`, attribution by reply
231/// key), for fetches that re-ask the **same key expression** — watch loops,
232/// the schema cache's re-asks, registry sweeps, doctor. Declaring once lets
233/// the network keep routing state warm instead of rebuilding it per GET
234/// (report §12's zenoh-1.9 adoption row).
235///
236/// When to use which:
237/// - recurring, same keyexpr → declare a `RepeatingQuery` and `fetch` many
238/// times (parameters and payload ride **per get**, never in the declared
239/// keyexpr — a `?params` suffix in `key` is a bug here);
240/// - genuinely one-shot, or an ad-hoc key → [`fleet_get`].
241///
242/// Liveliness sweeps ([`crate::roster()`]) are a different API
243/// (`session.liveliness().get()`) with no querier equivalent and stay
244/// undeclared.
245pub struct RepeatingQuery {
246 querier: zenoh::query::Querier<'static>,
247 base: String,
248}
249
250/// Declare a repeating query on `key` (a full wire keyexpr, no `?params`).
251///
252/// The §2.1 discipline is fixed at declaration: target `All`, consolidation
253/// `None`, `timeout` for every subsequent fetch.
254pub async fn declare_repeating(
255 session: &Session,
256 base: &str,
257 key: &str,
258 timeout: Duration,
259) -> Result<RepeatingQuery> {
260 declare(session, base, key, timeout, false).await
261}
262
263/// As [`declare_repeating`], additionally accepting replies **outside** the
264/// declared keyexpr (`ReplyKeyExpr::Any`) — the querying-subscriber pattern
265/// the `@adv` cache rung needs. A separate constructor because this axis is
266/// part of the querier's identity: never reuse one querier across both modes.
267pub async fn declare_repeating_any(
268 session: &Session,
269 base: &str,
270 key: &str,
271 timeout: Duration,
272) -> Result<RepeatingQuery> {
273 declare(session, base, key, timeout, true).await
274}
275
276async fn declare(
277 session: &Session,
278 base: &str,
279 key: &str,
280 timeout: Duration,
281 accept_any: bool,
282) -> Result<RepeatingQuery> {
283 let mut builder = session
284 .declare_querier(key.to_string())
285 .target(QueryTarget::All)
286 .consolidation(ConsolidationMode::None)
287 .timeout(timeout);
288 if accept_any {
289 builder = builder.accept_replies(zenoh::query::ReplyKeyExpr::Any);
290 }
291 let querier = builder
292 .await
293 .map_err(|e| anyhow::anyhow!("{e}"))
294 .with_context(|| format!("declare querier failed: {key}"))?;
295 Ok(RepeatingQuery {
296 querier,
297 base: base.to_string(),
298 })
299}
300
301impl RepeatingQuery {
302 /// The declared key expression.
303 pub fn key(&self) -> &str {
304 self.querier.key_expr().as_str()
305 }
306
307 /// One fetch on the declared keyexpr, every reply attributed by its own
308 /// key — [`fleet_get`]'s contract, minus the per-call declaration.
309 pub async fn fetch(&self) -> Result<Vec<FleetAnswer>> {
310 self.fetch_with("", None).await
311 }
312
313 /// As [`fetch`](Self::fetch), with selector parameters and/or a request
314 /// payload riding this one get.
315 pub async fn fetch_with(
316 &self,
317 params: &str,
318 payload: Option<Vec<u8>>,
319 ) -> Result<Vec<FleetAnswer>> {
320 let mut builder = self.querier.get();
321 if !params.is_empty() {
322 builder = builder.parameters(params);
323 }
324 if let Some(body) = payload {
325 builder = builder.payload(body);
326 }
327 let replies = builder
328 .await
329 .map_err(|e| anyhow::anyhow!("{e}"))
330 .with_context(|| format!("repeating query failed: {}", self.key()))?;
331 Ok(collect_answers(&self.base, replies).await)
332 }
333
334 /// As [`fetch`](Self::fetch), stamping each reply with how long after the
335 /// GET it arrived (issue #52).
336 ///
337 /// This exists because a fan-out call's *call* duration is the time until
338 /// the slowest answer, so attributing it to every origin would report a
339 /// fast responder's latency as the fleet's worst. Timing each reply where
340 /// it is drained is the only place the distinction is available — and it
341 /// keeps the RFC 05 §2.1 chokepoint intact rather than forking a second
342 /// GET path to measure with.
343 pub async fn fetch_timed(&self) -> Result<Vec<(FleetAnswer, Duration)>> {
344 let started = std::time::Instant::now();
345 let replies = self
346 .querier
347 .get()
348 .await
349 .map_err(|e| anyhow::anyhow!("{e}"))
350 .with_context(|| format!("repeating query failed: {}", self.key()))?;
351 let mut out = Vec::new();
352 while let Ok(reply) = replies.recv_async().await {
353 let at = started.elapsed();
354 out.push((answer_of(&self.base, reply), at));
355 }
356 Ok(out)
357 }
358
359 /// Undeclare, telling the network to drop the routing state. The crate's
360 /// idiom: teardown is explicit and awaited, never left to `Drop`.
361 pub async fn undeclare(self) -> Result<()> {
362 self.querier
363 .undeclare()
364 .await
365 .map_err(|e| anyhow::anyhow!("undeclare querier: {e}"))
366 }
367
368 /// Whether any queryable currently matches **this querier** — "someone
369 /// serves what *we* ask", a routing fact about the querier this process
370 /// declared (RFC 12 §9's allowed half). `false` is not a fleet verdict:
371 /// it never means "nobody serves this key" (RFC 05 §3.1).
372 pub async fn matching_status(&self) -> Result<bool> {
373 self.querier
374 .matching_status()
375 .await
376 .map(|s| s.matching())
377 .map_err(|e| anyhow::anyhow!("matching status: {e}"))
378 }
379
380 /// Event-driven matching changes for this querier — same honesty bounds
381 /// as [`matching_status`](Self::matching_status).
382 pub async fn matching_events(&self) -> Result<crate::write::MatchingEvents> {
383 crate::write::MatchingEvents::for_querier(&self.querier).await
384 }
385}
386
387/// The origin chunk of a wire key, via the grammar (never by index — RFC 03
388/// §1.1: positions are relative to the configured base).
389fn origin_of(base: &str, key: &str) -> String {
390 zenkey::grammar::parse_full(base, key)
391 .map(|k| k.origin.chunk().to_string())
392 .unwrap_or_else(|| "?".to_string())
393}
394
395/// Discover every live producer's registry slice **from the bus**, with nothing
396/// compiled in (RFC 08 §6: "generic explorer tooling … needs no compiled-in
397/// registry").
398///
399/// Every producer MUST serve its registry slice as TOML on
400/// `@rpc/<producer>/introspect`. This fans one wildcard-producer `introspect`
401/// GET across the fleet — `<base>/v1/*/@rpc/*/introspect` — and parses each
402/// reply. It is the same introspect+`parse_slice` path `doctor` walks, minus
403/// the compiled-in diff: here the served slice *is* the answer.
404///
405/// A reply that does not parse is reported to stderr and skipped, never fatal:
406/// one malformed producer must not blind the tool to every other producer's
407/// slice. The tuple's first element is the producer (or service) base name the
408/// slice declares (`slice.name`), matching the compiled path's producer column.
409///
410/// A verbatim service origin is unmatchable by the `*` of a fleet selector
411/// (grammar property D4), so the wildcard sweep cannot enumerate services.
412/// The well-known `@catalog` identity service (RFC 06 §5) is therefore asked
413/// by name, exactly as [`crate::roster()`] does for its alive token; other
414/// service origins remain reachable only via local registry files
415/// (`doctor --registry` asks each declared `service_origin` by name).
416pub async fn fleet_registry(
417 session: &Session,
418 base: &str,
419 timeout: Duration,
420) -> Result<Vec<(String, RegistrySlice)>> {
421 Ok(fleet_registry_raw(session, base, timeout)
422 .await?
423 .into_iter()
424 .map(|(slice, _)| (slice.name.clone(), slice))
425 .collect())
426}
427
428/// As [`fleet_registry`], additionally yielding each reply's raw TOML text
429/// (the artifact the slice cache persists).
430pub async fn fleet_registry_raw(
431 session: &Session,
432 base: &str,
433 timeout: Duration,
434) -> Result<Vec<(RegistrySlice, String)>> {
435 let repeating = RepeatingRegistry::declare(session, base, timeout).await?;
436 let slices = repeating.fetch().await?;
437 repeating.undeclare().await?;
438 Ok(slices)
439}
440
441/// The registry sweep as a **declared** pair of queriers (#37) — for callers
442/// that re-run the sweep (`--watch topic list`, doctor's second pass, a GUI
443/// refresh). One-shot callers keep [`fleet_registry`].
444///
445/// Two queriers, not one: the wildcard-producer fan-out plus `@catalog` by
446/// name (a `*` never matches a verbatim origin, D4 — the two cannot
447/// double-count; same reasoning as [`fleet_registry`]).
448pub struct RepeatingRegistry {
449 wildcard: RepeatingQuery,
450 catalog: RepeatingQuery,
451}
452
453impl RepeatingRegistry {
454 pub async fn declare(session: &Session, base: &str, timeout: Duration) -> Result<Self> {
455 // This session is un-namespaced on purpose (RFC 09 §5), so it must
456 // spell the base itself — exactly as `service call` composes its key.
457 let wildcard = with_base(base, zenkey::selector::fleet_rpc("*", &["introspect"]));
458 let catalog = with_base(
459 base,
460 zenkey::selector::service_rpc(&zenkey::ServiceOrigin::catalog(), &["introspect"]),
461 );
462 Ok(RepeatingRegistry {
463 wildcard: declare_repeating(session, base, &wildcard, timeout).await?,
464 catalog: declare_repeating(session, base, &catalog, timeout).await?,
465 })
466 }
467
468 /// One sweep: every parsed slice with its raw TOML. A reply that does not
469 /// parse is logged and skipped, never fatal — one malformed producer must
470 /// not blind the tool to every other producer's slice.
471 pub async fn fetch(&self) -> Result<Vec<(RegistrySlice, String)>> {
472 let mut slices = Vec::new();
473 for q in [&self.wildcard, &self.catalog] {
474 for answer in q.fetch().await? {
475 let Answer::Value(bytes) = answer.answer else {
476 continue;
477 };
478 let served_toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
479 match parse_slice(&served_toml) {
480 Ok(slice) => slices.push((slice, served_toml)),
481 Err(e) => tracing::warn!(
482 origin = %answer.origin,
483 "introspect reply did not parse, skipping: {e}"
484 ),
485 }
486 }
487 }
488 Ok(slices)
489 }
490
491 pub async fn undeclare(self) -> Result<()> {
492 self.wildcard.undeclare().await?;
493 self.catalog.undeclare().await
494 }
495}
496
497/// One state sample from a snapshot GET.
498#[derive(Debug, Clone)]
499pub struct StateSample {
500 /// Full wire key.
501 pub key: String,
502 /// HLC timestamp, when the deployment stamps samples (RFC 04 §4
503 /// requires it for LWW to be meaningful — its absence is itself a
504 /// doctor-grade observation).
505 pub timestamp: Option<zenoh::time::Timestamp>,
506 pub payload_len: usize,
507}
508
509/// GET the current state under a selector with the fan-in discipline
510/// (target All, consolidation None) — the doctor's freshness check
511/// (RFC 04 §1.2) consumes the timestamps. Same chokepoint posture as
512/// [`fleet_get`]: no subcommand issues a raw `session.get`.
513///
514/// `max` bounds the samples **drained** (`doctor --sample N`): the loop
515/// stops reading at the cap, so a bounded sweep is cheaper, not merely
516/// quieter. `None` drains every reply.
517pub async fn state_snapshot(
518 session: &Session,
519 selector: &str,
520 timeout: Duration,
521 max: Option<usize>,
522) -> Result<Vec<StateSample>> {
523 let replies = session
524 .get(selector)
525 .target(QueryTarget::All)
526 .consolidation(ConsolidationMode::None)
527 .timeout(timeout)
528 .await
529 .map_err(|e| anyhow::anyhow!("{e}"))
530 .with_context(|| format!("state snapshot failed: {selector}"))?;
531 let mut out = Vec::new();
532 while let Ok(reply) = replies.recv_async().await {
533 if max.is_some_and(|m| out.len() >= m) {
534 break;
535 }
536 let Ok(sample) = reply.result() else { continue };
537 out.push(StateSample {
538 key: sample.key_expr().as_str().to_string(),
539 timestamp: sample.timestamp().copied(),
540 payload_len: sample.payload().len(),
541 });
542 }
543 Ok(out)
544}
545
546/// Which rung of the fetch ladder produced a value.
547#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
548#[serde(rename_all = "snake_case")]
549pub enum ValueSource {
550 /// A GET on the concrete key answered — a router storage (or any plain
551 /// queryable standing at that key).
552 Storage,
553 /// The publisher's AdvancedPublisher cache answered on `<key>/@adv/**`.
554 Cache,
555 /// A brief bounded subscription caught a live sample.
556 Window,
557}
558
559/// One fetched value with its provenance.
560#[derive(Debug, Clone)]
561pub struct FetchedValue {
562 /// The concrete key the value arrived on.
563 pub key: String,
564 pub payload: zenoh::bytes::ZBytes,
565 pub encoding: String,
566 pub timestamp: Option<zenoh::time::Timestamp>,
567 /// The value's attachment, when the sample carried one (#117).
568 pub attachment: Option<zenoh::bytes::ZBytes>,
569 pub source: ValueSource,
570}
571
572/// The outcome: a value, or an attributed nothing.
573#[derive(Debug, Clone)]
574pub enum FetchOutcome {
575 Value(FetchedValue),
576 /// Every rung was tried and none answered — a non-verdict, stated with
577 /// exactly what was asked (RFC 05 §3.1: silence never becomes a claim
578 /// that no value exists).
579 None {
580 attempted: [&'static str; 3],
581 },
582}
583
584/// Fetch ladder bounds.
585#[derive(Debug, Clone, Copy)]
586pub struct FetchSpec {
587 /// Per-GET timeout (two GETs happen: concrete key, then `@adv` cache).
588 pub get_timeout: Duration,
589 /// The final subscribe-window rung's duration.
590 pub window: Duration,
591}
592
593impl Default for FetchSpec {
594 fn default() -> Self {
595 FetchSpec {
596 get_timeout: Duration::from_secs(2),
597 window: Duration::from_millis(1500),
598 }
599 }
600}
601
602/// Fetch one concrete key's current value **on demand** — the value half of
603/// the lazy-observation contract (issue #84): a selection retrieves one
604/// value; nothing is prefetched, nothing stays subscribed.
605///
606/// The ladder, each rung bounded:
607/// 1. GET the concrete key (storages answer; RFC 04 §3.2's "a plain GET does
608/// not reach publisher caches" is exactly why rung 2 exists);
609/// 2. GET `<key>/@adv/**?_max=1` — zenoh-ext's AdvancedPublisher cache
610/// declares its queryable there and replies with the cached sample on its
611/// own concrete key (`@adv` is verbatim, so no data selector ever collides
612/// with it — RFC 03 §4 D2 working in our favor);
613/// 3. a brief callback subscription on the key, first sample wins.
614///
615/// Several answers on a rung (multiple storages) resolve by latest HLC
616/// timestamp; unstamped answers lose to stamped ones (RFC 04 §1.2's LWW).
617pub async fn fetch_value(session: &Session, key: &str, spec: FetchSpec) -> Result<FetchOutcome> {
618 // Rung 1 + 2: bounded GETs.
619 for (selector, source) in [
620 (key.to_string(), ValueSource::Storage),
621 (format!("{key}/@adv/**?_max=1"), ValueSource::Cache),
622 ] {
623 if let Some(v) = get_latest(session, &selector, source, spec.get_timeout).await? {
624 return Ok(FetchOutcome::Value(v));
625 }
626 }
627
628 // Rung 3: a window. The subscriber is explicitly undeclared afterwards —
629 // the window closes, provably.
630 let (tx, rx) = tokio::sync::oneshot::channel::<FetchedValue>();
631 let tx = std::sync::Mutex::new(Some(tx));
632 let subscriber = session
633 .declare_subscriber(key)
634 .callback(move |sample| {
635 if let Some(tx) = tx.lock().expect("fetch window lock").take() {
636 let _ = tx.send(FetchedValue {
637 key: sample.key_expr().as_str().to_string(),
638 payload: sample.payload().clone(),
639 encoding: sample.encoding().to_string(),
640 timestamp: sample.timestamp().copied(),
641 attachment: sample.attachment().cloned(),
642 source: ValueSource::Window,
643 });
644 }
645 })
646 .await
647 .map_err(|e| anyhow::anyhow!("window subscribe {key}: {e}"))?;
648 let caught = tokio::time::timeout(spec.window, rx).await;
649 subscriber
650 .undeclare()
651 .await
652 .map_err(|e| anyhow::anyhow!("window undeclare {key}: {e}"))?;
653 if let Ok(Ok(v)) = caught {
654 return Ok(FetchOutcome::Value(v));
655 }
656
657 Ok(FetchOutcome::None {
658 attempted: ["get", "@adv cache", "subscribe window"],
659 })
660}
661
662async fn get_latest(
663 session: &Session,
664 selector: &str,
665 source: ValueSource,
666 timeout: Duration,
667) -> Result<Option<FetchedValue>> {
668 let replies = session
669 .get(selector)
670 .target(QueryTarget::All)
671 .consolidation(ConsolidationMode::None)
672 // The @adv cache replies with the cached sample on the sample's OWN
673 // key — outside the `<key>/@adv/**` selector — and zenoh drops such
674 // replies unless the caller opts in. This is the querying-subscriber
675 // pattern; harmless for the storage rung, whose replies sit inside
676 // the selector anyway.
677 .accept_replies(zenoh::query::ReplyKeyExpr::Any)
678 .timeout(timeout)
679 .await
680 .map_err(|e| anyhow::anyhow!("get {selector}: {e}"))?;
681 let mut best: Option<FetchedValue> = None;
682 while let Ok(reply) = replies.recv_async().await {
683 let Ok(sample) = reply.result() else { continue };
684 let candidate = FetchedValue {
685 key: sample.key_expr().as_str().to_string(),
686 payload: sample.payload().clone(),
687 encoding: sample.encoding().to_string(),
688 timestamp: sample.timestamp().copied(),
689 attachment: sample.attachment().cloned(),
690 source,
691 };
692 best = Some(match best.take() {
693 None => candidate,
694 // Latest HLC wins; stamped beats unstamped (RFC 04 §1.2 LWW).
695 Some(cur) => match (cur.timestamp, candidate.timestamp) {
696 (Some(a), Some(b)) if b > a => candidate,
697 (None, Some(_)) => candidate,
698 _ => cur,
699 },
700 });
701 }
702 Ok(best)
703}