Skip to main content

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}