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}