Skip to main content

zenkey_fleet/bus/
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 crate::{Error, Result};
7use zenkey::{RegistrySlice, parse_slice};
8use zenoh::Session;
9use zenoh::qos::Priority;
10use zenoh::query::{ConsolidationMode, QueryTarget};
11
12use crate::bus::monitor::SampleView;
13use crate::bus::session::Fleet;
14use crate::report::ValueSource;
15
16/// How a producer answered a procedure call.
17///
18/// `Clone` is a refcount bump on the payload, not a copy — which is what lets
19/// a GUI hold an answer in widget state without paying for it.
20#[derive(Debug, Clone)]
21pub enum Answer {
22    /// A value reply — RFC 05 §3: "a reply always indicates success".
23    /// Carried as zenoh's refcounted buffer: cloning is a refcount bump,
24    /// and consumers decode via `reader()`/`to_bytes()` (a `Cow` — it
25    /// copies only when the payload arrived fragmented). Report §14's
26    /// zero-copy discipline: the old `to_bytes().to_vec()` double copy per
27    /// reply is retired.
28    Value(zenoh::bytes::ZBytes),
29    /// An error reply (`reply_err`), carrying the `{error, message}` envelope
30    /// when it parses. RFC 05 §3: "an error always indicates failure".
31    Error { name: String, message: String },
32}
33
34/// One host's answer, attributed to the origin that actually replied.
35#[derive(Debug, Clone)]
36pub struct FleetAnswer {
37    pub origin: String,
38    /// The reply's **own** key expression — what `origin` was derived from, and
39    /// the concrete key a follow-up must be addressed to.
40    ///
41    /// Empty for an error reply, which zenoh gives no sample and therefore no
42    /// key. Carried because `origin` is lossy by design: the attribution helper goes
43    /// through the grammar and yields `"?"` for any key that does not parse
44    /// under `base`, and a caller that must still *name* the responder (RFC 09
45    /// §5.1 O1 — a non-conforming key is a fact) has nowhere else to look.
46    pub key: String,
47    /// The reply's declared encoding, when it carried one.
48    ///
49    /// A caller that speaks a specific wire (`@blob`'s postcard replies, say)
50    /// needs to tell "answered in a dialect we do not speak" from "did not
51    /// answer": the first is an observation, the second is silence, and RFC 09
52    /// §5.1 O4 forbids rendering them alike.
53    pub encoding: Option<String>,
54    /// The reply's attachment, when it carried one (refcounted, like the
55    /// payload). `None` on an error reply is the only truth available:
56    /// zenoh's `ReplyError` carries no attachment — a fact about the wire,
57    /// not an unobserved field.
58    pub attachment: Option<zenoh::bytes::ZBytes>,
59    /// The reply sample's HLC, when it carried one (#215). A reply is a
60    /// sample and is stamped like one — by the first timestamping node it
61    /// passed, not necessarily the responder (RFC 09 §5.1 O7). `None` on an
62    /// error reply, which is not a sample, and on a reply nothing stamped.
63    pub timestamp: Option<zenoh::time::Timestamp>,
64    pub answer: Answer,
65}
66
67/// What one GET may vary — everything RFC 05 §2.1 does **not** fix.
68///
69/// The §2.1 triple is not a knob and deliberately has no field here: it is
70/// applied by `disciplined_get` to every GET this crate issues. What a
71/// caller does choose is the timeout, the request body, an attachment riding
72/// beside it (#126), the query's priority (RFC 04 §3, RFC 07 §2.6) and
73/// whether replies from *outside* the selector are accepted.
74///
75/// A spec struct rather than named sibling functions: `fleet_get_at`
76/// (priority) and `fleet_get_call` (attachment) used to be those siblings, and
77/// `_at` had come to mean two things — this axis, and the injected-clock
78/// convention (`ingest_at`, `ZrecWriter::new_at`). The greps the siblings
79/// bought survive as setter greps: "who issues bulk GETs?" is
80/// `grep '\.priority('`, "who sends attachments on queries?" is
81/// `grep '\.attachment('`.
82#[derive(Debug, Clone)]
83pub struct GetOpts {
84    timeout: Duration,
85    payload: Option<Vec<u8>>,
86    attachment: Option<Vec<u8>>,
87    priority: Priority,
88    accept_any: bool,
89    max_replies: usize,
90    /// What the bound cost, filled in by the GET (#339). Shared rather than
91    /// returned — see [`GetOpts::elided`].
92    elided: std::sync::Arc<std::sync::atomic::AtomicU64>,
93}
94
95/// How many replies a GET keeps unless the caller says otherwise (#339).
96///
97/// Every fan-out here was unbounded: `collect_answers`, `fetch_timed` and
98/// `admin_get` pushed every reply into a `Vec`, each holding a refcounted
99/// payload, so a `**` sweep against a router with a large storage was
100/// unbounded memory in a tool that bounds everything else it accumulates.
101///
102/// 4096 is chosen against what the fan-out *means*: a fleet GET is one reply
103/// per producer per key, and a fleet with four thousand replying entities on
104/// one selector is past what any of these renderers show anyway. A caller
105/// that genuinely wants more says so, and hears what the last bound cost.
106pub const DEFAULT_MAX_REPLIES: usize = 4096;
107
108impl GetOpts {
109    /// A plain GET, bounded by `timeout`.
110    ///
111    /// [`Priority::DEFAULT`] is `Priority::Data` — byte-identical to setting
112    /// no priority at all, which is what every un-annotated GET did before
113    /// this type existed.
114    pub fn new(timeout: Duration) -> Self {
115        GetOpts {
116            timeout,
117            payload: None,
118            attachment: None,
119            priority: Priority::DEFAULT,
120            accept_any: false,
121            max_replies: DEFAULT_MAX_REPLIES,
122            elided: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
123        }
124    }
125
126    /// The request body, when there is one. `None` is the common case and
127    /// costs nothing to say.
128    pub fn payload(mut self, payload: Option<Vec<u8>>) -> Self {
129        self.payload = payload;
130        self
131    }
132
133    /// A query attachment (#126), verbatim — never schema-encoded. The encode
134    /// ladder is for bodies; an attachment is outside the registry's
135    /// vocabulary (#117), on a query exactly as on a publish.
136    pub fn attachment(mut self, attachment: Option<Vec<u8>>) -> Self {
137        self.attachment = attachment;
138        self
139    }
140
141    /// State the query's priority (RFC 04 §3, RFC 07 §2.6).
142    ///
143    /// Replies inherit the *query's* QoS — a server-side setter is a no-op —
144    /// so a bulk plane's priority can only be decided here. RFC 07 §2.6 makes
145    /// that a caller obligation rather than a suggestion: `@blob` GETs MUST
146    /// ride at [`Priority::DataLow`], or one operator fetching a debug bundle
147    /// starves the telemetry and alerts sharing the link.
148    pub fn priority(mut self, priority: Priority) -> Self {
149        self.priority = priority;
150        self
151    }
152
153    /// Accept replies on keys **outside** the selector
154    /// ([`zenoh::query::ReplyKeyExpr::Any`]) — the querying-subscriber
155    /// pattern.
156    ///
157    /// The `@adv` cache replies with the cached sample on the *sample's* own
158    /// key, outside a `<key>/@adv/**` selector, and zenoh drops such replies
159    /// unless the caller opts in. Harmless on a rung whose replies sit inside
160    /// the selector anyway.
161    pub fn accept_any(mut self) -> Self {
162        self.accept_any = true;
163        self
164    }
165
166    /// The bound this GET runs under.
167    pub fn timeout(&self) -> Duration {
168        self.timeout
169    }
170
171    /// Keep at most `max` replies (#339). Zero is clamped to one: a GET that
172    /// kept nothing would report silence, and silence is never a verdict
173    /// (RFC 05 §3.1).
174    pub fn max_replies(mut self, max: usize) -> Self {
175        self.max_replies = max.max(1);
176        self
177    }
178
179    /// The reply bound in force.
180    pub fn reply_bound(&self) -> usize {
181        self.max_replies
182    }
183
184    /// **What the bound cost**: replies that arrived and were not kept,
185    /// across every GET run under these options (RFC 13 §3 O6 — a bound that
186    /// hides data must say so).
187    ///
188    /// It rides here, on the object that *states* the bound, rather than in
189    /// the return type, for the reason every other bounded structure in this
190    /// crate keeps its own ledger (`StatsTable::evicted`,
191    /// `Retention::evicted`, `BoundedLru::admit`): the thing that owns the
192    /// ceiling owns the count of what the ceiling refused. A caller reads it
193    /// beside the answers it just got:
194    ///
195    /// ```ignore
196    /// let opts = GetOpts::new(timeout);
197    /// let answers = fleet_get(&fleet, key, &opts).await?;
198    /// if opts.elided() > 0 { /* say so — never render this as "all of them" */ }
199    /// ```
200    ///
201    /// The count is exact: past the bound the replies are still drained, they
202    /// are simply not kept. Draining is what makes the number honest; *keeping*
203    /// is what was unbounded.
204    pub fn elided(&self) -> u64 {
205        self.elided.load(std::sync::atomic::Ordering::Relaxed)
206    }
207
208    /// Forget what earlier GETs under these options cost — for a caller that
209    /// reuses one `GetOpts` and reports per GET rather than per run.
210    pub fn reset_elided(&self) {
211        self.elided.store(0, std::sync::atomic::Ordering::Relaxed);
212    }
213
214    /// Add to the ledger — for the drains that live in another module
215    /// ([`crate::admin_get_within`]) and keep their own reply shape.
216    pub(crate) fn note_elided(&self, n: u64) {
217        if n > 0 {
218            self.elided
219                .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
220        }
221    }
222}
223
224/// **The** `session.get` of this crate (RFC 05 §2.1) — no other module issues
225/// one, which is what makes the discipline checkable by grep rather than by
226/// review.
227///
228/// Two of the three things §2.1 requires are set here, once:
229///
230/// 1. **target = All.** The default `BestMatching` short-circuits to a single
231///    queryable the moment any matching one is declared `complete` — "one
232///    storage config away from silently collapsing the fleet to one reply".
233/// 2. **consolidation = None.** Default consolidation keeps one reply *per
234///    reply key*; belt-and-braces against a producer that wrongly echoes the
235///    wildcard selector instead of replying on its own concrete key.
236///
237/// The third — **attribution by the reply's own key**, never by the key we
238/// asked on — belongs to whoever drains the channel, and lives in
239/// `answer_of` for the [`FleetAnswer`] path.
240///
241/// The error is the middleware's own, unwrapped: every caller has a better
242/// sentence to wrap it in than this function does.
243pub(crate) async fn disciplined_get(
244    session: &Session,
245    selector: &str,
246    opts: &GetOpts,
247) -> Result<zenoh::handlers::FifoChannelHandler<zenoh::query::Reply>> {
248    let mut builder = session
249        .get(selector)
250        .target(QueryTarget::All)
251        .consolidation(ConsolidationMode::None)
252        .priority(opts.priority)
253        .timeout(opts.timeout);
254    if let Some(body) = opts.payload.clone() {
255        builder = builder.payload(body);
256    }
257    if let Some(att) = opts.attachment.clone() {
258        builder = builder.attachment(att);
259    }
260    if opts.accept_any {
261        builder = builder.accept_replies(zenoh::query::ReplyKeyExpr::Any);
262    }
263    builder.await.map_err(|e| Error::bus("get", "", e))
264}
265
266/// Call a procedure and collect **every** reply, attributed by origin.
267///
268/// The RFC 05 §2.1 fan-in, end to end: `disciplined_get` sets target `All`
269/// and consolidation `None`, and `answer_of` attributes each reply by the
270/// reply's *own* key — which is what makes `*`-origin fan-out legible.
271///
272/// Silence is deliberately *not* interpreted here (RFC 05 §3.1: "no reply" is
273/// not one condition). Callers that need a verdict join this against the
274/// liveliness roster; see `cmd::doctor`.
275/// Bounded at [`GetOpts::reply_bound`], and what the bound cost is on
276/// [`GetOpts::elided`] (#339).
277pub async fn fleet_get(fleet: &Fleet<'_>, key: &str, opts: &GetOpts) -> Result<Vec<FleetAnswer>> {
278    let replies = disciplined_get(fleet.session(), key, opts)
279        .await
280        .map_err(|e| Error::bus("query", key.to_string(), e))?;
281    let (answers, elided) = collect_answers(fleet.base(), replies, opts.max_replies).await;
282    opts.note_elided(elided);
283    Ok(answers)
284}
285
286/// Drain a reply channel into attributed answers — the shared back half of
287/// [`fleet_get`] and [`RepeatingQuery`]: one implementation of reply-key
288/// attribution and the RFC 05 §3 error envelope, however the query was issued.
289///
290/// Returns what it kept and **how many it did not** (#339). Past `max` the
291/// replies are still drained — the channel is being emptied either way — they
292/// are simply not retained, so the count is exact and the memory is bounded.
293/// The two are different facts: draining is the fan-in finishing, keeping is
294/// what used to be unbounded.
295async fn collect_answers(
296    base: &str,
297    replies: zenoh::handlers::FifoChannelHandler<zenoh::query::Reply>,
298    max: usize,
299) -> (Vec<FleetAnswer>, u64) {
300    let mut out = Vec::new();
301    let mut elided = 0u64;
302
303    while let Ok(reply) = replies.recv_async().await {
304        if out.len() >= max {
305            elided += 1;
306            continue;
307        }
308        out.push(answer_of(base, reply));
309    }
310    (out, elided)
311}
312
313/// One reply, attributed — the per-reply half of [`collect_answers`], shared
314/// with the timed drain in [`RepeatingQuery::fetch_timed`] so attribution and
315/// the RFC 05 §3 error envelope have exactly one implementation.
316fn answer_of(base: &str, reply: zenoh::query::Reply) -> FleetAnswer {
317    match reply.result() {
318        Ok(sample) => FleetAnswer {
319            origin: origin_of(base, sample.key_expr().as_str()),
320            key: sample.key_expr().as_str().to_string(),
321            encoding: Some(sample.encoding().to_string()),
322            attachment: sample.attachment().cloned(),
323            timestamp: sample.timestamp().copied(),
324            answer: Answer::Value(sample.payload().clone()),
325        },
326        Err(err) => {
327            // The error envelope is `{ "error": "<name>", "message": "…" }`
328            // (RFC 05 §3), with reserved names like `error/not-found`. If it
329            // does not parse we still surface the bytes — an unreadable
330            // refusal is still a refusal.
331            let bytes = err.payload().to_bytes();
332            let (name, message) = match serde_json::from_slice::<serde_json::Value>(&bytes) {
333                Ok(v) => (
334                    v.get("error")
335                        .and_then(|e| e.as_str())
336                        .unwrap_or("error/unparsed")
337                        .to_string(),
338                    v.get("message")
339                        .and_then(|m| m.as_str())
340                        .unwrap_or_default()
341                        .to_string(),
342                ),
343                Err(_) => (
344                    "error/unparsed".to_string(),
345                    String::from_utf8_lossy(&bytes).to_string(),
346                ),
347            };
348            // An error reply has no sample, so no concrete key to attribute
349            // by; zenoh does not surface the responder here.
350            FleetAnswer {
351                origin: "?".to_string(),
352                key: String::new(),
353                encoding: None,
354                attachment: None,
355                timestamp: None,
356                answer: Answer::Error { name, message },
357            }
358        }
359    }
360}
361
362/// A **declared** querier carrying the same RFC 05 §2.1 discipline as
363/// [`fleet_get`] (target `All`, consolidation `None`, attribution by reply
364/// key), for fetches that re-ask the **same key expression** — watch loops,
365/// the schema cache's re-asks, registry sweeps, doctor. Declaring once lets
366/// the network keep routing state warm instead of rebuilding it per GET
367/// (report §12's zenoh-1.9 adoption row).
368///
369/// When to use which:
370/// - recurring, same keyexpr → declare a `RepeatingQuery` and `fetch` many
371///   times (parameters and payload ride **per get**, never in the declared
372///   keyexpr — a `?params` suffix in `key` is a bug here);
373/// - genuinely one-shot, or an ad-hoc key → [`fleet_get`].
374///
375/// Liveliness sweeps ([`crate::bus::roster::roster()`]) are a different API
376/// (`session.liveliness().get()`) with no querier equivalent and stay
377/// undeclared.
378pub struct RepeatingQuery {
379    querier: zenoh::query::Querier<'static>,
380    base: String,
381    /// Replies kept per fetch, and what the bound has cost across all of them
382    /// (#339) — the same ledger [`GetOpts`] carries, for the declared path.
383    max_replies: usize,
384    elided: std::sync::atomic::AtomicU64,
385}
386
387/// Declare a repeating query on `key` (a full wire keyexpr, no `?params`).
388///
389/// The §2.1 discipline is fixed at declaration: target `All`, consolidation
390/// `None`, `timeout` for every subsequent fetch.
391pub async fn declare_repeating(
392    fleet: &Fleet<'_>,
393    key: &str,
394    timeout: Duration,
395) -> Result<RepeatingQuery> {
396    declare(fleet, key, timeout, false).await
397}
398
399/// As [`declare_repeating`], additionally accepting replies **outside** the
400/// declared keyexpr (`ReplyKeyExpr::Any`) — the querying-subscriber pattern
401/// the `@adv` cache rung needs. A separate constructor because this axis is
402/// part of the querier's identity: never reuse one querier across both modes.
403pub async fn declare_repeating_any(
404    fleet: &Fleet<'_>,
405    key: &str,
406    timeout: Duration,
407) -> Result<RepeatingQuery> {
408    declare(fleet, key, timeout, true).await
409}
410
411async fn declare(
412    fleet: &Fleet<'_>,
413    key: &str,
414    timeout: Duration,
415    accept_any: bool,
416) -> Result<RepeatingQuery> {
417    let mut builder = fleet
418        .session()
419        .declare_querier(key.to_string())
420        .target(QueryTarget::All)
421        .consolidation(ConsolidationMode::None)
422        .timeout(timeout);
423    if accept_any {
424        builder = builder.accept_replies(zenoh::query::ReplyKeyExpr::Any);
425    }
426    let querier = crate::bus::teardown::declared("declare querier", &key, builder).await?;
427    Ok(RepeatingQuery {
428        querier,
429        base: fleet.base().to_string(),
430        max_replies: DEFAULT_MAX_REPLIES,
431        elided: std::sync::atomic::AtomicU64::new(0),
432    })
433}
434
435impl RepeatingQuery {
436    /// The declared key expression.
437    pub fn key(&self) -> &str {
438        self.querier.key_expr().as_str()
439    }
440
441    /// One fetch on the declared keyexpr, every reply attributed by its own
442    /// key — [`fleet_get`]'s contract, minus the per-call declaration.
443    pub async fn fetch(&self) -> Result<Vec<FleetAnswer>> {
444        self.fetch_with("", None).await
445    }
446
447    /// As [`fetch`](Self::fetch), with selector parameters and/or a request
448    /// payload riding this one get.
449    pub async fn fetch_with(
450        &self,
451        params: &str,
452        payload: Option<Vec<u8>>,
453    ) -> Result<Vec<FleetAnswer>> {
454        let mut builder = self.querier.get();
455        if !params.is_empty() {
456            builder = builder.parameters(params);
457        }
458        if let Some(body) = payload {
459            builder = builder.payload(body);
460        }
461        let replies = builder
462            .await
463            .map_err(|e| Error::bus("query", self.key(), e))?;
464        let (answers, elided) = collect_answers(&self.base, replies, self.max_replies).await;
465        self.note_elided(elided);
466        Ok(answers)
467    }
468
469    /// Keep at most `max` replies per fetch (#339). Zero is clamped to one.
470    pub fn max_replies(mut self, max: usize) -> Self {
471        self.max_replies = max.max(1);
472        self
473    }
474
475    /// The reply bound in force.
476    pub fn reply_bound(&self) -> usize {
477        self.max_replies
478    }
479
480    /// Replies this querier's bound refused, across every fetch (RFC 13 §3
481    /// O6). See [`GetOpts::elided`] for why the count lives with the bound.
482    pub fn elided(&self) -> u64 {
483        self.elided.load(std::sync::atomic::Ordering::Relaxed)
484    }
485
486    /// Forget what earlier fetches through this querier cost — for a caller
487    /// that re-runs a sweep and reports per sweep rather than per querier
488    /// ([`GetOpts::reset_elided`] is the same call on the one-shot path).
489    ///
490    /// Without it a per-sweep figure has to be read as a before/after
491    /// subtraction, which is not safe when two sweeps overlap on one
492    /// declared querier.
493    pub fn reset_elided(&self) {
494        self.elided.store(0, std::sync::atomic::Ordering::Relaxed);
495    }
496
497    fn note_elided(&self, n: u64) {
498        if n > 0 {
499            self.elided
500                .fetch_add(n, std::sync::atomic::Ordering::Relaxed);
501        }
502    }
503
504    /// As [`fetch`](Self::fetch), stamping each reply with how long after the
505    /// GET it arrived (issue #52).
506    ///
507    /// This exists because a fan-out call's *call* duration is the time until
508    /// the slowest answer, so attributing it to every origin would report a
509    /// fast responder's latency as the fleet's worst. Timing each reply where
510    /// it is drained is the only place the distinction is available — and it
511    /// keeps the RFC 05 §2.1 chokepoint intact rather than forking a second
512    /// GET path to measure with.
513    pub async fn fetch_timed(&self) -> Result<Vec<(FleetAnswer, Duration)>> {
514        let started = std::time::Instant::now();
515        let replies = self
516            .querier
517            .get()
518            .await
519            .map_err(|e| Error::bus("query", self.key(), e))?;
520        let mut out = Vec::new();
521        let mut elided = 0u64;
522        while let Ok(reply) = replies.recv_async().await {
523            let at = started.elapsed();
524            if out.len() >= self.max_replies {
525                elided += 1;
526                continue;
527            }
528            out.push((answer_of(&self.base, reply), at));
529        }
530        self.note_elided(elided);
531        Ok(out)
532    }
533
534    /// Undeclare, telling the network to drop the routing state. The crate's
535    /// idiom: teardown is explicit and awaited, never left to `Drop`.
536    pub async fn undeclare(self) -> Result<()> {
537        self.querier
538            .undeclare()
539            .await
540            .map_err(|e| Error::bus("undeclare querier", "", e))
541    }
542
543    /// Whether any queryable currently matches **this querier** — "someone
544    /// serves what *we* ask", a routing fact about the querier this process
545    /// declared (RFC 12 §9's allowed half). `false` is not a fleet verdict:
546    /// it never means "nobody serves this key" (RFC 05 §3.1).
547    pub async fn matching_status(&self) -> Result<bool> {
548        self.querier
549            .matching_status()
550            .await
551            .map(|s| s.matching())
552            .map_err(|e| Error::bus("matching status", "", e))
553    }
554
555    /// Event-driven matching changes for this querier — same honesty bounds
556    /// as [`matching_status`](Self::matching_status).
557    pub async fn matching_events(&self) -> Result<crate::bus::write::MatchingEvents> {
558        crate::bus::write::MatchingEvents::for_querier(&self.querier).await
559    }
560}
561
562/// The origin chunk of a wire key, via the grammar (never by index — RFC 03
563/// §1.1: positions are relative to the configured base).
564fn origin_of(base: &str, key: &str) -> String {
565    zenkey::grammar::parse_full(base, key)
566        .map(|k| k.origin.chunk().to_string())
567        .unwrap_or_else(|| "?".to_string())
568}
569
570/// Discover every live producer's registry slice **from the bus**, with nothing
571/// compiled in (RFC 08 §6: "generic explorer tooling … needs no compiled-in
572/// registry").
573///
574/// Every producer MUST serve its registry slice as TOML on
575/// `@rpc/<producer>/introspect`. This fans one wildcard-producer `introspect`
576/// GET across the fleet — `<base>/v1/*/@rpc/*/introspect` — and parses each
577/// reply. It is the same introspect+`parse_slice` path `doctor` walks, minus
578/// the compiled-in diff: here the served slice *is* the answer.
579///
580/// A reply that does not parse is reported to stderr and skipped, never fatal:
581/// one malformed producer must not blind the tool to every other producer's
582/// slice. The tuple's first element is the producer (or service) base name the
583/// slice declares (`slice.name`), matching the compiled path's producer column.
584///
585/// A verbatim service origin is unmatchable by the `*` of a fleet selector
586/// (grammar property D4), so the wildcard sweep cannot enumerate services.
587/// The well-known `@catalog` identity service (RFC 06 §5) is therefore asked
588/// by name, exactly as [`crate::bus::roster::roster()`] does for its alive token; other
589/// service origins remain reachable only via local registry files
590/// (`doctor --registry` asks each declared `service_origin` by name).
591pub async fn fleet_registry(
592    fleet: &Fleet<'_>,
593    timeout: Duration,
594) -> Result<Vec<(String, RegistrySlice)>> {
595    Ok(fleet_registry_by_origin(fleet, timeout)
596        .await?
597        .into_iter()
598        .map(|served| (served.slice.name.clone(), served.slice))
599        .collect())
600}
601
602/// As [`fleet_registry`], additionally yielding each reply's raw TOML text
603/// (the artifact the slice cache persists).
604///
605/// Also drops the origin — see [`fleet_registry_by_origin`], which is the
606/// call to reach for when *which host said this* is part of the question.
607pub async fn fleet_registry_raw(
608    fleet: &Fleet<'_>,
609    timeout: Duration,
610) -> Result<Vec<(RegistrySlice, String)>> {
611    Ok(fleet_registry_by_origin(fleet, timeout)
612        .await?
613        .into_iter()
614        .map(|served| (served.slice, served.raw))
615        .collect())
616}
617
618/// One producer's served registry slice, attributed to the host that
619/// answered (#385).
620///
621/// The origin cannot come from the slice: a slice is `include_str!` of a
622/// compiled registry file, and [`RegistrySlice::service_origin`] is `Some`
623/// only for a service — a host producer's origin is the host it runs on and
624/// is therefore not in the document. It comes from the reply's own key, the
625/// way RFC 05 §2.1 requires every fan-in answer to be attributed.
626#[derive(Debug, Clone)]
627#[non_exhaustive]
628pub struct ServedSlice {
629    /// The origin that answered — the `h-…` host id, or a verbatim service
630    /// origin. `"?"` when the reply key did not parse under this base, the
631    /// same lossy-but-stated convention [`FleetAnswer::origin`] uses.
632    pub origin: String,
633    /// The parsed slice. Its `name` is the producer, which is a different
634    /// question from `origin` and is why both are here.
635    pub slice: RegistrySlice,
636    /// The reply's raw TOML — the artifact the slice cache persists, since
637    /// slices do not re-serialize.
638    pub raw: String,
639}
640
641/// The fleet sweep, **keeping the origin that answered** (#385).
642///
643/// [`fleet_registry`] and [`fleet_registry_raw`] answer "what does this
644/// fleet serve", collapsing to one entry per producer; this answers "who
645/// served it", which is a different question and the only one that can
646/// express per-host drift. RFC 08 §6 promises exactly that capability of
647/// the introspect sweep — *which hosts still serve a deprecated subject,
648/// which run last month's registry* — and neither can be asked without the
649/// origin.
650///
651/// Nothing is deduplicated here: N hosts running one producer are N entries,
652/// which is the point. Feed it to [`crate::SliceSet::from_slices`] (or
653/// [`crate::SliceSet::from_bus`]) when a decoder needs one slice per
654/// producer instead — for *refining a key*, which host answered is
655/// genuinely irrelevant.
656pub async fn fleet_registry_by_origin(
657    fleet: &Fleet<'_>,
658    timeout: Duration,
659) -> Result<Vec<ServedSlice>> {
660    let repeating = RepeatingRegistry::declare(fleet, timeout).await?;
661
662    let slices = repeating.fetch_by_origin().await?;
663
664    repeating.undeclare().await?;
665
666    Ok(slices)
667}
668
669/// The registry sweep as a **declared** pair of queriers (#37) — for callers
670/// that re-run the sweep (`--watch topic list`, doctor's second pass, a GUI
671/// refresh). One-shot callers keep [`fleet_registry`].
672///
673/// Two queriers, not one: the wildcard-producer fan-out plus `@catalog` by
674/// name (a `*` never matches a verbatim origin, D4 — the two cannot
675/// double-count; same reasoning as [`fleet_registry`]).
676pub struct RepeatingRegistry {
677    wildcard: RepeatingQuery,
678    catalog: RepeatingQuery,
679}
680
681impl RepeatingRegistry {
682    pub async fn declare(fleet: &Fleet<'_>, timeout: Duration) -> Result<Self> {
683        // This session is un-namespaced on purpose (RFC 09 §5), so it must
684        // spell the base itself — exactly as `service call` composes its key.
685        let wildcard = fleet.wire(zenkey::selector::rpc(
686            zenkey::selector::Scope::fleet(),
687            zenkey::selector::Producers::all(),
688            &["introspect"],
689        ));
690        let catalog = fleet.wire(zenkey::selector::service_rpc(
691            &zenkey::ServiceOrigin::catalog(),
692            &["introspect"],
693        ));
694        Ok(RepeatingRegistry {
695            wildcard: declare_repeating(fleet, &wildcard, timeout).await?,
696            catalog: declare_repeating(fleet, &catalog, timeout).await?,
697        })
698    }
699
700    /// One sweep: every parsed slice with its raw TOML.
701    ///
702    /// Drops the answering origin. [`fetch_by_origin`](Self::fetch_by_origin)
703    /// is the same sweep keeping it, and is what a caller asking *which host*
704    /// wants (#385).
705    pub async fn fetch(&self) -> Result<Vec<(RegistrySlice, String)>> {
706        Ok(self
707            .fetch_by_origin()
708            .await?
709            .into_iter()
710            .map(|served| (served.slice, served.raw))
711            .collect())
712    }
713
714    /// One sweep, attributed: every parsed slice with the origin that served
715    /// it and its raw TOML (#385).
716    ///
717    /// A reply that does not parse is logged and skipped, never fatal — one
718    /// malformed producer must not blind the tool to every other producer's
719    /// slice. Nothing is deduplicated: a fleet mid-rollout serving three
720    /// versions of one producer yields three entries, and that disagreement
721    /// is the finding.
722    pub async fn fetch_by_origin(&self) -> Result<Vec<ServedSlice>> {
723        let mut slices = Vec::new();
724        for q in [&self.wildcard, &self.catalog] {
725            for answer in q.fetch().await? {
726                let origin = answer.origin;
727                let Answer::Value(bytes) = answer.answer else {
728                    continue;
729                };
730                let served_toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
731                match parse_slice(&served_toml) {
732                    Ok(slice) => slices.push(ServedSlice {
733                        origin,
734                        slice,
735                        raw: served_toml,
736                    }),
737                    Err(e) => tracing::warn!(
738                        origin = %origin,
739                        "introspect reply did not parse, skipping: {e}"
740                    ),
741                }
742            }
743        }
744        Ok(slices)
745    }
746
747    /// Undeclare both queriers, acknowledged.
748    ///
749    /// Both, even when the first refuses (#346): the wildcard sweep and the
750    /// `@catalog` ask are one teardown, and leaving the second declared
751    /// because the first would not go is the half-torn-down state
752    /// [`crate::Monitor::shutdown`] refuses. Failures are reported together.
753    pub async fn undeclare(self) -> Result<()> {
754        crate::bus::teardown::drain_undeclare(
755            vec![
756                ("wildcard introspect".to_string(), self.wildcard),
757                ("@catalog introspect".to_string(), self.catalog),
758            ],
759            RepeatingQuery::undeclare,
760        )
761        .await
762    }
763}
764
765/// One state sample from a snapshot GET.
766#[derive(Debug, Clone)]
767pub struct StateSample {
768    /// Full wire key.
769    pub key: String,
770    /// HLC timestamp, when the deployment stamps samples (RFC 04 §4
771    /// requires it for LWW to be meaningful — its absence is itself a
772    /// doctor-grade observation).
773    pub timestamp: Option<zenoh::time::Timestamp>,
774    pub payload_len: usize,
775}
776
777/// GET the current state under a selector with the fan-in discipline
778/// (target All, consolidation None) — the doctor's freshness check
779/// (RFC 04 §1.2) consumes the timestamps. Same chokepoint posture as
780/// [`fleet_get`]: no subcommand issues a raw `session.get`.
781///
782/// `max` bounds the samples **drained** (`doctor --sample N`): the loop
783/// stops reading at the cap, so a bounded sweep is cheaper, not merely
784/// quieter. `None` drains every reply.
785pub async fn state_snapshot(
786    session: &Session,
787    selector: &str,
788    timeout: Duration,
789    max: Option<usize>,
790) -> Result<Vec<StateSample>> {
791    let replies = disciplined_get(session, selector, &GetOpts::new(timeout))
792        .await
793        .map_err(|e| Error::bus("state snapshot", selector, e))?;
794    let mut out = Vec::new();
795    while let Ok(reply) = replies.recv_async().await {
796        if max.is_some_and(|m| out.len() >= m) {
797            break;
798        }
799        let Ok(sample) = reply.result() else { continue };
800        out.push(StateSample {
801            key: sample.key_expr().as_str().to_string(),
802            timestamp: sample.timestamp().copied(),
803            payload_len: sample.payload().len(),
804        });
805    }
806    Ok(out)
807}
808
809/// What one snapshot GET brought back (#219).
810#[derive(Debug, Default)]
811pub struct SnapshotReplies {
812    /// Every value reply as a [`SampleView`], with the replier's zenoh id
813    /// where the reply named one (`Reply::replier_id`, zenoh's unstable
814    /// surface). Not yet folded per key — that is [`crate::model::snapshot::fold_latest`]'s job,
815    /// and keeping the two apart is what lets the fold count what it
816    /// superseded.
817    pub values: Vec<(SampleView, Option<zenoh::config::ZenohId>)>,
818    /// Error replies (RFC 05 §3 envelopes): a refusal is not a value and not
819    /// silence, so it is counted rather than folded into either.
820    pub errors: u64,
821}
822
823/// GET a selector's current values with the fan-in discipline, keeping
824/// **everything a snapshot row needs** — the third sibling of [`fleet_get`]
825/// (which keeps the payload but not the timestamp) and [`state_snapshot`]
826/// (which keeps the timestamp but not the payload). RFC 13 §4.4's `.zsnap`
827/// wants both, plus the stamper and the replier, so this drains the channel
828/// into the same [`SampleView`] the seed path builds
829/// ([`SampleView::of`], the one conversion) and reads the replier id beside
830/// it.
831///
832/// Bounded by [`GetOpts::reply_bound`]; what the bound cost rides
833/// [`GetOpts::elided`], summed across every selector run under one `opts`
834/// (#339). Silence is not interpreted here (RFC 05 §3.1): an empty
835/// `values` is "nobody answered", and the caller decides what that means.
836pub async fn snapshot_get(
837    session: &Session,
838    selector: &str,
839    opts: &GetOpts,
840) -> Result<SnapshotReplies> {
841    let replies = disciplined_get(session, selector, opts)
842        .await
843        .map_err(|e| Error::bus("snapshot", selector, e))?;
844    let mut out = SnapshotReplies::default();
845    let mut elided = 0u64;
846    while let Ok(reply) = replies.recv_async().await {
847        let replier = reply.replier_id().map(|e| e.zid());
848        match reply.result() {
849            Ok(sample) => {
850                if out.values.len() >= opts.max_replies {
851                    elided += 1;
852                    continue;
853                }
854                out.values.push((SampleView::of(sample), replier));
855            }
856            Err(_) => out.errors += 1,
857        }
858    }
859    opts.note_elided(elided);
860    Ok(out)
861}
862
863/// One fetched value with its provenance.
864#[derive(Debug, Clone)]
865pub struct FetchedValue {
866    /// The concrete key the value arrived on.
867    pub key: String,
868    pub payload: zenoh::bytes::ZBytes,
869    pub encoding: String,
870    pub timestamp: Option<zenoh::time::Timestamp>,
871    /// The value's attachment, when the sample carried one (#117).
872    pub attachment: Option<zenoh::bytes::ZBytes>,
873    pub source: ValueSource,
874}
875
876/// The outcome: a value, or an attributed nothing.
877#[derive(Debug, Clone)]
878pub enum FetchOutcome {
879    Value(FetchedValue),
880    /// Every rung was tried and none answered — a non-verdict, stated with
881    /// exactly what was asked (RFC 05 §3.1: silence never becomes a claim
882    /// that no value exists).
883    None {
884        attempted: [&'static str; 3],
885    },
886}
887
888/// Fetch ladder bounds.
889#[derive(Debug, Clone, Copy)]
890pub struct FetchSpec {
891    /// Per-GET timeout (two GETs happen: concrete key, then `@adv` cache).
892    pub get_timeout: Duration,
893    /// The final subscribe-window rung's duration.
894    pub window: Duration,
895}
896
897impl Default for FetchSpec {
898    fn default() -> Self {
899        FetchSpec {
900            get_timeout: Duration::from_secs(2),
901            window: Duration::from_millis(1500),
902        }
903    }
904}
905
906/// Fetch one concrete key's current value **on demand** — the value half of
907/// the lazy-observation contract (issue #84): a selection retrieves one
908/// value; nothing is prefetched, nothing stays subscribed.
909///
910/// The ladder, each rung bounded:
911/// 1. GET the concrete key (storages answer; RFC 04 §3.2's "a plain GET does
912///    not reach publisher caches" is exactly why rung 2 exists);
913/// 2. GET `<key>/@adv/**?_max=1` — zenoh-ext's AdvancedPublisher cache
914///    declares its queryable there and replies with the cached sample on its
915///    own concrete key (`@adv` is verbatim, so no data selector ever collides
916///    with it — RFC 03 §4 D2 working in our favor);
917/// 3. a brief callback subscription on the key, first sample wins.
918///
919/// Several answers on a rung (multiple storages) resolve by latest HLC
920/// timestamp; unstamped answers lose to stamped ones (RFC 04 §1.2's LWW).
921pub async fn fetch_value(session: &Session, key: &str, spec: FetchSpec) -> Result<FetchOutcome> {
922    // Rung 1 + 2: bounded GETs.
923    if let Some(v) = fetch_stored(session, key, spec.get_timeout).await? {
924        return Ok(FetchOutcome::Value(v));
925    }
926
927    // Rung 3: a window. The subscriber is explicitly undeclared afterwards —
928    // the window closes, provably.
929    let (tx, rx) = tokio::sync::oneshot::channel::<FetchedValue>();
930    let tx = std::sync::Mutex::new(Some(tx));
931    let subscriber = crate::bus::teardown::declared(
932        "window subscribe",
933        key,
934        session.declare_subscriber(key).callback(move |sample| {
935            if let Some(tx) = tx.lock().expect("fetch window lock").take() {
936                let _ = tx.send(FetchedValue {
937                    key: sample.key_expr().as_str().to_string(),
938                    payload: sample.payload().clone(),
939                    encoding: sample.encoding().to_string(),
940                    timestamp: sample.timestamp().copied(),
941                    attachment: sample.attachment().cloned(),
942                    source: ValueSource::Window,
943                });
944            }
945        }),
946    )
947    .await?;
948    let caught = tokio::time::timeout(spec.window, rx).await;
949    subscriber
950        .undeclare()
951        .await
952        .map_err(|e| Error::bus("window undeclare", key, e))?;
953    if let Ok(Ok(v)) = caught {
954        return Ok(FetchOutcome::Value(v));
955    }
956
957    Ok(FetchOutcome::None {
958        attempted: ["get", "@adv cache", "subscribe window"],
959    })
960}
961
962/// The **stored** half of the [`fetch_value`] ladder, on its own: GET the
963/// concrete key (rung 1 — storages answer), then GET the `@adv` cache
964/// (rung 2). No subscriber is ever declared, so this is two bounded GETs
965/// and nothing on the data plane — the shape `zenctl why`'s default run
966/// needs (issue #214), where the subscribe window is an explicit opt-in.
967///
968/// `Ok(None)` is silence, and silence is never a verdict (RFC 05 §3.1): it
969/// means neither a storage nor a publisher cache *answered*, not that no
970/// value exists.
971pub async fn fetch_stored(
972    session: &Session,
973    key: &str,
974    get_timeout: Duration,
975) -> Result<Option<FetchedValue>> {
976    for (selector, source) in [
977        (key.to_string(), ValueSource::Storage),
978        (format!("{key}/@adv/**?_max=1"), ValueSource::Cache),
979    ] {
980        if let Some(v) = get_latest(session, &selector, source, get_timeout).await? {
981            return Ok(Some(v));
982        }
983    }
984    Ok(None)
985}
986
987async fn get_latest(
988    session: &Session,
989    selector: &str,
990    source: ValueSource,
991    timeout: Duration,
992) -> Result<Option<FetchedValue>> {
993    // `accept_any`: the @adv cache replies with the cached sample on the
994    // sample's OWN key — outside the `<key>/@adv/**` selector.
995    let replies = disciplined_get(session, selector, &GetOpts::new(timeout).accept_any())
996        .await
997        .map_err(|e| Error::bus("get", selector, e))?;
998    let mut candidates = Vec::new();
999    while let Ok(reply) = replies.recv_async().await {
1000        let Ok(sample) = reply.result() else { continue };
1001        candidates.push(FetchedValue {
1002            key: sample.key_expr().as_str().to_string(),
1003            payload: sample.payload().clone(),
1004            encoding: sample.encoding().to_string(),
1005            timestamp: sample.timestamp().copied(),
1006            attachment: sample.attachment().cloned(),
1007            source,
1008        });
1009    }
1010    Ok(pick_latest(candidates))
1011}
1012
1013/// The winner among several answers on one rung — RFC 04 §1.2's LWW, as a
1014/// pure function.
1015///
1016/// Latest HLC wins; a **stamped** answer beats an unstamped one whatever the
1017/// order they arrived in (a storage that does not stamp cannot outrank one
1018/// that does, and RFC 04 §4 is why an unstamped deployment is a doctor-grade
1019/// observation rather than a tie-break rule here). Ties keep the first
1020/// answer, which is the arrival order the channel gave us — arbitrary, but
1021/// stated.
1022///
1023/// Extracted from [`get_latest`] because a rule this quiet is exactly the
1024/// kind that stops being true: as a loop over a live reply channel it was
1025/// unreachable from a test.
1026fn pick_latest(candidates: impl IntoIterator<Item = FetchedValue>) -> Option<FetchedValue> {
1027    let mut best: Option<FetchedValue> = None;
1028    for candidate in candidates {
1029        best = Some(match best.take() {
1030            None => candidate,
1031            Some(cur) => match (cur.timestamp, candidate.timestamp) {
1032                (Some(a), Some(b)) if b > a => candidate,
1033                (None, Some(_)) => candidate,
1034                _ => cur,
1035            },
1036        });
1037    }
1038    best
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    fn stamp(secs: u64) -> zenoh::time::Timestamp {
1046        zenoh::time::Timestamp::new(
1047            zenoh::time::NTP64::from(Duration::from_secs(secs)),
1048            zenoh::time::TimestampId::rand(),
1049        )
1050    }
1051
1052    fn value(key: &str, timestamp: Option<zenoh::time::Timestamp>) -> FetchedValue {
1053        FetchedValue {
1054            key: key.to_string(),
1055            payload: zenoh::bytes::ZBytes::from(vec![0u8]),
1056            encoding: "application/json".to_string(),
1057            timestamp,
1058            attachment: None,
1059            source: ValueSource::Storage,
1060        }
1061    }
1062
1063    #[test]
1064    fn the_latest_hlc_wins_whatever_order_the_replies_arrived_in() {
1065        let pick = |order: [u64; 3]| {
1066            pick_latest(order.map(|s| value(&format!("k/{s}"), Some(stamp(s)))))
1067                .expect("three candidates")
1068                .key
1069        };
1070        assert_eq!(pick([1, 2, 3]), "k/3");
1071        assert_eq!(pick([3, 2, 1]), "k/3", "arrival order is not the rule");
1072        assert_eq!(pick([2, 3, 1]), "k/3");
1073    }
1074
1075    /// RFC 04 §1.2: a storage that does not stamp cannot outrank one that
1076    /// does — in either arrival order. That asymmetry is the whole reason
1077    /// this is not a `max_by_key` on the timestamp.
1078    #[test]
1079    fn a_stamped_answer_beats_an_unstamped_one_both_ways_round() {
1080        let stamped = || value("stamped", Some(stamp(7)));
1081        let bare = || value("bare", None);
1082        assert_eq!(pick_latest([bare(), stamped()]).unwrap().key, "stamped");
1083        assert_eq!(pick_latest([stamped(), bare()]).unwrap().key, "stamped");
1084    }
1085
1086    #[test]
1087    fn nothing_answered_is_nothing_picked_and_a_tie_keeps_the_first() {
1088        assert!(
1089            pick_latest(Vec::new()).is_none(),
1090            "silence is not a value (RFC 05 §3.1)"
1091        );
1092        let ts = stamp(4);
1093        assert_eq!(
1094            pick_latest([value("first", Some(ts)), value("second", Some(ts))])
1095                .unwrap()
1096                .key,
1097            "first",
1098            "equal stamps keep arrival order — arbitrary, but stated"
1099        );
1100        assert_eq!(
1101            pick_latest([value("first", None), value("second", None)])
1102                .unwrap()
1103                .key,
1104            "first",
1105            "two unstamped answers cannot be ordered; the first stands"
1106        );
1107    }
1108}