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