zenkey_fleet/model/decode.rs
1//! The schema-aware decode seam (issues #11/#15): wire key → type name →
2//! served schema → named-field JSON, with the honest fallbacks a generic
3//! tool owes its user.
4//!
5//! [`SchemaStore`] caches each producer's served `describe` reply (RFC 08
6//! §7) and fetches on first miss through a declared
7//! [`crate::bus::query::RepeatingQuery`] (the RFC 05 §2.1 discipline, kept warm
8//! across the negative-TTL re-asks — #37). [`decode_sample`] is the whole
9//! pipeline in one call; encoding resolution is **sample > registry > sniff**
10//! and the sniff never goes away.
11
12use std::sync::Mutex;
13use std::sync::atomic::Ordering;
14use std::time::Duration;
15
16use crate::model::bounded::BoundedLru;
17
18use crate::Result;
19use zenkey::schema::decode::{DecodeError, DecodedPayload, DecoderRegistry};
20use zenkey::schema::validate::{NotValidated, Verdict};
21use zenkey::schema::{SchemaSet, TypeSchema, WireEncoding};
22use zenoh::Session;
23
24use crate::model::registry::SliceSet;
25use crate::report::{DriftVerdict, SchemaDrift, SchemaServer, TotalityGap};
26
27/// How many producers one store remembers anything about (#340).
28///
29/// The keys these maps are built from come off the wire —
30/// `parse_full(base, key)` over whatever traffic an explorer happens to
31/// watch — not from a trusted enumeration, so "a fleet's producer set is
32/// small" is an assumption about well-behaved traffic and not a bound. 1024
33/// is far past any fleet the reference application has, and far short of
34/// what a runaway key family could mint in an overnight session.
35pub const DEFAULT_MAX_PRODUCERS: usize = 1_024;
36
37/// What one store's bounds have cost, as of one read (#340, RFC 13 §3 O6).
38///
39/// Three numbers, not one, because they are three different facts and only
40/// the first hides anything: an evicted **set** is a schema the next sample
41/// of that producer must re-ask for; an evicted **querier** is routing state
42/// that gets re-declared; an evicted **gate** is at worst one duplicate GET.
43/// Folding them would report a re-declared querier as lost knowledge.
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
45pub struct StoreBounds {
46 /// The producer bound in force.
47 pub max_producers: usize,
48 /// Producers currently answered-for — the length of [`SchemaStore::known`].
49 pub producers: usize,
50 /// Cached `describe` answers dropped under the bound. Non-zero means a
51 /// decode may re-ask for something this store had already learned.
52 pub sets_evicted: u64,
53 /// Declared queriers dropped under the bound.
54 pub queriers_evicted: u64,
55 /// Single-flight gates dropped under the bound.
56 pub gates_evicted: u64,
57}
58
59/// One entry with the recency `BoundedLru` orders by.
60///
61/// A monotone counter rather than a clock, exactly as
62/// [`FactsCache`](crate::model::facts::FactsCache) does it: these entries have
63/// no timestamp of their own, and "least recently *used*" is the property
64/// that matters — a producer being decoded right now must outlive one seen
65/// once an hour ago.
66#[derive(Debug)]
67struct Entry<V> {
68 value: V,
69 seen: u64,
70}
71
72/// Per-producer schema sets, fetched lazily and cached for the process.
73///
74/// **Bounded** (#340). All three maps are keyed by a producer name lifted out
75/// of arbitrary bus traffic, so all three are `BoundedLru` at
76/// [`DEFAULT_MAX_PRODUCERS`], and each keeps its own eviction count —
77/// [`SchemaStore::bounds`], beside [`SchemaStore::known`].
78pub struct SchemaStore {
79 base: String,
80 timeout: Duration,
81 /// producer → what we know about its `describe` (see [`Cached`]).
82 ///
83 /// A served set is behind an `Arc` because it is read **per sample**:
84 /// handing out a deep clone of every type's document to answer "what is
85 /// the schema for this one type" was the other half of issue #100's cost,
86 /// and the quieter half — a descriptor pool rebuild at least looks
87 /// expensive.
88 sets: Mutex<BoundedLru<String, Entry<Cached>>>,
89 /// One declared querier per producer's describe key (#37), reused across
90 /// the negative-TTL re-asks.
91 queriers: Mutex<BoundedLru<String, Entry<std::sync::Arc<crate::bus::query::RepeatingQuery>>>>,
92 /// One in-flight `describe` per producer. A hot bus misses on many
93 /// samples of the same producer at once — the first sample's GET is
94 /// still on the wire when the second arrives — and the store used to
95 /// fan one GET per miss at a producer that had been asked microseconds
96 /// earlier. The losers wait on the winner's gate and then read its
97 /// answer out of `sets`, so the fleet sees exactly one ask.
98 inflight: Mutex<BoundedLru<String, Entry<std::sync::Arc<tokio::sync::Mutex<()>>>>>,
99 /// The recency clock all three maps order by, and their three ledgers.
100 clock: std::sync::atomic::AtomicU64,
101 sets_evicted: std::sync::atomic::AtomicU64,
102 queriers_evicted: std::sync::atomic::AtomicU64,
103 gates_evicted: std::sync::atomic::AtomicU64,
104 /// Behind a lock because registration is a `&self` act: the store is
105 /// shared through an `Arc` by every frontend that has one, and a
106 /// `&mut self` setter on it is unreachable by construction. Read-locked
107 /// per decode, which is the same order of cost as the `sets` lookup that
108 /// preceded it.
109 decoders: std::sync::RwLock<DecoderRegistry>,
110 /// While set, a **decode** answers from the cache or not at all — see
111 /// [`SchemaStore::seal`] (#337).
112 sealed: std::sync::atomic::AtomicBool,
113}
114
115/// A sealed store, for as long as this guard lives ([`SchemaStore::seal`]).
116///
117/// A guard rather than a pair of calls because every judging window has
118/// `?`-shaped ways out, and a store left sealed by an early return would
119/// answer `NoSchema` for the rest of the process.
120pub struct Sealed<'a> {
121 store: &'a SchemaStore,
122}
123
124impl Drop for Sealed<'_> {
125 fn drop(&mut self) {
126 self.store
127 .sealed
128 .store(false, std::sync::atomic::Ordering::Release);
129 }
130}
131
132/// How long "asked, and answered with nothing usable" stays authoritative
133/// before re-asking. A producer that genuinely serves no `describe` must not
134/// be re-asked per sample, and 60s is the bound for that.
135const NOT_SERVED_TTL: Duration = Duration::from_secs(60);
136
137/// The first backoff after a GET that drew **zero replies** (issue #101).
138///
139/// Zero replies is the RFC 05 §3.1 non-verdict this codebase refuses to treat
140/// as an answer anywhere else, and it is what an explorer started before its
141/// fleet sees. Doubling from here, capped at [`NOT_SERVED_TTL`], means a
142/// routing race resolves in well under a second while a producer that is
143/// simply absent still converges on the same 60s bound.
144const NO_REPLY_BACKOFF: Duration = Duration::from_millis(250);
145
146/// Why a producer has no cached set, which decides how soon we re-ask.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum MissReason {
149 /// The GET returned no replies at all. Nobody said anything — including
150 /// "no". Could be a producer that does not exist, or a connector whose
151 /// GET went out before the producer's queryable was routable.
152 NoReplies,
153 /// Somebody replied, and nothing in the replies parsed as a `SchemaSet`.
154 /// That *is* an answer about this producer, and it earns the full TTL.
155 AnsweredUnusable,
156}
157
158/// A producer we asked and got nothing usable from.
159#[derive(Debug, Clone, Copy)]
160struct Missing {
161 reason: MissReason,
162 asked: std::time::Instant,
163 /// Consecutive zero-reply asks, driving the backoff.
164 attempts: u32,
165}
166
167impl Missing {
168 /// How long this miss stays authoritative before the next ask.
169 fn backoff(&self) -> Duration {
170 match self.reason {
171 MissReason::AnsweredUnusable => NOT_SERVED_TTL,
172 MissReason::NoReplies => NO_REPLY_BACKOFF
173 .saturating_mul(1u32 << self.attempts.saturating_sub(1).min(16))
174 .min(NOT_SERVED_TTL),
175 }
176 }
177
178 fn may_reask(&self) -> bool {
179 self.asked.elapsed() >= self.backoff()
180 }
181}
182
183/// What the store knows about one producer's `describe`.
184enum Cached {
185 Served(std::sync::Arc<SchemaSet>),
186 Missing(Missing),
187}
188
189/// What the cached state answers on its own, before any GET.
190enum Lookup {
191 /// The cache is authoritative: the served set, or `None` for a miss
192 /// still inside its backoff.
193 Answered(Option<std::sync::Arc<SchemaSet>>),
194 /// Nothing authoritative — ask, carrying this many consecutive
195 /// zero-reply asks into the backoff.
196 Ask(u32),
197}
198
199/// What one `describe` GET produced — the distinction issue #101 exists for.
200enum Fetched {
201 Served(SchemaSet),
202 NoReplies,
203 AnsweredUnusable,
204}
205
206impl SchemaStore {
207 pub fn new(base: impl Into<String>, timeout: Duration) -> Self {
208 SchemaStore::bounded(base, timeout, DEFAULT_MAX_PRODUCERS)
209 }
210
211 /// A store that remembers at most `max_producers` producers (#340).
212 pub fn bounded(base: impl Into<String>, timeout: Duration, max_producers: usize) -> Self {
213 SchemaStore {
214 base: base.into(),
215 timeout,
216 sets: Mutex::new(BoundedLru::with_capacity(max_producers)),
217 queriers: Mutex::new(BoundedLru::with_capacity(max_producers)),
218 inflight: Mutex::new(BoundedLru::with_capacity(max_producers)),
219 decoders: std::sync::RwLock::new(DecoderRegistry::new()),
220 sealed: std::sync::atomic::AtomicBool::new(false),
221 clock: std::sync::atomic::AtomicU64::new(0),
222 sets_evicted: std::sync::atomic::AtomicU64::new(0),
223 queriers_evicted: std::sync::atomic::AtomicU64::new(0),
224 gates_evicted: std::sync::atomic::AtomicU64::new(0),
225 }
226 }
227
228 /// The next recency stamp. Monotone and shared by all three maps: they
229 /// are three views of the same producer set, and ordering them on one
230 /// clock keeps "least recently used" meaning the same thing in each.
231 fn tick(&self) -> u64 {
232 self.clock.fetch_add(1, Ordering::Relaxed)
233 }
234
235 /// What the bounds hold and what they have cost (#340, RFC 13 §3 O6).
236 ///
237 /// Read it beside [`known`](Self::known): that says what the store can
238 /// answer for, this says what it stopped being able to answer for.
239 pub fn bounds(&self) -> StoreBounds {
240 let sets = self.sets.lock().expect("store lock");
241 StoreBounds {
242 max_producers: sets.max_keys(),
243 producers: sets.len(),
244 sets_evicted: self.sets_evicted.load(Ordering::Relaxed),
245 queriers_evicted: self.queriers_evicted.load(Ordering::Relaxed),
246 gates_evicted: self.gates_evicted.load(Ordering::Relaxed),
247 }
248 }
249
250 /// Stop **decodes** from going to the bus until the guard drops (#337).
251 ///
252 /// A judging window's drain loop calls [`decode_sample`] per sample, and
253 /// on a cache miss that used to be a `describe` GET, awaited inside the
254 /// loop, bounded by this store's timeout. Nobody drains the monitor's
255 /// bounded broadcast while it is in flight, so the window loses samples
256 /// to its own decode — and loses them twice over, because the window's
257 /// deadline does not extend to cover the wait. Self-inflicted
258 /// `Dropped(n)` in the one place where the whole product is a verdict
259 /// about a window (RFC 13 §3 O6).
260 ///
261 /// Sealed, a miss is simply a miss: [`set_for`](Self::set_for) answers
262 /// from the cache or returns `None`, which reads through as
263 /// `NotValidated(NoSchema)` — "asked, none served" — and records nothing,
264 /// because a seal is a fact about the observer, not about the producer.
265 ///
266 /// It does **not** stop the store talking to the fleet: [`prewarm`] still
267 /// asks. That is the distinction — a deliberate ask, made where the
268 /// caller has decided it is safe to wait, is fine; an incidental one from
269 /// inside a drain loop is not.
270 pub fn seal(&self) -> Sealed<'_> {
271 self.sealed
272 .store(true, std::sync::atomic::Ordering::Release);
273 Sealed { store: self }
274 }
275
276 /// Register a custom kind's codec (RFC 08 §7 is open to kinds beyond the
277 /// built-ins; later registrations win on conflict).
278 ///
279 /// Takes `&self`, unlike the `decoders_mut` it replaces: every frontend
280 /// shares one store through an `Arc`, so a `&mut self` setter could only
281 /// be called before the store was shared — which is to say, not by the
282 /// code that has the store.
283 pub fn register_decoder(&self, decoder: Box<dyn zenkey::schema::decode::PayloadDecoder>) {
284 self.decoders
285 .write()
286 .expect("decoder lock")
287 .register(decoder);
288 }
289
290 /// Pre-warm one producer's served set with a `describe` reply the caller
291 /// already holds (RFC 08 §7).
292 ///
293 /// The doctor fetches every producer's describe document in its GET
294 /// phase and then opens a listen window; without this the window's store
295 /// starts empty and re-asks the fleet, mid-window, for documents the
296 /// same run already has — load this tool put on the fleet for nothing.
297 ///
298 /// Authoritative, not a hint: it overwrites whatever the store held,
299 /// including a negative entry still inside its backoff.
300 pub fn insert(&self, producer: impl Into<String>, set: SchemaSet) {
301 self.remember(producer.into(), Cached::Served(std::sync::Arc::new(set)));
302 }
303
304 /// Put one producer's cache entry in, under the bound, counting what the
305 /// bound refused (#340).
306 fn remember(&self, producer: String, cached: Cached) {
307 let seen = self.tick();
308 let mut sets = self.sets.lock().expect("store lock");
309 // Only a *new* producer needs room made: overwriting one that is
310 // already held does not grow the map, and evicting for it would drop
311 // a stranger's entry to make space that was never needed.
312 if sets.get(producer.as_str()).is_none() {
313 let dropped = sets.admit(|e| e.seen) as u64;
314 if dropped > 0 {
315 self.sets_evicted.fetch_add(dropped, Ordering::Relaxed);
316 }
317 }
318 sets.insert(
319 producer,
320 Entry {
321 value: cached,
322 seen,
323 },
324 );
325 }
326
327 /// The schema for `type_name` as served by `producer`, fetching
328 /// `@rpc/<producer>/describe` on first miss. `None` = the producer does
329 /// not serve describe or does not describe this type — render
330 /// structurally (never an error; RFC 08 §7 is a SHOULD for
331 /// self-describing encodings).
332 ///
333 /// A bare `&Session` rather than a [`crate::Fleet`]: the store was
334 /// constructed with the base and composes the `Fleet` itself, so a
335 /// caller cannot hand it a *second* base for the two to disagree over.
336 /// Same for [`set_for`](Self::set_for) and everything built on them.
337 pub async fn schema_for(
338 &self,
339 session: &Session,
340 producer: &str,
341 type_name: &str,
342 ) -> Option<TypeSchema> {
343 self.set_for(session, producer)
344 .await
345 .and_then(|set| set.get(type_name).cloned())
346 }
347
348 /// The producer's **whole** served set, on the same fetch-and-cache path
349 /// as [`schema_for`](Self::schema_for) (issue #51: `zenctl schema show
350 /// <producer>` dumps the inventory, and asking type-by-type would be a
351 /// different question than the one `describe` answers).
352 ///
353 /// `None` = the producer does not serve `describe` — an honest
354 /// degradation, never an error.
355 pub async fn set_for(
356 &self,
357 session: &Session,
358 producer: &str,
359 ) -> Option<std::sync::Arc<SchemaSet>> {
360 let may_ask = !self.sealed.load(std::sync::atomic::Ordering::Acquire);
361 self.set_for_within(session, producer, may_ask).await
362 }
363
364 /// [`set_for`](Self::set_for), stating whether this caller is allowed to
365 /// go to the bus. The seal is a caller-level policy (#337), so the one
366 /// path that is *meant* to ask — [`prewarm`] — passes `true` regardless.
367 async fn set_for_within(
368 &self,
369 session: &Session,
370 producer: &str,
371 may_ask: bool,
372 ) -> Option<std::sync::Arc<SchemaSet>> {
373 if let Lookup::Answered(hit) = self.lookup(producer) {
374 return hit;
375 }
376 if !may_ask {
377 // Sealed: a miss stays a miss, and nothing is recorded — the
378 // store learned nothing about this producer, and a negative entry
379 // would outlive the window that refused to ask.
380 return None;
381 }
382 // Singleflight: hold the producer's gate for the duration of the ask.
383 let gate = self.gate_for(producer);
384 let _held = gate.lock().await;
385 // Whoever held the gate before us has already written its answer —
386 // served or missing — so ask only if the cache is still undecided.
387 // This is the whole point of the gate: the waiters pay a lock, not a
388 // GET.
389 let attempts = match self.lookup(producer) {
390 Lookup::Answered(hit) => return hit,
391 Lookup::Ask(attempts) => attempts,
392 };
393 let entry = match self.fetch(session, producer).await {
394 Fetched::Served(set) => Cached::Served(std::sync::Arc::new(set)),
395 Fetched::NoReplies => Cached::Missing(Missing {
396 reason: MissReason::NoReplies,
397 asked: std::time::Instant::now(),
398 attempts: attempts.saturating_add(1),
399 }),
400 // An answer resets the streak: this is a verdict about the
401 // producer, not a routing race.
402 Fetched::AnsweredUnusable => Cached::Missing(Missing {
403 reason: MissReason::AnsweredUnusable,
404 asked: std::time::Instant::now(),
405 attempts: 0,
406 }),
407 };
408 let served = match &entry {
409 Cached::Served(set) => Some(std::sync::Arc::clone(set)),
410 Cached::Missing(_) => None,
411 };
412 self.remember(producer.to_string(), entry);
413 served
414 }
415
416 /// This producer's single-flight gate, admitted under the bound (#340).
417 ///
418 /// An evicted gate costs at most one duplicate `describe` GET: whoever
419 /// still holds the old `Arc` is still gated by it, and a newcomer simply
420 /// makes a new one. That is why its ledger is separate from the sets' —
421 /// it is not lost knowledge.
422 fn gate_for(&self, producer: &str) -> std::sync::Arc<tokio::sync::Mutex<()>> {
423 let seen = self.tick();
424 let mut inflight = self.inflight.lock().expect("inflight lock");
425 if let Some(entry) = inflight.get_mut(producer) {
426 entry.seen = seen;
427 return std::sync::Arc::clone(&entry.value);
428 }
429 let dropped = inflight.admit(|e| e.seen) as u64;
430 if dropped > 0 {
431 self.gates_evicted.fetch_add(dropped, Ordering::Relaxed);
432 }
433 let gate = std::sync::Arc::new(tokio::sync::Mutex::new(()));
434 inflight.insert(
435 producer.to_string(),
436 Entry {
437 value: std::sync::Arc::clone(&gate),
438 seen,
439 },
440 );
441 gate
442 }
443
444 /// What the cache alone can say about `producer`: a verdict, or how many
445 /// consecutive zero-reply asks precede the next one (carried across so
446 /// the backoff actually grows).
447 ///
448 /// A hit is a *use*, so it refreshes the entry's recency: the producers
449 /// being decoded right now are the ones the bound must keep (#340).
450 fn lookup(&self, producer: &str) -> Lookup {
451 let seen = self.tick();
452 let mut sets = self.sets.lock().expect("store lock");
453 let Some(entry) = sets.get_mut(producer) else {
454 return Lookup::Ask(0);
455 };
456 entry.seen = seen;
457 match &entry.value {
458 Cached::Served(set) => Lookup::Answered(Some(std::sync::Arc::clone(set))),
459 Cached::Missing(m) if !m.may_reask() => Lookup::Answered(None),
460 Cached::Missing(m) => Lookup::Ask(m.attempts),
461 }
462 }
463
464 /// Forget what we learned about one producer, so the next question goes
465 /// to the bus (issue #101).
466 ///
467 /// The queriers are kept: they are idle routing state, and re-declaring
468 /// them is exactly the cost #37 removed.
469 pub fn forget(&self, producer: &str) {
470 self.sets.lock().expect("store lock").remove(producer);
471 }
472
473 /// Forget every producer — the "re-ask schemas" action a frontend offers.
474 ///
475 /// Covers the case the backoff cannot: a *positive* entry never expires,
476 /// so a producer that changes its served set mid-session is otherwise
477 /// read with the schemas it had at first contact.
478 pub fn forget_all(&self) {
479 self.sets.lock().expect("store lock").clear();
480 }
481
482 /// Producers currently answered-for, and whether each served a set —
483 /// what a frontend shows next to its re-ask button.
484 pub fn known(&self) -> Vec<(String, bool)> {
485 let sets = self.sets.lock().expect("store lock");
486 let mut out: Vec<(String, bool)> = sets
487 .iter()
488 .map(|(p, e)| (p.clone(), matches!(e.value, Cached::Served(_))))
489 .collect();
490 out.sort();
491 out
492 }
493
494 async fn fetch(&self, session: &Session, producer: &str) -> Fetched {
495 let cached = {
496 let seen = self.tick();
497 let mut queriers = self.queriers.lock().expect("querier lock");
498 queriers.get_mut(producer).map(|e| {
499 e.seen = seen;
500 std::sync::Arc::clone(&e.value)
501 })
502 };
503 let querier = match cached {
504 Some(q) => q,
505 None => {
506 let key = zenkey::grammar::with_base(
507 &self.base,
508 zenkey::selector::fleet_rpc(producer, &["describe"]),
509 );
510 // The store carries the base already, so it composes the
511 // `Fleet` rather than taking one — two bases in scope is a
512 // chance for them to disagree.
513 let fleet = crate::Fleet::new(session, &self.base);
514 let declared =
515 match crate::bus::query::declare_repeating(&fleet, &key, self.timeout).await {
516 Ok(q) => std::sync::Arc::new(q),
517 // We could not even ask. Nobody said anything about
518 // this producer, so this is the non-verdict case, not
519 // a 60s verdict.
520 Err(_) => return Fetched::NoReplies,
521 };
522 // A concurrent miss may have declared first; keep whichever
523 // landed (the loser undeclares itself on drop — idle state,
524 // not a leak).
525 let seen = self.tick();
526 let mut queriers = self.queriers.lock().expect("querier lock");
527 if let Some(entry) = queriers.get_mut(producer) {
528 entry.seen = seen;
529 std::sync::Arc::clone(&entry.value)
530 } else {
531 // Room, under the bound (#340). An evicted querier is
532 // routing state, not knowledge — it is re-declared on the
533 // next miss, which is why its ledger is its own.
534 let dropped = queriers.admit(|e| e.seen);
535 if dropped > 0 {
536 self.queriers_evicted
537 .fetch_add(dropped as u64, Ordering::Relaxed);
538 }
539 queriers.insert(
540 producer.to_string(),
541 Entry {
542 value: std::sync::Arc::clone(&declared),
543 seen,
544 },
545 );
546 declared
547 }
548 }
549 };
550 let Ok(answers) = querier.fetch().await else {
551 return Fetched::NoReplies;
552 };
553 if answers.is_empty() {
554 return Fetched::NoReplies;
555 }
556 // Any well-formed reply will do; hashes make same-name drift a
557 // doctor finding, not a decode concern.
558 for a in answers {
559 if let crate::bus::query::Answer::Value(bytes) = a.answer {
560 let cow = bytes.to_bytes();
561 if let Ok(text) = std::str::from_utf8(&cow)
562 && let Ok(set) = SchemaSet::parse(text)
563 {
564 return Fetched::Served(set);
565 }
566 }
567 }
568 // Somebody answered — with an error, or with something that is not a
569 // SchemaSet. That is a statement about this producer.
570 Fetched::AnsweredUnusable
571 }
572
573 /// Decode `bytes` under a schema, if one resolves.
574 pub fn decode(
575 &self,
576 schema: &TypeSchema,
577 encoding: &WireEncoding,
578 bytes: &[u8],
579 ) -> Result<DecodedPayload, DecodeError> {
580 self.decoders
581 .read()
582 .expect("decoder lock")
583 .decode(schema, encoding, bytes)
584 }
585
586 /// The other direction (issue #97): a JSON value framed for the wire.
587 /// The store owns the decoder table, so the write path resolves its codec
588 /// exactly where the read path does — one registration, both directions.
589 pub fn encode(
590 &self,
591 schema: &TypeSchema,
592 value: &serde_json::Value,
593 target: &WireEncoding,
594 ) -> Result<Vec<u8>, DecodeError> {
595 self.decoders
596 .read()
597 .expect("decoder lock")
598 .encode(schema, value, target)
599 }
600}
601
602/// The registry type names one producer's slice references — RFC 08 §7's
603/// totality set for that producer.
604fn referenced_types(slice: &zenkey::slice::RegistrySlice) -> Vec<String> {
605 let mut names: Vec<&str> = slice
606 .subjects
607 .iter()
608 .map(|s| s.type_name.as_str())
609 .filter(|t| !t.is_empty())
610 .collect();
611 for p in &slice.procedures {
612 names.extend(p.request.as_deref());
613 names.extend(p.reply.as_deref());
614 }
615 for b in &slice.blob {
616 names.extend(b.reference.as_deref());
617 }
618 // Media frames are opaque, but their per-frame attachment sidecar is a
619 // registry type like any other (RFC 08 §2) — the build-side §7 totality
620 // check includes it, and this set must not be the smaller one (G-08g).
621 for m in &slice.media {
622 names.extend(m.attachment.as_deref());
623 }
624 names.sort_unstable();
625 names.dedup();
626 names.into_iter().map(str::to_string).collect()
627}
628
629/// One type's schema, as a report row.
630fn row(
631 producer: &str,
632 type_name: &str,
633 schema: &TypeSchema,
634 full: bool,
635) -> crate::report::SchemaRow {
636 crate::report::SchemaRow {
637 producer: producer.to_string(),
638 type_name: type_name.to_string(),
639 kind: schema.kind_str().to_string(),
640 hash: schema.hash().unwrap_or_default().to_string(),
641 document: full.then(|| schema_document(schema)),
642 }
643}
644
645/// A schema's document in a renderable form. `json-schema` has one natively;
646/// every other kind is summarised structurally rather than faked — a codec
647/// this build cannot read still gets to say what it is.
648fn schema_document(schema: &TypeSchema) -> serde_json::Value {
649 if let Some(doc) = schema.json_document() {
650 return doc.clone();
651 }
652 let mut obj = serde_json::Map::new();
653 obj.insert(
654 "kind".into(),
655 serde_json::Value::String(schema.kind_str().to_string()),
656 );
657 if let Some(m) = schema.protobuf_message() {
658 obj.insert("message".into(), serde_json::Value::String(m.to_string()));
659 }
660 if let Some(bytes) = schema.protobuf_descriptor_set() {
661 obj.insert(
662 "descriptor_set_bytes".into(),
663 serde_json::Value::from(bytes.len()),
664 );
665 }
666 if let Some(fields) = schema.cdr_fields() {
667 obj.insert("fields".into(), fields.clone());
668 }
669 if let Some(types) = schema.cdr_types() {
670 obj.insert("types".into(), serde_json::Value::Object(types.clone()));
671 }
672 serde_json::Value::Object(obj)
673}
674
675/// Dump one producer's served `describe` reply (issue #51), joined against
676/// its registry slice so the RFC 08 §7 totality gap is visible where the user
677/// is already looking.
678///
679/// A producer serving no `describe` yields `served: false` — the honest
680/// degradation, never an error: §7 is a SHOULD, and silence about a type is
681/// not a claim about it.
682///
683/// `slices: None` means no registry was loaded, and `missing` comes back
684/// `None` with it: a totality gap computed against nothing is vacuously
685/// empty, and rendering that as "nothing missing" would report a verdict
686/// never obtained (RFC 09 §5.1 O4; #246).
687pub async fn schema_dump(
688 store: &SchemaStore,
689 session: &Session,
690 slices: Option<&SliceSet>,
691 producer: &str,
692 type_filter: Option<&str>,
693 full: bool,
694) -> crate::report::SchemaDump {
695 let set = store.set_for(session, producer).await;
696
697 let Some(set) = set else {
698 return crate::report::SchemaDump {
699 producer: producer.to_string(),
700 served: false,
701 app: None,
702 types: Vec::new(),
703 // No served set to check against — totality is unaskable here,
704 // not clean.
705 missing: crate::report::Asked::NotAsked,
706 };
707 };
708 let types: Vec<crate::report::SchemaRow> = set
709 .iter()
710 .filter(|(name, _)| type_filter.is_none_or(|f| f == *name))
711 .map(|(name, schema)| row(producer, name, schema, full || type_filter.is_some()))
712 .collect();
713 // Checked only when a registry answered: a loaded registry with no slice
714 // for this producer declares nothing, so `Asked(vec![])` is a real clean
715 // bill; no registry at all stays `NotAsked` (RFC 09 §5.1 O4).
716 let missing = slices.map(|slices| {
717 slices
718 .get(producer)
719 .map(|slice| {
720 referenced_types(slice)
721 .into_iter()
722 .filter(|n| set.get(n).is_none())
723 .collect()
724 })
725 .unwrap_or_default()
726 });
727 crate::report::SchemaDump {
728 producer: producer.to_string(),
729 served: true,
730 app: Some(set.app().to_string()),
731 types,
732 missing: missing.into(),
733 }
734}
735
736/// One row per producer for one type name, over sets already in hand — the
737/// rows `interface show --schema` tables (issue #51), fed from a
738/// [`DescribeSweep`](crate::bus::describe::DescribeSweep)'s
739/// `first_per_producer` (#410).
740///
741/// Rows, not a verdict: a [`SchemaRow`](crate::report::SchemaRow) carries a
742/// producer and no origin, and its `hash` is flattened to `""` when none was
743/// served, so nothing about agreement can be read off two of them — that is
744/// [`schema_drift`]'s job, over the sweep's attributed answers. This function
745/// exists so the table and the verdict come from one sweep rather than the
746/// table recomputing a second, worse verdict of its own.
747pub fn schema_rows_for_type(
748 described: &[(String, SchemaSet)],
749 type_name: &str,
750 full: bool,
751) -> Vec<crate::report::SchemaRow> {
752 described
753 .iter()
754 .filter_map(|(producer, set)| set.get(type_name).map(|s| (producer, s)))
755 .map(|(producer, schema)| row(producer, type_name, schema, full))
756 .collect()
757}
758
759/// One producer's served describe set, attributed to the host that answered
760/// (#398).
761///
762/// The origin cannot come from the set: a `SchemaSet` names the declaring app
763/// and its types, never the host serving them. It comes from the reply's own
764/// key, the way RFC 05 §2.1 requires every fan-in answer to be attributed —
765/// the same shape [`ServedSlice`](crate::ServedSlice) carries one plane over.
766///
767/// Nothing is deduplicated: N hosts running one producer are N entries, which
768/// is the point.
769#[derive(Debug, Clone)]
770#[non_exhaustive]
771pub struct DescribedSchema {
772 /// The origin that answered — the `h-…` host id, or a verbatim service
773 /// origin. `"?"` when the reply key did not parse under this base.
774 pub origin: String,
775 /// The producer the describe was addressed to. A different question from
776 /// `origin`, which is why both are here.
777 pub producer: String,
778 /// The set that origin served.
779 pub set: SchemaSet,
780}
781
782impl DescribedSchema {
783 /// One attributed describe answer.
784 ///
785 /// The type is `#[non_exhaustive]` like its sibling
786 /// [`ServedSlice`](crate::ServedSlice), so this is how a caller outside
787 /// the crate builds one — [`schema_drift`] is pure and documented to take
788 /// whatever replies were gathered, which is only true if they can be
789 /// spelled.
790 pub fn new(
791 origin: impl Into<String>,
792 producer: impl Into<String>,
793 set: SchemaSet,
794 ) -> DescribedSchema {
795 DescribedSchema {
796 origin: origin.into(),
797 producer: producer.into(),
798 set,
799 }
800 }
801}
802
803/// Compute drift across a described fleet. Pure — feed it whatever describe
804/// replies were gathered (the store's cache, or a fresh sweep).
805///
806/// Reports a name **only when more than one answer serves it**, because with
807/// one there is nothing to compare; a lone answer that served no identity is
808/// degraded caching (RFC 08 §7), not a disagreement.
809///
810/// **An answer, not a producer** (#398). The input used to be one entry per
811/// producer, so the only drift this could see was *between* producers — and a
812/// half-rolled-out sensor, whose two hosts serve one producer under two
813/// identities, collapsed to a single entry and was filtered out as having
814/// nothing to compare. That is the likeliest disagreement there is: a schema
815/// hash changes on any field addition. Each `(producer, origin)` pair is now
816/// its own claim, so the comparison a mid-rollout fleet actually needs — this
817/// host against that one, for the same producer — is the one this makes.
818///
819/// Two claims that each served *no* identity used to compare equal — both
820/// flattened to `""` — and were reported as agreeing: a "no drift" verdict on
821/// a question nobody answered (#370, RFC 09 §5.1 O4). They are
822/// [`DriftVerdict::Unjudgeable`] now, which is neither agreement nor a defect.
823pub fn schema_drift(described: &[DescribedSchema]) -> Vec<SchemaDrift> {
824 use std::collections::BTreeMap;
825 let mut by_name: BTreeMap<&str, Vec<SchemaServer>> = BTreeMap::new();
826 for d in described {
827 for (name, schema) in d.set.iter() {
828 by_name.entry(name).or_default().push(SchemaServer {
829 producer: d.producer.clone(),
830 origin: d.origin.clone(),
831 hash: schema.hash().map(str::to_string).into(),
832 });
833 }
834 }
835 by_name
836 .into_iter()
837 .filter(|(_, servers)| servers.len() > 1)
838 .filter_map(|(name, servers)| {
839 let claimed: Vec<&String> = servers.iter().filter_map(|s| s.hash.as_option()).collect();
840 let verdict = if claimed.len() < servers.len() {
841 // Somebody did not say. Whatever the rest agree on, agreement
842 // across the fleet is not established.
843 DriftVerdict::Unjudgeable
844 } else if claimed.iter().any(|h| *h != claimed[0]) {
845 DriftVerdict::Disagree
846 } else {
847 return None;
848 };
849 Some(SchemaDrift {
850 type_name: name.to_string(),
851 servers,
852 verdict,
853 })
854 })
855 .collect()
856}
857
858/// Totality per producer: every type name the slice references (subjects,
859/// procedure request/reply, blob references) must appear in the served set
860/// (RFC 08 §7). A producer that served no describe at all is NOT a gap here —
861/// that is "describe absent", a different finding with a different fix.
862pub fn totality_gaps(described: &[(String, SchemaSet)], slices: &SliceSet) -> Vec<TotalityGap> {
863 let mut gaps = Vec::new();
864 for (producer, set) in described {
865 let Some(slice) = slices.get(producer) else {
866 continue;
867 };
868 let mut names: Vec<&str> = Vec::new();
869 // An untyped subject (empty `type`) references nothing — without this
870 // filter it would demand a schema for "" and report a phantom gap.
871 names.extend(
872 slice
873 .subjects
874 .iter()
875 .map(|s| s.type_name.as_str())
876 .filter(|t| !t.is_empty()),
877 );
878 for p in &slice.procedures {
879 names.extend(p.request.as_deref());
880 names.extend(p.reply.as_deref());
881 }
882 for b in &slice.blob {
883 names.extend(b.reference.as_deref());
884 }
885 names.sort();
886 names.dedup();
887 let missing: Vec<String> = names
888 .into_iter()
889 .filter(|n| set.get(n).is_none())
890 .map(str::to_string)
891 .collect();
892 if !missing.is_empty() {
893 gaps.push(TotalityGap {
894 producer: producer.clone(),
895 missing,
896 });
897 }
898 }
899 gaps
900}
901
902/// How a rendered payload was produced — a tool surfaces this honestly
903/// instead of letting decoded and sniffed output look alike.
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub enum Rendering {
906 /// Schema-decoded into named fields.
907 Typed(DecodedPayload),
908 /// No schema (or an undecodable kind): structural sniff — JSON if it
909 /// parses, CBOR diagnostic, UTF-8 text, else a byte count.
910 Structural(String),
911}
912
913/// Resolve the wire encoding: sample `Encoding` > registry `encoding` > sniff
914/// (RFC 08 §7).
915pub fn resolve_encoding(
916 sample_encoding: Option<&str>,
917 registry_encoding: Option<&WireEncoding>,
918 bytes: &[u8],
919) -> WireEncoding {
920 // Zenoh's default when a publisher sets nothing is the opaque
921 // `zenoh/bytes` — that is "unsaid", not "bytes on purpose".
922 if let Some(e) = sample_encoding
923 && e != "zenoh/bytes"
924 {
925 return WireEncoding::from_encoding_str(e);
926 }
927 if let Some(e) = registry_encoding {
928 return e.clone();
929 }
930 // The sniff: JSON text starts with a JSON-ish byte; otherwise call it
931 // CBOR (the reference profile default) and let the decoder's error path
932 // fall through to structural rendering.
933 match bytes.first() {
934 Some(b'{' | b'[' | b'"') => WireEncoding::Json,
935 _ => WireEncoding::Cbor,
936 }
937}
938
939/// How many bytes an *observation* path will structurally decode.
940///
941/// `structural_value` parses the whole payload into a `serde_json::Value`, and
942/// the observation paths call it **per sample** on a drain loop — field
943/// intelligence has to, because a field that stopped moving is only visible
944/// sample by sample. Unbounded, a multi-megabyte payload spends that parse on
945/// every one of them, on the loop whose whole job is to keep up (#337's
946/// lesson, applied to CPU rather than to I/O).
947///
948/// The number and the doctrine are `zengui`'s, from #345 — *"past it the size
949/// is reported and the decode is skipped, which is stated, never silently
950/// empty"* — moved here because both frontends and the engine's own judges
951/// need it, and three copies of one limit would be three answers to one
952/// question (the #353 lesson).
953pub const OBSERVE_LIMIT: usize = 64 * 1024;
954
955/// The structural sniff as a **value** rather than as text — the same ladder
956/// [`structural`] renders, stopped one step earlier.
957///
958/// `Some` means the bytes carry a self-describing document (JSON, or CBOR that
959/// accounts for every byte and is not the text-vs-scalar ambiguity below).
960/// `None` means they do not: plain text, or opaque bytes. That distinction is
961/// what lets a caller diff two payloads field-by-field when it can, and say so
962/// honestly — a byte comparison — when it cannot.
963///
964/// Deliberately sync and schema-free: this runs on render paths, where the
965/// async [`decode_sample`] (which may GET a `describe` on a miss) must never
966/// sit.
967pub fn structural_value(bytes: &[u8]) -> Option<serde_json::Value> {
968 let looks_json = bytes.first().is_some_and(|b| {
969 matches!(
970 b,
971 b'{' | b'[' | b'"' | b'-' | b'0'..=b'9' | b't' | b'f' | b'n'
972 )
973 });
974 if looks_json && let Ok(v) = serde_json::from_slice::<serde_json::Value>(bytes) {
975 return Some(v);
976 }
977 let is_text = std::str::from_utf8(bytes).is_ok_and(|t| !t.is_empty());
978 if let Some(v) = cbor_whole(bytes)
979 // A bare CBOR scalar over bytes that are *also* valid text is the
980 // ambiguous case, and plain text is the likelier reading on a bus that
981 // carries anything. Structured CBOR (a map, an array) is unambiguous
982 // and still wins.
983 && !(is_text && is_scalar(&v))
984 // A CBOR map keyed by anything but strings has no JSON form; that is a
985 // failure of the *rendering*, not of the payload, so it degrades to
986 // text like any other unreadable shape rather than being invented.
987 && let Ok(value) = serde_json::to_value(&v)
988 {
989 return Some(value);
990 }
991 None
992}
993
994/// Structural fallback rendering — what the wire honestly says when no
995/// schema resolves.
996pub fn structural(bytes: &[u8]) -> String {
997 if let Some(v) = structural_value(bytes) {
998 return serde_json::to_string(&v).unwrap_or_default();
999 }
1000 match std::str::from_utf8(bytes).ok().filter(|t| !t.is_empty()) {
1001 Some(text) => text.to_string(),
1002 None => format!("<{} bytes>", bytes.len()),
1003 }
1004}
1005
1006/// Decode CBOR only if it accounts for **every** byte.
1007///
1008/// `ciborium::from_reader` decodes one value from the front and ignores the
1009/// rest, which makes it a false-positive machine on plain text: `j` is `0x6A`,
1010/// "text string of length 10", so `just a plain string` decodes as the CBOR
1011/// text `"ust a plai"` with eight bytes left over — and an explorer that shows
1012/// that has silently corrupted the payload it was asked to display. Any
1013/// lowercase-initial ASCII text is a candidate. Requiring total consumption is
1014/// what makes the sniff honest (RFC 08 §7 — sniffing is the last resort, so it
1015/// must at least be self-consistent).
1016fn cbor_whole(bytes: &[u8]) -> Option<ciborium::Value> {
1017 let mut cursor = std::io::Cursor::new(bytes);
1018 let value = ciborium::from_reader::<ciborium::Value, _>(&mut cursor).ok()?;
1019 (cursor.position() as usize == bytes.len()).then_some(value)
1020}
1021
1022/// A single scalar, as opposed to a map or array.
1023fn is_scalar(v: &ciborium::Value) -> bool {
1024 !matches!(v, ciborium::Value::Map(_) | ciborium::Value::Array(_))
1025}
1026
1027/// One sample, fully decoded — the pipeline's answer plus its honesty (#159).
1028#[derive(Debug, Clone, PartialEq, Eq)]
1029pub struct DecodedSample {
1030 /// The registered type name, when the key refined to one.
1031 pub type_name: Option<String>,
1032 /// What to show: typed fields, or the structural fallback.
1033 pub rendering: Rendering,
1034 /// Conformance of the payload to its declared schema — three states,
1035 /// never a boolean ([`zenkey::schema::validate::Verdict`]).
1036 pub verdict: Verdict,
1037 /// The decode failure under a *present* schema, verbatim — the evidence
1038 /// behind `NotValidated(Undecodable)`. `None` everywhere else; before
1039 /// #159 this error was swallowed into the structural fallback.
1040 pub decode_error: Option<String>,
1041}
1042
1043impl DecodedSample {
1044 fn structural(type_name: Option<String>, reason: NotValidated, bytes: &[u8]) -> DecodedSample {
1045 DecodedSample {
1046 type_name,
1047 rendering: Rendering::Structural(structural(bytes)),
1048 verdict: Verdict::NotValidated(reason),
1049 decode_error: None,
1050 }
1051 }
1052}
1053
1054/// The whole decode pipeline for one sample: refine the key against the
1055/// slices, resolve the schema through the store, decode — or fall back
1056/// structurally, tagged with whatever we did learn and why it was not more.
1057///
1058/// `slices: None` means no registry was loaded at all, and the verdict is
1059/// [`NotValidated::NoRegistry`] — nobody looked a type up, which must not
1060/// masquerade as [`NotValidated::NoSchema`]'s "asked, and no schema is
1061/// served/known for this type" (RFC 09 §5.1 O4; #246). Mirrors
1062/// [`schema_dump`]'s `Option<&SliceSet>`.
1063///
1064/// The argument order is *where*, then *what we know*, then *what arrived*:
1065/// the fleet the sample came off, the two knowledge sources consulted about
1066/// it (the schema store, the registry), then the sample itself — key,
1067/// declared encoding, bytes. It used to open `(store, session, slices, base,
1068/// …)`, which put the deployment fourth and split it from its session.
1069/// Ask every producer the loaded registry names for its `describe`, before
1070/// a judging window opens (#337). Returns how many now have a served set.
1071///
1072/// **This is exhaustive, not a heuristic.** [`decode_sample`] refines a key
1073/// against the slices *first* and only then asks the store, so the only
1074/// producers it can ever miss on are the ones the registry names — the set
1075/// this walks. After a pre-warm, every decode inside the window is a cache
1076/// hit or a cached miss, and neither touches the bus.
1077///
1078/// Pair it with [`SchemaStore::seal`], which covers what warming cannot: a
1079/// producer that answered nothing is cached as a *miss with a backoff*, and
1080/// the backoff would expire mid-window and put the GET back inside the drain
1081/// loop.
1082///
1083/// With no registry loaded there is nothing to warm and nothing to miss on —
1084/// `decode_sample` returns `NoRegistry` before it reaches the store.
1085///
1086/// Sequential, like the doctor's own describe sweep: each ask is bounded by
1087/// the store's timeout, and the phase is deliberately *before* anything is
1088/// watched, so its cost is latency to the window's start rather than samples
1089/// lost inside it.
1090pub async fn prewarm(
1091 fleet: &crate::Fleet<'_>,
1092 store: &SchemaStore,
1093 slices: Option<&SliceSet>,
1094) -> usize {
1095 let Some(slices) = slices else { return 0 };
1096 let mut served = 0;
1097 for slice in slices.slices() {
1098 if store
1099 .set_for_within(fleet.session(), &slice.name, true)
1100 .await
1101 .is_some()
1102 {
1103 served += 1;
1104 }
1105 }
1106 served
1107}
1108
1109pub async fn decode_sample(
1110 fleet: &crate::Fleet<'_>,
1111 store: &SchemaStore,
1112 slices: Option<&SliceSet>,
1113 wire_key: &str,
1114 sample_encoding: Option<&str>,
1115 bytes: &[u8],
1116) -> DecodedSample {
1117 use zenkey::grammar::ClassOrPlane;
1118
1119 let (session, base) = (fleet.session(), fleet.base());
1120
1121 let Some(slices) = slices else {
1122 // Not asked is not answered no: with no registry there was never a
1123 // lookup to fail, so the reason names the missing registry, not the
1124 // type (RFC 09 §5.1 O4; #246).
1125 return DecodedSample::structural(None, NotValidated::NoRegistry, bytes);
1126 };
1127 let refined = zenkey::grammar::parse_full(base, wire_key).and_then(|parsed| {
1128 let producer = match (parsed.producer(), &parsed.origin) {
1129 (Some(p), _) => p.name().to_string(),
1130 (None, zenkey::grammar::Origin::Service(s)) => {
1131 slices.by_service_origin(s.as_str())?.name.clone()
1132 }
1133 _ => return None,
1134 };
1135 let ClassOrPlane::Class(class) = parsed.class else {
1136 return None;
1137 };
1138 let (subject, _) = slices.refine(&producer, class.chunk(), &parsed.subject)?;
1139 Some((
1140 producer,
1141 subject.type_name.clone(),
1142 subject.encoding.clone(),
1143 ))
1144 });
1145 let Some((producer, type_name, registry_encoding)) = refined else {
1146 // The loaded registry was consulted and names no type for this key —
1147 // there is no schema to conform to (O4: this is "no contract", not
1148 // "checked and passed", and not `NoRegistry`'s "nobody looked").
1149 return DecodedSample::structural(None, NotValidated::NoSchema, bytes);
1150 };
1151 let encoding = resolve_encoding(sample_encoding, registry_encoding.as_ref(), bytes);
1152 match store.schema_for(session, &producer, &type_name).await {
1153 Some(schema) => match store.decode(&schema, &encoding, bytes) {
1154 Ok(decoded) => {
1155 let verdict = decoded.verdict.clone();
1156 DecodedSample {
1157 type_name: Some(type_name),
1158 rendering: Rendering::Typed(decoded),
1159 verdict,
1160 decode_error: None,
1161 }
1162 }
1163 // Wrong schema/encoding is a finding for the *user*, not a crash:
1164 // fall back to structure, keep the type tag — and keep the error,
1165 // which is exactly the payload-undecodable evidence (#161).
1166 Err(e) => DecodedSample {
1167 type_name: Some(type_name),
1168 rendering: Rendering::Structural(structural(bytes)),
1169 verdict: Verdict::NotValidated(NotValidated::Undecodable),
1170 decode_error: Some(e.to_string()),
1171 },
1172 },
1173 None => DecodedSample::structural(Some(type_name), NotValidated::NoSchema, bytes),
1174 }
1175}
1176
1177#[cfg(test)]
1178mod tests {
1179 use super::*;
1180
1181 /// #340: the store's maps are bounded, and each bound counts what it
1182 /// dropped — the discipline every other accumulating structure in this
1183 /// crate already keeps (`StatsTable::evicted`, `Retention::evicted`,
1184 /// `FactsCache::evicted`).
1185 ///
1186 /// The keys come from `parse_full` over arbitrary bus traffic, so "a
1187 /// fleet's producer set is small" was never a bound — it was a hope about
1188 /// what an explorer happens to be pointed at.
1189 #[test]
1190 fn the_store_is_bounded_and_says_what_the_bound_cost() {
1191 const PRODUCERS: usize = 200;
1192 let set = || {
1193 SchemaSet::parse(
1194 r#"{"schema_version":1,"app":"t",
1195 "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
1196 )
1197 .expect("fixture parses")
1198 };
1199 let store = SchemaStore::bounded("", Duration::from_millis(1), 16);
1200 for i in 0..PRODUCERS {
1201 store.insert(format!("p{i:04}"), set());
1202 }
1203
1204 let bounds = store.bounds();
1205 assert_eq!(bounds.max_producers, 16);
1206 assert!(bounds.producers <= 16, "the bound bit: {bounds:?}");
1207 assert_eq!(
1208 bounds.producers as u64 + bounds.sets_evicted,
1209 PRODUCERS as u64,
1210 "every producer is held or counted: {bounds:?}"
1211 );
1212 assert_eq!(store.known().len(), bounds.producers, "known() agrees");
1213 // The three ledgers are three facts: nothing was declared and nothing
1214 // was gated here, so only the sets' bound has a cost to report.
1215 assert_eq!(bounds.queriers_evicted, 0);
1216 assert_eq!(bounds.gates_evicted, 0);
1217 }
1218
1219 /// Eviction is least-recently-**used**, not least-recently-inserted: the
1220 /// producer being decoded right now outlives one seen once (#340).
1221 #[test]
1222 fn a_producer_still_being_read_survives_the_bound() {
1223 let set = || {
1224 SchemaSet::parse(
1225 r#"{"schema_version":1,"app":"t",
1226 "types":{"W":{"kind":"cddl","hash":"sha256:00","spec":"x = int"}}}"#,
1227 )
1228 .expect("fixture parses")
1229 };
1230 let store = SchemaStore::bounded("", Duration::from_millis(1), 8);
1231 store.insert("hot", set());
1232 for i in 0..7 {
1233 store.insert(format!("cold{i}"), set());
1234 }
1235 // Read `hot` between every further insert — a decode's cache hit.
1236 for i in 7..64 {
1237 assert!(
1238 matches!(store.lookup("hot"), Lookup::Answered(Some(_))),
1239 "the hot producer was evicted at insert {i}"
1240 );
1241 store.insert(format!("cold{i}"), set());
1242 }
1243 assert!(store.bounds().sets_evicted > 0, "the bound did bite");
1244 assert!(
1245 store
1246 .known()
1247 .iter()
1248 .any(|(p, served)| p == "hot" && *served),
1249 "the producer in use survived: {:?}",
1250 store.known()
1251 );
1252 }
1253
1254 /// RFC 08 §7's totality set for one producer: every type the slice
1255 /// references — subject types, procedure request/reply, blob references,
1256 /// **and media attachment sidecars**. The build-side check has counted
1257 /// media since v1.16; the fleet side must not be the smaller set (G-08g).
1258 #[test]
1259 fn the_totality_set_counts_every_referenced_type() {
1260 let slice = zenkey::slice::parse_slice(
1261 r#"
1262 [registry]
1263 version = "1.0"
1264 app = "acme"
1265 convention = 1
1266 [producer]
1267 name = "netring"
1268 [[subject]]
1269 path = "health"
1270 class = "state"
1271 type = "Health"
1272 [[procedure]]
1273 path = "capture/trigger"
1274 kind = "write"
1275 request = "CaptureSpec"
1276 reply = "Ack"
1277 [[blob]]
1278 tier = "artifact"
1279 endpoints = ["manifest"]
1280 reference = "PcapRef"
1281 [[media]]
1282 path = "front/video/h264"
1283 encoding = "video/h264"
1284 attachment = "FrameMeta"
1285 "#,
1286 )
1287 .unwrap();
1288 assert_eq!(
1289 referenced_types(&slice),
1290 ["Ack", "CaptureSpec", "FrameMeta", "Health", "PcapRef"]
1291 );
1292 }
1293
1294 #[test]
1295 fn encoding_resolution_order() {
1296 // Sample wins…
1297 assert_eq!(
1298 resolve_encoding(Some("application/json"), Some(&WireEncoding::Cbor), b"x"),
1299 WireEncoding::Json
1300 );
1301 // …but the opaque default is "unsaid", so the registry speaks…
1302 assert_eq!(
1303 resolve_encoding(Some("zenoh/bytes"), Some(&WireEncoding::Cbor), b"{"),
1304 WireEncoding::Cbor
1305 );
1306 // …and with neither, the sniff.
1307 assert_eq!(
1308 resolve_encoding(None, None, b"{\"a\":1}"),
1309 WireEncoding::Json
1310 );
1311 assert_eq!(resolve_encoding(None, None, &[0xa1]), WireEncoding::Cbor);
1312 }
1313
1314 #[test]
1315 fn structural_rendering_is_honest() {
1316 assert_eq!(structural(b"{\"a\":1}"), "{\"a\":1}");
1317 // CBOR map {1: 2} renders as structure.
1318 let mut cbor = Vec::new();
1319 ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1320 assert!(structural(&cbor).contains("\"x\""));
1321 assert_eq!(structural(&[0xff, 0xfe, 0x00]), "<3 bytes>");
1322 }
1323
1324 /// The value form answers the question a diff actually asks: is there a
1325 /// document here to compare field by field, or only bytes?
1326 #[test]
1327 fn structural_value_yields_documents_and_nothing_else() {
1328 assert_eq!(
1329 structural_value(br#"{"value":42.0}"#),
1330 Some(serde_json::json!({"value": 42.0}))
1331 );
1332 let mut cbor = Vec::new();
1333 ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1334 assert_eq!(structural_value(&cbor), Some(serde_json::json!({"x": 1})));
1335 // Plain text and opaque bytes are not documents — the caller falls
1336 // back to a byte comparison rather than being handed a fake one.
1337 assert_eq!(structural_value(b"just a plain string"), None);
1338 assert_eq!(structural_value(&[0xff, 0xfe, 0x00]), None);
1339 assert_eq!(structural_value(b""), None);
1340 }
1341
1342 /// The two must not drift: `structural` is the rendering of
1343 /// `structural_value` wherever one exists.
1344 #[test]
1345 fn the_rendering_agrees_with_the_value() {
1346 for payload in [
1347 &br#"{"a":1}"#[..],
1348 &b"[1,2,3]"[..],
1349 &b"just a plain string"[..],
1350 &[0xff, 0xfe, 0x00][..],
1351 ] {
1352 if let Some(v) = structural_value(payload) {
1353 assert_eq!(structural(payload), serde_json::to_string(&v).unwrap());
1354 }
1355 }
1356 }
1357
1358 /// Regression: plain text must not be eaten by the CBOR sniff.
1359 ///
1360 /// `ciborium` decodes one value from the front and ignores trailing bytes,
1361 /// so `just a plain string` used to render as `"ust a plai"` — `j` is
1362 /// `0x6A`, "text string of length 10". Every lowercase-initial ASCII
1363 /// payload was a candidate, which on an arbitrary bus is most of them.
1364 #[test]
1365 fn plain_text_is_not_mistaken_for_cbor() {
1366 assert_eq!(structural(b"just a plain string"), "just a plain string");
1367 assert_eq!(
1368 structural(b"a v2 key: not this convention"),
1369 "a v2 key: not this convention"
1370 );
1371 // The whole lowercase range is the danger zone (0x60..=0x7b).
1372 for first in b'a'..=b'z' {
1373 let mut payload = vec![first];
1374 payload.extend_from_slice(b" some trailing words here");
1375 let text = String::from_utf8(payload.clone()).unwrap();
1376 assert_eq!(structural(&payload), text, "mangled {text:?}");
1377 }
1378 }
1379
1380 /// The ambiguous case: bytes that are *both* a complete CBOR text string
1381 /// and valid UTF-8. Plain text is the likelier reading on a bus that
1382 /// carries anything, and it is the lossless one.
1383 #[test]
1384 fn an_exact_cbor_text_string_still_reads_as_text() {
1385 // 0x6A = text(10), followed by exactly 10 bytes: fully consumed CBOR.
1386 let payload = b"just a plai";
1387 assert!(cbor_whole(payload).is_some(), "setup: this is valid CBOR");
1388 assert_eq!(structural(payload), "just a plai");
1389 }
1390
1391 /// …but structured CBOR is unambiguous and must still win, even when the
1392 /// bytes happen to be valid UTF-8.
1393 #[test]
1394 fn structured_cbor_still_wins_over_text() {
1395 let mut cbor = Vec::new();
1396 ciborium::into_writer(&serde_json::json!({"ok": true}), &mut cbor).unwrap();
1397 let rendered = structural(&cbor);
1398 assert!(rendered.contains("\"ok\""), "{rendered}");
1399 assert!(rendered.starts_with('{'), "{rendered}");
1400 }
1401
1402 /// Trailing bytes mean the buffer is not one CBOR value, whatever the
1403 /// front of it looks like.
1404 #[test]
1405 fn cbor_must_account_for_every_byte() {
1406 let mut cbor = Vec::new();
1407 ciborium::into_writer(&serde_json::json!({"x": 1}), &mut cbor).unwrap();
1408 assert!(cbor_whole(&cbor).is_some());
1409 cbor.push(0x00);
1410 assert!(cbor_whole(&cbor).is_none(), "trailing byte must reject");
1411 }
1412
1413 fn set_with(name: &str, schema: serde_json::Value) -> SchemaSet {
1414 SchemaSet::builder("app")
1415 .entry(name, zenkey::schema::TypeSchema::json_schema(schema))
1416 .build()
1417 }
1418
1419 /// RFC 08 §7: same name, different hash, across producers — one finding
1420 /// listing every server; agreement is silent.
1421 #[test]
1422 fn drift_findings_name_every_server() {
1423 let a = SchemaSet::builder("app")
1424 .entry(
1425 "T",
1426 zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1427 )
1428 .build();
1429 let b = SchemaSet::builder("app")
1430 .entry(
1431 "T",
1432 zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"string"})),
1433 )
1434 .build();
1435 let c = SchemaSet::builder("app")
1436 .entry(
1437 "T",
1438 zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1439 )
1440 .build();
1441 let described = vec![
1442 DescribedSchema::new("h-aaaaaaaaaaaa", "p1", a),
1443 DescribedSchema::new("h-aaaaaaaaaaaa", "p2", b),
1444 DescribedSchema::new("h-aaaaaaaaaaaa", "p3", c),
1445 ];
1446 let drift = schema_drift(&described);
1447 assert_eq!(drift.len(), 1);
1448 assert_eq!(drift[0].type_name, "T");
1449 assert_eq!(drift[0].servers.len(), 3, "every server is named");
1450 assert_eq!(drift[0].verdict, DriftVerdict::Disagree);
1451 // p1 and p3 agree; p2 is the odd one out — the caller can see which.
1452 assert_eq!(drift[0].servers[0].hash, drift[0].servers[2].hash);
1453 assert_ne!(drift[0].servers[0].hash, drift[0].servers[1].hash);
1454
1455 // Two producers that each served *no* identity are not agreeing —
1456 // they answered nothing, and "no drift" would be a verdict on a
1457 // question nobody put (#370, RFC 09 §5.1 O4).
1458 let unhashed = |app: &str| {
1459 SchemaSet::parse(&format!(
1460 r#"{{"schema_version":1,"app":"{app}","types":{{"T":{{"kind":"json-schema","hash":"","schema":{{}}}}}}}}"#
1461 ))
1462 .unwrap()
1463 };
1464 let silent = vec![
1465 DescribedSchema::new("h-aaaaaaaaaaaa", "p1", unhashed("app")),
1466 DescribedSchema::new("h-aaaaaaaaaaaa", "p2", unhashed("app")),
1467 ];
1468 let drift = schema_drift(&silent);
1469 assert_eq!(drift.len(), 1, "silence is reported, not read as agreement");
1470 assert_eq!(drift[0].verdict, DriftVerdict::Unjudgeable);
1471 assert!(
1472 drift[0].servers.iter().all(|s| s.hash.is_not_asked()),
1473 "and it names who did not say"
1474 );
1475
1476 // One that says and one that does not is likewise unjudgeable — the
1477 // half that answered cannot establish fleet-wide agreement alone.
1478 let mixed = vec![
1479 DescribedSchema::new("h-aaaaaaaaaaaa", "p1", unhashed("app")),
1480 DescribedSchema::new(
1481 "h-aaaaaaaaaaaa",
1482 "p2",
1483 SchemaSet::builder("app")
1484 .entry(
1485 "T",
1486 zenkey::schema::TypeSchema::json_schema(
1487 serde_json::json!({"type":"object"}),
1488 ),
1489 )
1490 .build(),
1491 ),
1492 ];
1493 assert_eq!(schema_drift(&mixed)[0].verdict, DriftVerdict::Unjudgeable);
1494
1495 // A *lone* producer with no identity is nothing to compare against,
1496 // so it is not a drift question at all.
1497 assert!(
1498 schema_drift(&[DescribedSchema::new(
1499 "h-aaaaaaaaaaaa",
1500 "p1",
1501 unhashed("app")
1502 )])
1503 .is_empty()
1504 );
1505
1506 // All agreeing: no finding.
1507 let described = vec![
1508 DescribedSchema::new(
1509 "h-aaaaaaaaaaaa",
1510 "p1",
1511 set_with("T", serde_json::json!({"type":"object"})),
1512 ),
1513 DescribedSchema::new(
1514 "h-aaaaaaaaaaaa",
1515 "p3",
1516 set_with("T", serde_json::json!({"type":"object"})),
1517 ),
1518 ];
1519 assert!(schema_drift(&described).is_empty());
1520 }
1521
1522 /// The case the producer-keyed shape could not see at all (#398): **one**
1523 /// producer, two hosts, two identities — a half-rolled-out sensor, which
1524 /// is the likeliest disagreement there is because a schema hash changes on
1525 /// any field addition.
1526 ///
1527 /// Before this, both hosts collapsed into one entry and the name was
1528 /// filtered out as having nothing to compare: a fleet mid-rollout read as
1529 /// agreeing.
1530 #[test]
1531 fn one_producer_on_two_hosts_with_two_identities_is_a_disagreement() {
1532 const OLD_HOST: &str = "h-aaaaaaaaaaaa";
1533 const NEW_HOST: &str = "h-bbbbbbbbbbbb";
1534 let described = vec![
1535 DescribedSchema::new(
1536 OLD_HOST,
1537 "sysinfo",
1538 set_with("Health", serde_json::json!({"type":"object"})),
1539 ),
1540 DescribedSchema::new(
1541 NEW_HOST,
1542 "sysinfo",
1543 set_with("Health", serde_json::json!({"type":"string"})),
1544 ),
1545 ];
1546 let drift = schema_drift(&described);
1547 assert_eq!(drift.len(), 1, "{drift:#?}");
1548 assert_eq!(drift[0].verdict, DriftVerdict::Disagree);
1549 let hosts: Vec<&str> = drift[0].servers.iter().map(|s| s.origin.as_str()).collect();
1550 assert_eq!(
1551 hosts,
1552 [OLD_HOST, NEW_HOST],
1553 "both hosts are named — a producer name alone gives nobody to go and look at"
1554 );
1555 assert!(
1556 drift[0].servers.iter().all(|s| s.producer == "sysinfo"),
1557 "one producer: the origin is the axis that differs"
1558 );
1559 assert_ne!(drift[0].servers[0].hash, drift[0].servers[1].hash);
1560 }
1561
1562 /// One host answering for one producer is still nothing to compare.
1563 #[test]
1564 fn a_lone_host_serving_a_name_is_not_a_disagreement() {
1565 assert!(
1566 schema_drift(&[DescribedSchema::new(
1567 "h-aaaaaaaaaaaa",
1568 "sysinfo",
1569 set_with("Health", serde_json::json!({"type":"object"})),
1570 )])
1571 .is_empty()
1572 );
1573 }
1574
1575 /// Totality: a slice-referenced type absent from the served describe is a
1576 /// gap; a producer that served no describe is not judged here.
1577 #[test]
1578 fn totality_gaps_check_only_served_producers() {
1579 use zenkey::slice::{RegistrySlice, SubjectDecl};
1580 let mut subject = SubjectDecl::new("cpu", zenkey::Class::Telemetry);
1581 subject.type_name = "TelemetryPoint".into();
1582 let mut slice = RegistrySlice::new("1", "a", "sysinfo");
1583 slice.subjects = vec![subject];
1584 let slices = crate::model::registry::SliceSet::from_slices(vec![slice]);
1585
1586 // Served describe missing the referenced type: one gap.
1587 let incomplete = SchemaSet::builder("a")
1588 .entry(
1589 "Other",
1590 zenkey::schema::TypeSchema::json_schema(serde_json::json!({"type":"object"})),
1591 )
1592 .build();
1593 let gaps = totality_gaps(&[("sysinfo".to_string(), incomplete)], &slices);
1594 assert_eq!(gaps.len(), 1);
1595 assert_eq!(gaps[0].missing, ["TelemetryPoint"]);
1596
1597 // No describe served at all: not judged by totality.
1598 assert!(totality_gaps(&[], &slices).is_empty());
1599 }
1600
1601 /// An untyped subject (empty `type`) references nothing — it must not
1602 /// demand a schema for `""` (regression: phantom gap found while
1603 /// consolidating doctor's totality check onto this function, #55).
1604 #[test]
1605 fn an_untyped_subject_is_not_a_totality_gap() {
1606 use zenkey::slice::{RegistrySlice, SubjectDecl};
1607 let mut subject = SubjectDecl::new("raw", zenkey::Class::Telemetry);
1608 subject.type_name = String::new();
1609 let mut slice = RegistrySlice::new("1", "a", "sysinfo");
1610 slice.subjects = vec![subject];
1611 let slices = crate::model::registry::SliceSet::from_slices(vec![slice]);
1612 let served = SchemaSet::builder("a").build();
1613 assert!(
1614 totality_gaps(&[("sysinfo".to_string(), served)], &slices).is_empty(),
1615 "empty type names must be filtered, not reported as gaps"
1616 );
1617 }
1618
1619 /// Issue #101: the two ways of learning nothing are different facts and
1620 /// must not share a bound. Zero replies is the RFC 05 §3.1 non-verdict —
1621 /// it backs off in milliseconds and grows; an answer that served nothing
1622 /// usable keeps the full 60s.
1623 #[test]
1624 fn a_zero_reply_ask_backs_off_fast_and_an_answered_one_does_not() {
1625 let now = std::time::Instant::now();
1626 let no_reply = |attempts| Missing {
1627 reason: MissReason::NoReplies,
1628 asked: now,
1629 attempts,
1630 };
1631 assert_eq!(no_reply(1).backoff(), NO_REPLY_BACKOFF);
1632 assert_eq!(no_reply(2).backoff(), NO_REPLY_BACKOFF * 2);
1633 assert_eq!(no_reply(3).backoff(), NO_REPLY_BACKOFF * 4);
1634 // …and it converges on the same bound a genuinely absent producer
1635 // deserves, rather than re-asking forever.
1636 assert_eq!(no_reply(30).backoff(), NOT_SERVED_TTL);
1637
1638 let answered = Missing {
1639 reason: MissReason::AnsweredUnusable,
1640 asked: now,
1641 attempts: 0,
1642 };
1643 assert_eq!(
1644 answered.backoff(),
1645 NOT_SERVED_TTL,
1646 "a producer that answered and served nothing is asked once per TTL"
1647 );
1648 }
1649
1650 /// The first zero-reply backoff must be short enough that an explorer
1651 /// started before its fleet is not blind for a human-noticeable time.
1652 #[test]
1653 fn the_first_reask_is_sub_second() {
1654 let m = Missing {
1655 reason: MissReason::NoReplies,
1656 asked: std::time::Instant::now(),
1657 attempts: 1,
1658 };
1659 assert!(m.backoff() < Duration::from_secs(1));
1660 assert!(!m.may_reask(), "and not before it elapses");
1661 }
1662}