zenkey_fleet/bus/producer.rs
1//! Producer-side bring-up discipline (RFC 04 §5, RFC 05 §2.1/§3, RFC 08
2//! §6.1) — the MUSTs every producer owes the bus, as an API that makes the
3//! wrong shapes unrepresentable rather than a checklist that trusts review.
4//!
5//! The conformance review found these uniformly unenforced, with the
6//! in-repo dev tools modeling the violations; this module is the
7//! enforcement seam, and [`crate::bus::serve`]/[`crate::tape::generate`] now ride it
8//! or its rules. It lives beside — not inside — `serve.rs`, deliberately:
9//! `serve` is the *explorer's* mock (answer anything, wildcards welcome),
10//! while this is the *producer's* posture (concrete keys, ordered
11//! presence), and folding the strict API into the permissive one would
12//! give every mock a way to claim it is a producer.
13//!
14//! Three rules, three shapes:
15//!
16//! - **Order** ([`BringUp`]): a producer declares its queryables
17//! (`introspect`, `describe`, its procedures) **first**, and its `alive`
18//! liveliness token **last** — "alive ⇒ callable" (RFC 04 §5): callers
19//! attribute RPC silence against the roster, so a token that precedes
20//! the queryables manufactures false negatives. RFC 08 §6.1's bounded
21//! grace exists to tolerate declarations spawned concurrently at
22//! startup; this API sequences them instead, so there is no race for a
23//! checker to be graceful about. The token is only mintable by
24//! consuming the bring-up: alive-before-queryable does not typecheck.
25//! - **Vocabulary** ([`ReservedError`]): the RFC 05 §3 reserved error
26//! names as an enum, so a responder cannot misspell `error/unsupported`
27//! — and RFC 08 §6.1's conditional-procedure rule (declare always,
28//! answer `error/unsupported`/`error/gated` when gated) is writable
29//! without a string literal.
30//! - **Reply key** ([`Responder::reply`]): a procedure replies on its
31//! **own concrete key**, never by echoing `query.key_expr()` (RFC 05
32//! §2.1: consolidation keeps one reply *per reply key*, so a fleet
33//! echoing a shared wildcard selector consolidates down to one
34//! survivor). The responder holds its declared key and replies on it;
35//! the query's key is never consulted.
36
37use crate::{Error, Result};
38use zenoh::Session;
39use zenoh::handlers::FifoChannelHandler;
40use zenoh::liveliness::LivelinessToken;
41use zenoh::query::{Query, Queryable};
42
43/// The RFC 05 §3 reserved error vocabulary — the names every conforming
44/// caller understands, as a closed enum. Producer-specific names live under
45/// `error/<producer>/…` and are registered like subjects; these six are the
46/// convention's own.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum ReservedError {
49 /// `error/invalid-args` — the request payload does not parse or fails
50 /// validation.
51 InvalidArgs,
52 /// `error/unauthorized` — the caller may not invoke this procedure.
53 Unauthorized,
54 /// `error/not-found` — the named entity does not exist here.
55 NotFound,
56 /// `error/unsupported` — the capability is absent from this build
57 /// (RFC 08 §6.1: a conditional procedure still declares, and answers
58 /// with this — "producer present, capability not in this build →
59 /// rebuild").
60 Unsupported,
61 /// `error/busy` — present and capable, but not now.
62 Busy,
63 /// `error/gated` — the capability is built in but disabled here by
64 /// policy or configuration (RFC 05 §3: gated writes keep their gate at
65 /// the server; RFC 08 §6.1: "capability built in, disabled here →
66 /// reconfigure").
67 Gated,
68}
69
70impl ReservedError {
71 /// Every reserved name, for iteration (renderers, doctors).
72 pub const ALL: [ReservedError; 6] = [
73 ReservedError::InvalidArgs,
74 ReservedError::Unauthorized,
75 ReservedError::NotFound,
76 ReservedError::Unsupported,
77 ReservedError::Busy,
78 ReservedError::Gated,
79 ];
80
81 /// The wire name, namespaced like a key (RFC 05 §3).
82 pub fn name(self) -> &'static str {
83 match self {
84 ReservedError::InvalidArgs => "error/invalid-args",
85 ReservedError::Unauthorized => "error/unauthorized",
86 ReservedError::NotFound => "error/not-found",
87 ReservedError::Unsupported => "error/unsupported",
88 ReservedError::Busy => "error/busy",
89 ReservedError::Gated => "error/gated",
90 }
91 }
92
93 /// The RFC 05 §3 error envelope, `{ "error": <name>, "message": … }`,
94 /// serialized as JSON (the RFC shows the envelope as JSON for
95 /// readability; a deployment whose payload default differs encodes the
96 /// same two fields itself).
97 pub fn envelope(self, message: &str) -> Vec<u8> {
98 serde_json::to_vec(&serde_json::json!({
99 "error": self.name(),
100 "message": message,
101 }))
102 .expect("two string fields serialize")
103 }
104}
105
106/// One declared `@rpc` queryable, bound to its own concrete key — the only
107/// key it will ever reply on.
108pub struct Responder {
109 key: String,
110 queryable: Queryable<FifoChannelHandler<Query>>,
111}
112
113impl std::fmt::Debug for Responder {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 f.debug_struct("Responder")
116 .field("key", &self.key)
117 .finish_non_exhaustive()
118 }
119}
120
121impl Responder {
122 /// The concrete key this responder was declared on — and replies on.
123 pub fn key(&self) -> &str {
124 &self.key
125 }
126
127 /// The next query, or `None` once the queryable is gone.
128 pub async fn next(&self) -> Option<Query> {
129 self.queryable.recv_async().await.ok()
130 }
131
132 /// The queries as a [`Stream`](futures_core::Stream) (#343).
133 ///
134 /// Borrows, which is the point: `reply` and `reply_err` take `&self`, so
135 /// a query pulled from this stream can still be answered while the stream
136 /// is held — the same receive/answer split [`next`](Self::next) has.
137 pub fn stream(&self) -> impl futures_core::Stream<Item = Query> + '_ {
138 self.queryable.stream()
139 }
140
141 /// Reply a value on this responder's **own concrete key** (RFC 05
142 /// §2.1). `query.key_expr()` is deliberately never consulted: echoing
143 /// the query's selector puts a fleet's replies on one shared wildcard
144 /// key, and default consolidation keeps one survivor per reply key.
145 pub async fn reply(
146 &self,
147 query: &Query,
148 payload: Vec<u8>,
149 encoding: Option<&str>,
150 ) -> Result<()> {
151 self.reply_stamped(query, payload, encoding, None).await
152 }
153
154 /// [`reply`](Self::reply), with an explicit HLC when the producer mints
155 /// one (`session.new_timestamp()`) — the reply-side twin of
156 /// [`crate::Publication::send_stamped`].
157 ///
158 /// A deployment's `timestamping.enabled` stamps *publications* on the
159 /// way past; a query reply is stamped only when the responder stamps
160 /// it. That stamp is what gives a traced call (#215) its HLC reference:
161 /// without it every effect's `hlc_delta_ms` is absent, because the
162 /// caller's own session mints no HLC to measure from.
163 pub async fn reply_stamped(
164 &self,
165 query: &Query,
166 payload: Vec<u8>,
167 encoding: Option<&str>,
168 timestamp: Option<zenoh::time::Timestamp>,
169 ) -> Result<()> {
170 let reply = query.reply(self.key.clone(), payload);
171 let reply = match encoding {
172 Some(e) => reply.encoding(e),
173 None => reply,
174 };
175 let reply = match timestamp {
176 Some(ts) => reply.timestamp(ts),
177 None => reply,
178 };
179 reply.await.map_err(|e| Error::bus("reply", &self.key, e))
180 }
181
182 /// Refuse on Zenoh's reply-error channel with a [`ReservedError`]
183 /// envelope (RFC 05 §3: a value reply always means success; a failure
184 /// always rides `reply_err` — never a success payload carrying
185 /// `ok: false`).
186 pub async fn reply_err(
187 &self,
188 query: &Query,
189 error: ReservedError,
190 message: &str,
191 ) -> Result<()> {
192 query
193 .reply_err(error.envelope(message))
194 .encoding("application/json")
195 .await
196 .map_err(|e| Error::bus("reply_err", &self.key, e))
197 }
198
199 /// Undeclare, acknowledged.
200 pub async fn undeclare(self) -> Result<()> {
201 self.queryable
202 .undeclare()
203 .await
204 .map_err(|e| Error::bus("undeclare", &self.key, e))
205 }
206}
207
208/// The ordered bring-up (RFC 04 §5): queryables first, `alive` last — as
209/// one API in which the wrong order is unrepresentable.
210///
211/// ```no_run
212/// # async fn demo(session: zenoh::Session) -> zenkey_fleet::Result<()> {
213/// let mut up = zenkey_fleet::bus::producer::BringUp::new(&session);
214/// up.serve("v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect").await?;
215/// up.serve("v1/h-3fa9c2d41b7e/@rpc/sysinfo/describe").await?;
216/// // The token is mintable only by consuming the bring-up: every
217/// // queryable above is declared (awaited, not spawned) before it.
218/// let live = up.alive("v1/h-3fa9c2d41b7e/state/sysinfo/alive").await?;
219/// # let _ = live; Ok(()) }
220/// ```
221///
222/// Keys are written base-relative here because a *producer's* session is
223/// namespaced (the deployment base is session config, RFC 03 §1.1); an
224/// un-namespaced explorer passes full wire keys, which is equally fine —
225/// the API is a string seam either way, concreteness enforced.
226#[derive(Debug)]
227pub struct BringUp<'a> {
228 session: &'a Session,
229 responders: Vec<Responder>,
230}
231
232impl<'a> BringUp<'a> {
233 /// Start a bring-up: nothing is declared yet, and no `alive` token can
234 /// exist before [`alive`](Self::alive) consumes this value.
235 pub fn new(session: &'a Session) -> Self {
236 BringUp {
237 session,
238 responders: Vec::new(),
239 }
240 }
241
242 /// Declare one `@rpc` queryable on the producer's **own concrete key**,
243 /// awaited before return (no spawn race, so RFC 08 §6.1's bounded
244 /// grace has nothing to tolerate). Refused:
245 ///
246 /// - a wildcard key — a producer serves its own concrete keys, and a
247 /// concrete declared key is what makes [`Responder::reply`]'s
248 /// reply-key discipline meaningful (RFC 05 §2.1);
249 /// - `complete` is not a parameter at all: `@rpc` queryables are
250 /// **never** declared complete (RFC 05 §2.1 — one complete queryable
251 /// short-circuits `BestMatching` callers to a single reply).
252 pub async fn serve(&mut self, key: &str) -> Result<&Responder> {
253 let parsed = zenoh::key_expr::KeyExpr::try_from(key.to_string())
254 .map_err(|e| Error::bus("declare queryable", key, e))?;
255 if parsed.is_wild() {
256 // The caller handed us the key; nothing was declared.
257 return Err(Error::unaskable(
258 key.to_string(),
259 "a producer serves its own concrete key, never a wildcard \
260 (RFC 05 §2.1 — replies are attributed by their concrete reply \
261 key)",
262 ));
263 }
264 let queryable = crate::bus::teardown::declared(
265 "declare queryable",
266 key,
267 self.session.declare_queryable(parsed).complete(false),
268 )
269 .await?;
270 self.responders.push(Responder {
271 key: key.to_string(),
272 queryable,
273 });
274 Ok(self.responders.last().expect("just pushed"))
275 }
276
277 /// Declare the `alive` liveliness token — consuming the bring-up, so
278 /// the token structurally cannot precede the queryables ("alive ⇒
279 /// callable", RFC 04 §5).
280 pub async fn alive(self, alive_key: &str) -> Result<LiveProducer> {
281 let token = crate::bus::teardown::declared(
282 "declare alive token",
283 alive_key,
284 self.session
285 .liveliness()
286 .declare_token(alive_key.to_string()),
287 )
288 .await?;
289 Ok(LiveProducer {
290 token: Some(token),
291 responders: self.responders,
292 })
293 }
294
295 /// Finish **without** presence: hand back the declared responders and
296 /// never mint a token. This is the mock/synthetic path ([`crate::
297 /// generate`]'s impersonated producers, RFC 13 §5) — a tool that
298 /// answers for a producer must not also claim its presence. A real
299 /// producer wants [`alive`](Self::alive).
300 pub fn without_alive(self) -> Vec<Responder> {
301 self.responders
302 }
303}
304
305/// A producer that is up: queryables declared, then `alive` — held while
306/// serving.
307#[derive(Debug)]
308pub struct LiveProducer {
309 token: Option<LivelinessToken>,
310 /// The declared responders, in bring-up order — drive them
311 /// ([`Responder::next`]) to serve.
312 pub responders: Vec<Responder>,
313}
314
315impl LiveProducer {
316 /// Retire in the reverse of bring-up: retract `alive` **first** (the
317 /// roster must stop attributing silence to a producer that is going
318 /// away), then undeclare the queryables, acknowledged.
319 ///
320 /// A token that will not retract still bails before the queryables, and
321 /// deliberately: "alive ⇒ callable" (RFC 04 §5) is a claim that outlives
322 /// this call, and stripping callability while presence stands would
323 /// manufacture exactly the false negative the ordering exists to prevent.
324 /// The queryables themselves drain (#346): one that will not undeclare
325 /// must not leave the rest declared, and the failures are reported
326 /// together — the `bus::teardown` shape, shared with the monitor and the
327 /// replayer.
328 pub async fn retire(mut self) -> Result<()> {
329 if let Some(token) = self.token.take() {
330 token
331 .undeclare()
332 .await
333 .map_err(|e| Error::bus("retract alive token", "", e))?;
334 }
335 let declared: Vec<(String, Responder)> = self
336 .responders
337 .drain(..)
338 .map(|r| (r.key.clone(), r))
339 .collect();
340 crate::bus::teardown::drain_undeclare(declared, Responder::undeclare).await
341 }
342}