Skip to main content

zenkey_fleet/
query.rs

1//! The fan-in query discipline (RFC 05 §2.1) — moved verbatim from
2//! zenctl's `bus.rs`; this stays the single chokepoint for fleet GETs.
3
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use zenkey::grammar::with_base;
8use zenkey::{RegistrySlice, parse_slice};
9use zenoh::Session;
10use zenoh::query::{ConsolidationMode, QueryTarget};
11
12/// How a producer answered a procedure call.
13pub enum Answer {
14    /// A value reply — RFC 05 §3: "a reply always indicates success".
15    /// Carried as zenoh's refcounted buffer: cloning is a refcount bump,
16    /// and consumers decode via `reader()`/`to_bytes()` (a `Cow` — it
17    /// copies only when the payload arrived fragmented). Report §14's
18    /// zero-copy discipline: the old `to_bytes().to_vec()` double copy per
19    /// reply is retired.
20    Value(zenoh::bytes::ZBytes),
21    /// An error reply (`reply_err`), carrying the `{error, message}` envelope
22    /// when it parses. RFC 05 §3: "an error always indicates failure".
23    Error { name: String, message: String },
24}
25
26/// One host's answer, attributed to the origin that actually replied.
27pub struct FleetAnswer {
28    pub origin: String,
29    pub answer: Answer,
30}
31
32/// Call a procedure and collect **every** reply, attributed by origin.
33///
34/// The three things RFC 05 §2.1 requires, in the one place they cannot be
35/// forgotten:
36///
37/// 1. **target = All.** The default `BestMatching` short-circuits to a single
38///    queryable the moment any matching one is declared `complete` — "one
39///    storage config away from silently collapsing the fleet to one reply".
40/// 2. **consolidation = None.** Default consolidation keeps one reply *per
41///    reply key*; belt-and-braces against a producer that wrongly echoes the
42///    wildcard selector instead of replying on its own concrete key.
43/// 3. **Attribution by the reply's own key**, never by the key we asked on —
44///    that is what makes `*`-origin fan-out legible.
45///
46/// Silence is deliberately *not* interpreted here (RFC 05 §3.1: "no reply" is
47/// not one condition). Callers that need a verdict join this against the
48/// liveliness roster; see `cmd::doctor`.
49pub async fn fleet_get(
50    session: &Session,
51    base: &str,
52    key: &str,
53    payload: Option<Vec<u8>>,
54    timeout: Duration,
55) -> Result<Vec<FleetAnswer>> {
56    let mut builder = session
57        .get(key)
58        .target(QueryTarget::All)
59        .consolidation(ConsolidationMode::None)
60        .timeout(timeout);
61    if let Some(body) = payload {
62        builder = builder.payload(body);
63    }
64    let replies = builder
65        .await
66        .map_err(|e| anyhow::anyhow!("{e}"))
67        .with_context(|| format!("query failed: {key}"))?;
68
69    let mut out = Vec::new();
70    while let Ok(reply) = replies.recv_async().await {
71        match reply.result() {
72            Ok(sample) => {
73                let origin = origin_of(base, sample.key_expr().as_str());
74                out.push(FleetAnswer {
75                    origin,
76                    answer: Answer::Value(sample.payload().clone()),
77                });
78            }
79            Err(err) => {
80                // The error envelope is `{ "error": "<name>", "message": "…" }`
81                // (RFC 05 §3), with reserved names like `error/not-found`. If it
82                // does not parse we still surface the bytes — an unreadable
83                // refusal is still a refusal.
84                let bytes = err.payload().to_bytes();
85                let (name, message) = match serde_json::from_slice::<serde_json::Value>(&bytes) {
86                    Ok(v) => (
87                        v.get("error")
88                            .and_then(|e| e.as_str())
89                            .unwrap_or("error/unparsed")
90                            .to_string(),
91                        v.get("message")
92                            .and_then(|m| m.as_str())
93                            .unwrap_or_default()
94                            .to_string(),
95                    ),
96                    Err(_) => (
97                        "error/unparsed".to_string(),
98                        String::from_utf8_lossy(&bytes).to_string(),
99                    ),
100                };
101                // An error reply has no sample, so no concrete key to attribute
102                // by; zenoh does not surface the responder here.
103                out.push(FleetAnswer {
104                    origin: "?".to_string(),
105                    answer: Answer::Error { name, message },
106                });
107            }
108        }
109    }
110    Ok(out)
111}
112
113/// The origin chunk of a wire key, via the grammar (never by index — RFC 03
114/// §1.1: positions are relative to the configured base).
115fn origin_of(base: &str, key: &str) -> String {
116    zenkey::grammar::parse_full(base, key)
117        .map(|k| k.origin.chunk().to_string())
118        .unwrap_or_else(|| "?".to_string())
119}
120
121/// Discover every live producer's registry slice **from the bus**, with nothing
122/// compiled in (RFC 08 §6: "generic explorer tooling … needs no compiled-in
123/// registry").
124///
125/// Every producer MUST serve its registry slice as TOML on
126/// `@rpc/<producer>/introspect`. This fans one wildcard-producer `introspect`
127/// GET across the fleet — `<base>/v1/*/@rpc/*/introspect` — and parses each
128/// reply. It is the same introspect+`parse_slice` path `doctor` walks, minus
129/// the compiled-in diff: here the served slice *is* the answer.
130///
131/// A reply that does not parse is reported to stderr and skipped, never fatal:
132/// one malformed producer must not blind the tool to every other producer's
133/// slice. The tuple's first element is the producer (or service) base name the
134/// slice declares (`slice.name`), matching the compiled path's producer column.
135///
136/// A verbatim service origin is unmatchable by the `*` of a fleet selector
137/// (grammar property D4), so the wildcard sweep cannot enumerate services.
138/// The well-known `@catalog` identity service (RFC 06 §5) is therefore asked
139/// by name, exactly as [`crate::roster()`] does for its alive token; other
140/// service origins remain reachable only via local registry files
141/// (`doctor --registry` asks each declared `service_origin` by name).
142pub async fn fleet_registry(
143    session: &Session,
144    base: &str,
145    timeout: Duration,
146) -> Result<Vec<(String, RegistrySlice)>> {
147    Ok(fleet_registry_raw(session, base, timeout)
148        .await?
149        .into_iter()
150        .map(|(slice, _)| (slice.name.clone(), slice))
151        .collect())
152}
153
154/// As [`fleet_registry`], additionally yielding each reply's raw TOML text
155/// (the artifact the slice cache persists).
156pub async fn fleet_registry_raw(
157    session: &Session,
158    base: &str,
159    timeout: Duration,
160) -> Result<Vec<(RegistrySlice, String)>> {
161    // This session is un-namespaced on purpose (RFC 09 §5), so it must
162    // spell the base itself — exactly as `service call` composes its full key.
163    // Two GETs: the wildcard-producer fan-out, plus `@catalog` by name (a `*`
164    // never matches a verbatim origin, D4 — the two cannot double-count).
165    let keys = [
166        with_base(base, zenkey::selector::fleet_rpc("*", &["introspect"])),
167        with_base(
168            base,
169            zenkey::selector::service_rpc(&zenkey::ServiceOrigin::catalog(), &["introspect"]),
170        ),
171    ];
172    let mut slices = Vec::new();
173    for key in keys {
174        let answers = fleet_get(session, base, &key, None, timeout).await?;
175        for answer in answers {
176            let Answer::Value(bytes) = answer.answer else {
177                continue;
178            };
179            let served_toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
180            match parse_slice(&served_toml) {
181                Ok(slice) => slices.push((slice, served_toml)),
182                Err(e) => tracing::warn!(
183                    origin = %answer.origin,
184                    "introspect reply did not parse, skipping: {e}"
185                ),
186            }
187        }
188    }
189    Ok(slices)
190}
191
192/// One state sample from a snapshot GET.
193#[derive(Debug, Clone)]
194pub struct StateSample {
195    /// Full wire key.
196    pub key: String,
197    /// HLC timestamp, when the deployment stamps samples (RFC 04 §4
198    /// requires it for LWW to be meaningful — its absence is itself a
199    /// doctor-grade observation).
200    pub timestamp: Option<zenoh::time::Timestamp>,
201    pub payload_len: usize,
202}
203
204/// GET the current state under a selector with the fan-in discipline
205/// (target All, consolidation None) — the doctor's freshness check
206/// (RFC 04 §1.2) consumes the timestamps. Same chokepoint posture as
207/// [`fleet_get`]: no subcommand issues a raw `session.get`.
208pub async fn state_snapshot(
209    session: &Session,
210    selector: &str,
211    timeout: Duration,
212) -> Result<Vec<StateSample>> {
213    let replies = session
214        .get(selector)
215        .target(QueryTarget::All)
216        .consolidation(ConsolidationMode::None)
217        .timeout(timeout)
218        .await
219        .map_err(|e| anyhow::anyhow!("{e}"))
220        .with_context(|| format!("state snapshot failed: {selector}"))?;
221    let mut out = Vec::new();
222    while let Ok(reply) = replies.recv_async().await {
223        let Ok(sample) = reply.result() else { continue };
224        out.push(StateSample {
225            key: sample.key_expr().as_str().to_string(),
226            timestamp: sample.timestamp().copied(),
227            payload_len: sample.payload().len(),
228        });
229    }
230    Ok(out)
231}