zenkey_fleet/bus/roster.rs
1//! The liveliness roster (RFC 04 §5).
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use crate::report::{Freshness, MediaStreamInfo, NodeInfo, ProducerInfo};
7use crate::{Error, Result};
8use zenkey::grammar::with_base;
9
10/// The fleet-presence roster: who is up, and what they run.
11///
12/// RFC 04 §5 — a liveliness query on `<base>/v1/*/state/*/alive`. Zero
13/// payload bytes: the token *key* is the record. `@catalog` is asked for by
14/// name because `*` can never match a verbatim service origin (property D4).
15pub async fn roster(
16 fleet: &crate::Fleet<'_>,
17 timeout: Duration,
18) -> Result<BTreeMap<String, Vec<String>>> {
19 let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
20
21 let catalog_alive = zenkey::selector::service_alive(&zenkey::ServiceOrigin::catalog());
22
23 // The builders are base-relative; this session is deliberately
24 // un-namespaced, so it must spell the base itself.
25 for expr in [
26 fleet.wire(zenkey::selector::all_liveliness(
27 zenkey::selector::Scope::fleet(),
28 )),
29 fleet.wire(catalog_alive),
30 ] {
31 let Ok(replies) = fleet
32 .session()
33 .liveliness()
34 .get(&expr)
35 .timeout(timeout)
36 .await
37 else {
38 continue;
39 };
40 while let Ok(reply) = replies.recv_async().await {
41 let Ok(sample) = reply.result() else { continue };
42 let key = sample.key_expr().as_str();
43 let Some((origin, producer)) = token_identity(fleet.base(), key) else {
44 continue;
45 };
46 out.entry(origin).or_default().push(producer);
47 }
48 }
49 for producers in out.values_mut() {
50 producers.sort();
51 producers.dedup();
52 }
53 Ok(out)
54}
55
56/// A live roster, driven by liveliness events rather than polled (#56).
57///
58/// The roster is *pushed* by the bus, so a `--watch` on it has no business
59/// running a timer. Both explorers had the same loop — seed with one GET,
60/// subscribe with history, coalesce a burst, re-render only on a real change
61/// — and zenctl's copy had drifted into `cmd/node.rs` alongside a duplicate of
62/// the polling driver's cycle body (issue #207). This is that loop, once.
63///
64/// Zero data-plane subscribers by construction: the monitor is started with an
65/// empty selector list and only liveliness selectors, so watching the roster
66/// costs nothing on the data plane (the lazy contract, #85).
67pub struct RosterWatch {
68 monitor: crate::Monitor,
69 events: crate::EventStream,
70 roster: BTreeMap<String, Vec<String>>,
71 base: String,
72 /// What [`next_change`](RosterWatch::next_change) has applied to `roster`
73 /// but not yet reported — the accumulator, held here rather than in the
74 /// poll's stack frame so a dropped poll cannot take it with it (#328).
75 pending: RosterChange,
76}
77
78/// What one coalesced burst of liveliness events did to the roster.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub struct RosterChange {
81 /// At least one producer appeared. The caller may want to re-read the
82 /// registry — a new producer can serve a slice nothing has asked for yet —
83 /// and this says so once per burst rather than once per event.
84 pub node_up: bool,
85 /// At least one producer went away.
86 pub node_down: bool,
87}
88
89/// How long a burst is drained before rendering. Liveliness storms arrive in
90/// clumps (a host booting declares every producer at once); one frame per
91/// clump beats one frame per token.
92const BURST_QUIET: Duration = Duration::from_millis(50);
93
94impl RosterWatch {
95 /// Subscribe, then seed.
96 ///
97 /// Both, and in that order: the monitor's history-backed events can land
98 /// in the broadcast before this task drains it, so history alone races,
99 /// and a GET alone would miss everything after it. Duplicates from a late
100 /// history event are absorbed by [`apply_token`]'s idempotence.
101 pub async fn start(fleet: &crate::Fleet<'_>, timeout: Duration) -> Result<RosterWatch> {
102 let liveliness = vec![
103 fleet.wire(zenkey::selector::all_liveliness(
104 zenkey::selector::Scope::fleet(),
105 )),
106 fleet.wire(zenkey::selector::service_alive(
107 &zenkey::ServiceOrigin::catalog(),
108 )),
109 ];
110 let monitor = crate::Monitor::start(
111 fleet.session(),
112 crate::MonitorSpec {
113 selectors: vec![],
114 liveliness,
115 ..Default::default()
116 },
117 )
118 .await?;
119 let events = monitor.events();
120 let roster = roster(fleet, timeout).await?;
121 Ok(RosterWatch {
122 monitor,
123 events,
124 roster,
125 base: fleet.base().to_string(),
126 pending: RosterChange::default(),
127 })
128 }
129
130 /// The roster as it stands. Valid immediately after [`start`](Self::start)
131 /// — the first frame is the seed, and "0 producers" is a statement rather
132 /// than silence (RFC 05 §3.1).
133 pub fn roster(&self) -> &BTreeMap<String, Vec<String>> {
134 &self.roster
135 }
136
137 /// Wait for the roster to actually change, coalescing a burst into one
138 /// answer. `None` = the event stream closed.
139 ///
140 /// Never returns for a burst that changed nothing, so a caller can render
141 /// on every `Some` without checking.
142 ///
143 /// **Cancel-safe** (#328): [`apply_token`] mutates `self.roster` the moment
144 /// an event lands, and a burst is drained across an await — so a poll
145 /// dropped in that await had already changed the roster. The accumulator
146 /// therefore lives in `self.pending`, not on the stack: dropping this
147 /// future loses the *wait*, never the change, and the next call reports it
148 /// before it listens for anything further. A roster that moved while its
149 /// caller was told nothing is a hole in the window with no later event
150 /// bound to correct it — RFC 13 §3 O6, where an unreported gap converts
151 /// "I missed it" into "it never happened".
152 pub async fn next_change(&mut self) -> Option<RosterChange> {
153 next_change_in(
154 &mut self.events,
155 &mut self.roster,
156 &self.base,
157 &mut self.pending,
158 )
159 .await
160 }
161
162 /// The same coalesced changes as a [`Stream`](futures_core::Stream)
163 /// (#343).
164 ///
165 /// **Borrowing, deliberately**: [`stop`](Self::stop) is an acknowledged
166 /// teardown that consumes `self` (#207/#336), and a stream that moved the
167 /// watch in would leave a caller no way to reach it — a half-torn-down
168 /// monitor is exactly what that teardown exists to prevent. Hold the
169 /// watch, take the stream, drop the stream, then `stop`.
170 ///
171 /// Cancel-safety carries over unchanged, because the accumulator lives in
172 /// `self.pending` rather than in a poll's stack frame (#328): a stream
173 /// dropped mid-burst keeps the transitions it had already applied, and the
174 /// next one reports them.
175 pub fn changes(&mut self) -> impl futures_core::Stream<Item = RosterChange> + '_ {
176 futures_util::stream::unfold(self, |watch| async move {
177 watch.next_change().await.map(|change| (change, watch))
178 })
179 }
180
181 /// Release the subscriptions, **acknowledged**.
182 ///
183 /// On every exit path, which the zenctl original managed only on Ctrl-C:
184 /// its channel-closed arm returned before reaching `monitor.stop()`, so a
185 /// closed stream leaked the liveliness subscribers (#207).
186 ///
187 /// And awaited, which it only looked like (#336): this was an `async fn`
188 /// that awaited nothing, calling the `Drop` teardown and leaving the
189 /// liveliness subscribers to undeclare in the background. It now goes
190 /// through [`crate::Monitor::shutdown`], so the caller that waits for this
191 /// gets what waiting was for.
192 pub async fn stop(self) -> Result<()> {
193 self.monitor.shutdown().await
194 }
195}
196
197/// [`RosterWatch::next_change`]'s body over its four moving parts — the seam
198/// that lets the cancellation contract be tested against a bare
199/// [`crate::MonitorCore`], with no session and no bus.
200async fn next_change_in(
201 events: &mut crate::EventStream,
202 roster: &mut BTreeMap<String, Vec<String>>,
203 base: &str,
204 pending: &mut RosterChange,
205) -> Option<RosterChange> {
206 loop {
207 // First, whatever a previous — possibly cancelled — poll applied.
208 if let Some(change) = take_change(pending) {
209 return Some(change);
210 }
211 let mut item = events.recv().await;
212 loop {
213 match item {
214 // Closed: report this burst's work, and say so on the next
215 // call — the roster moved, and a closing stream is no reason
216 // to drop the last thing it said.
217 None => return take_change(pending),
218 Some(crate::StreamItem::Dropped(_)) => {}
219 Some(crate::StreamItem::Event(ev)) => {
220 let transition = match ev {
221 crate::FleetEvent::NodeUp(key) => Some((key, true)),
222 crate::FleetEvent::NodeDown(key) => Some((key, false)),
223 _ => None,
224 };
225 if let Some((key, up)) = transition
226 && apply_token(roster, base, &key, up)
227 {
228 if up {
229 pending.node_up = true;
230 } else {
231 pending.node_down = true;
232 }
233 }
234 }
235 }
236 match tokio::time::timeout(BURST_QUIET, events.recv()).await {
237 Ok(next) => item = next,
238 Err(_) => break,
239 }
240 }
241 }
242}
243
244/// Take the accumulated change, leaving nothing behind. `None` when the burst
245/// moved nothing — a caller renders on every `Some` without checking.
246fn take_change(pending: &mut RosterChange) -> Option<RosterChange> {
247 if !pending.node_up && !pending.node_down {
248 return None;
249 }
250 Some(std::mem::take(pending))
251}
252
253/// Who a liveliness token names: `(origin, producer)`, or `None` when the key
254/// is not a token under this base.
255///
256/// One home for a rule that had two (issue #207): `roster()` read it off a GET
257/// reply and `zenctl node list --watch` re-derived it, character for
258/// character, from a `NodeUp`/`NodeDown` event. `@catalog`'s token has no
259/// producer chunk — the service *is* the producer — and everything else names
260/// its producer in position 5.
261pub fn token_identity(base: &str, key: &str) -> Option<(String, String)> {
262 let parsed = zenkey::grammar::parse_full(base, key)?;
263 let origin = parsed.origin.chunk().to_string();
264 let producer = parsed
265 .producer()
266 .map(|p| p.chunk())
267 .unwrap_or_else(|| origin.trim_start_matches('@').to_string());
268 Some((origin, producer))
269}
270
271/// Apply one liveliness transition to a roster. Returns whether it changed
272/// anything — a burst of no-op events must not force a re-render.
273///
274/// Idempotent on the way up, which is what makes a seeded roster safe: the
275/// history-backed events a monitor replays can land after the one-shot GET
276/// that seeded it, and a duplicate `NodeUp` returns `false` rather than
277/// double-listing the producer.
278pub fn apply_token(
279 roster: &mut BTreeMap<String, Vec<String>>,
280 base: &str,
281 key: &str,
282 up: bool,
283) -> bool {
284 let Some((origin, producer)) = token_identity(base, key) else {
285 return false;
286 };
287 if up {
288 let entry = roster.entry(origin).or_default();
289 if entry.contains(&producer) {
290 return false;
291 }
292 entry.push(producer);
293 entry.sort();
294 return true;
295 }
296 let Some(entry) = roster.get_mut(&origin) else {
297 return false;
298 };
299 let before = entry.len();
300 entry.retain(|p| p != &producer);
301 let changed = entry.len() != before;
302 if entry.is_empty() {
303 roster.remove(&origin);
304 }
305 changed
306}
307
308/// Roster → typed rows, joining the slice facts when given (`--verbose`).
309/// Absent slice = `None` fields, never a default (RFC 09 §5.1 O4).
310pub fn node_rows(
311 roster: &BTreeMap<String, Vec<String>>,
312 slices: Option<&crate::SliceSet>,
313) -> crate::report::NodeList {
314 let mut nodes = Vec::new();
315
316 for (origin, producers) in roster {
317 for producer in producers {
318 let joined = slices.and_then(|s| {
319 // Instance suffixes share the base slice (RFC 03 §1.5).
320 let base_name = zenkey::grammar::Producer::parse_chunk(producer)
321 .map(|pr| pr.name().to_string())
322 .unwrap_or_else(|_| producer.clone());
323 s.get(&base_name)
324 });
325 nodes.push(crate::report::NodeRow {
326 origin: origin.clone(),
327 producer: producer.clone(),
328 app: joined.map(|s| s.app.clone()),
329 registry_version: joined.map(|s| s.version.clone()),
330 });
331 }
332 }
333 crate::report::NodeList {
334 nodes,
335 slices_joined: slices.is_some(),
336 }
337}
338
339/// How one origin string spells its two framework keys. A host and a service
340/// differ in both (`v1/<h>/state/*/alive` + a producer chunk in `@rpc`, versus
341/// `v1/@svc/state/alive` + no producer chunk), and a `*` can reach neither
342/// other's shape — so the split is made once, up front, rather than guessed
343/// per key (D4).
344enum Node {
345 Host(zenkey::origin::RemoteOrigin),
346 Service(zenkey::ServiceOrigin),
347}
348
349impl Node {
350 fn parse(origin: &str) -> Result<Node> {
351 if origin.starts_with('@') {
352 zenkey::ServiceOrigin::new(origin)
353 .map(Node::Service)
354 .map_err(Error::from)
355 } else {
356 zenkey::origin::RemoteOrigin::parse(origin)
357 .map(Node::Host)
358 .map_err(|e| {
359 Error::unaskable(
360 "origin",
361 format!("{e} — a hostname is not an origin (RFC 06 §6)"),
362 )
363 })
364 }
365 }
366
367 /// This node's liveliness tokens, and nothing else's.
368 fn alive_selector(&self) -> String {
369 match self {
370 Node::Host(o) => {
371 zenkey::selector::all_liveliness(zenkey::selector::Scope::origin(o)).to_string()
372 }
373 Node::Service(o) => zenkey::selector::service_alive(o).to_string(),
374 }
375 }
376
377 /// This node's producers' `introspect`, and nothing else's.
378 fn introspect_selector(&self) -> String {
379 match self {
380 Node::Host(o) => zenkey::selector::rpc(
381 zenkey::selector::Scope::origin(o),
382 zenkey::selector::Producers::all(),
383 &["introspect"],
384 )
385 .to_string(),
386 Node::Service(o) => zenkey::selector::service_rpc(o, &["introspect"]).to_string(),
387 }
388 }
389
390 /// This node's state subtree. `**` cannot cross an `@` chunk (D2), so this
391 /// cannot pull a plane however deep the subject tail runs.
392 fn state_selector(&self) -> String {
393 let scope = match self {
394 Node::Host(o) => zenkey::selector::Scope::origin(o),
395 Node::Service(o) => zenkey::selector::Scope::origin(o),
396 };
397 zenkey::selector::all_state(scope).to_string()
398 }
399}
400
401/// Assemble one node's full story (issue #40; feeds `zenctl node info` and
402/// the zengui dashboard).
403///
404/// Three bounded sweeps, **all three scoped to the asked origin** (issue #96 —
405/// before it, this re-ran the whole fleet roster and a fleet-wide introspect
406/// fan-in per call and then filtered, which the zengui node dashboard pays for
407/// on every card click): this origin's liveliness tokens, this origin's
408/// producers' introspect replies (per-origin truth, not the fleet-deduped
409/// `SliceSet`), and — when `with_freshness` — one state GET on this origin
410/// only (D2 guarantees it cannot pull planes).
411///
412/// Narrower is also *more* honest: the answers can no longer be diluted by a
413/// deduplication across origins that never applied to this one.
414pub async fn node_info(
415 fleet: &crate::Fleet<'_>,
416 origin: &str,
417 timeout: Duration,
418 with_freshness: bool,
419) -> Result<NodeInfo> {
420 let (session, base) = (fleet.session(), fleet.base());
421
422 let node = Node::parse(origin)?;
423
424 // Liveliness, this origin only. A producer chunk is position 5 for a host;
425
426 // `@catalog`'s token has none — the service *is* the producer.
427 let mut alive: Vec<String> = Vec::new();
428 let alive_expr = with_base(base, node.alive_selector());
429 if let Ok(replies) = session.liveliness().get(&alive_expr).timeout(timeout).await {
430 while let Ok(reply) = replies.recv_async().await {
431 let Ok(sample) = reply.result() else { continue };
432 let Some(parsed) = zenkey::grammar::parse_full(base, sample.key_expr().as_str()) else {
433 continue;
434 };
435 alive.push(
436 parsed
437 .producer()
438 .map(|p| p.chunk())
439 .unwrap_or_else(|| parsed.origin.chunk().trim_start_matches('@').to_string()),
440 );
441 }
442 }
443 alive.sort();
444 alive.dedup();
445
446 // Per-origin capabilities: one origin-scoped introspect GET. Replies are
447 // still attributed by reply key, so a router that answered for somebody
448 // else could not smuggle a slice in.
449 let introspect = with_base(base, node.introspect_selector());
450 let answers = crate::bus::query::fleet_get(
451 fleet,
452 &introspect,
453 &crate::bus::query::GetOpts::new(timeout),
454 )
455 .await
456 .unwrap_or_default();
457 let served: Vec<zenkey::slice::RegistrySlice> = answers
458 .into_iter()
459 .filter(|a| a.origin == origin)
460 .filter_map(|a| {
461 let crate::bus::query::Answer::Value(bytes) = a.answer else {
462 return None;
463 };
464 let toml = String::from_utf8_lossy(&bytes.to_bytes()).to_string();
465 match zenkey::parse_slice(&toml) {
466 Ok(slice) => Some(slice),
467 Err(e) => {
468 tracing::warn!(origin, "introspect reply did not parse, skipping: {e}");
469 None
470 }
471 }
472 })
473 .collect();
474 let mine: Vec<&zenkey::slice::RegistrySlice> = served.iter().collect();
475
476 let mut names: Vec<String> = alive.clone();
477 names.extend(mine.iter().map(|s| s.name.clone()));
478 names.sort();
479 names.dedup();
480
481 let producers: Vec<ProducerInfo> = names
482 .iter()
483 .map(|name| {
484 let slice = mine.iter().find(|s| &s.name == name);
485 ProducerInfo {
486 name: name.clone(),
487 alive: alive.iter().any(|a| a == name),
488 app: slice.map(|s| s.app.clone()),
489 registry_version: slice.map(|s| s.version.clone()),
490 subjects: slice.map(|s| s.subjects.len()).unwrap_or(0),
491 procedures: slice.map(|s| s.procedures.len()).unwrap_or(0),
492 blob_tiers: slice
493 .map(|s| s.blob.iter().map(|b| b.tier.token().to_string()).collect())
494 .unwrap_or_default(),
495 media: slice
496 .map(|s| {
497 s.media
498 .iter()
499 .map(|m| MediaStreamInfo {
500 path: m.path.clone(),
501 encoding: m.encoding.as_encoding_str().to_string(),
502 })
503 .collect()
504 })
505 .unwrap_or_default(),
506 deprecated_served: slice.map(|s| s.deprecated.len()).unwrap_or(0),
507 }
508 })
509 .collect();
510
511 let mut freshness = Vec::new();
512 if with_freshness && !mine.is_empty() {
513 // One origin-scoped state sweep; join against declared ttl_s.
514 let selector = with_base(base, node.state_selector());
515 let samples = crate::bus::query::state_snapshot(session, &selector, timeout, None)
516 .await
517 .unwrap_or_default();
518 let now = std::time::SystemTime::now();
519 for slice in &mine {
520 for subject in &slice.subjects {
521 let Some(ttl) = subject.ttl_s else { continue };
522 if !subject.class.is(&zenkey::Class::State) {
523 continue;
524 }
525 // Newest sample whose tail refines to this subject.
526 let age = samples
527 .iter()
528 .filter_map(|s| {
529 let parsed = zenkey::grammar::parse_full(base, &s.key)?;
530 let p = parsed.producer()?.name().to_string();
531 if p != slice.name {
532 return None;
533 }
534 let tail: Vec<&str> = parsed.subject.clone();
535 let pattern = zenkey::pattern::SubjectPattern::parse(&subject.path).ok()?;
536 pattern.matches(&tail)?;
537 s.timestamp.map(|t| {
538 now.duration_since(t.get_time().to_system_time())
539 .map(|d| d.as_secs() as i64)
540 .unwrap_or(0)
541 })
542 })
543 .min();
544 freshness.push(Freshness {
545 producer: slice.name.clone(),
546 path: subject.path.clone(),
547 ttl_s: ttl,
548 age_s: age,
549 stale: match age {
550 Some(a) => a > ttl,
551 // Declared live state with no sample anywhere: stale
552 // in the sense that matters — but the age stays None.
553 None => true,
554 },
555 });
556 }
557 }
558 }
559
560 Ok(NodeInfo {
561 origin: origin.to_string(),
562 producers,
563 freshness,
564 })
565}
566
567/// One origin claiming a human identity label, through the health-document
568/// bridge (RFC 06 §6.2 bridge 1).
569#[derive(Debug, Clone, PartialEq, Eq)]
570pub struct BridgeMatch {
571 /// The origin id — the payload `host_id`, which IS the origin (§6.1).
572 pub host_id: zenkey::origin::HostId,
573 /// The display label the document carried (`source`).
574 pub source: String,
575 /// The key the claim arrived on — self-certifying, because the doc is
576 /// origin-scoped and carries `host_id` beside `source`.
577 pub key: String,
578}
579
580/// Resolve a human identity (hostname, `source` label) to the origin(s)
581/// claiming it — the consumer identity bridge, run the sanctioned way
582/// (RFC 06 §6.2): GET the fleet's `state/<producer>/health` documents and
583/// read `host_id` beside `source`. Every match is returned; the *caller*
584/// prices zero (the bridge yielded nothing — a probe MUST fail there,
585/// RFC 09 §6) and more-than-one (a hostname collision is exactly the
586/// misrouting hazard §6.2 names).
587///
588/// A document without both fields is skipped silently here — it is not a
589/// claim about this label either way — but the total documents seen ride
590/// back so the caller can tell "no claims" from "nobody answered".
591pub async fn bridge_resolve(
592 fleet: &crate::Fleet<'_>,
593 producer: &str,
594 label: &str,
595 timeout: std::time::Duration,
596) -> Result<(Vec<BridgeMatch>, usize)> {
597 let relative =
598 zenkey::selector::producer_state(zenkey::selector::Scope::fleet(), producer, &["health"])
599 .to_string();
600 let key = fleet.wire(relative);
601 let answers =
602 crate::bus::query::fleet_get(fleet, &key, &crate::bus::query::GetOpts::new(timeout))
603 .await?;
604 let mut matches = Vec::new();
605 let seen = answers.len();
606 for a in &answers {
607 let crate::bus::query::Answer::Value(bytes) = &a.answer else {
608 continue;
609 };
610 let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&bytes.to_bytes()) else {
611 continue;
612 };
613 let (Some(host_id), Some(source)) = (
614 doc.get("host_id").and_then(|v| v.as_str()),
615 doc.get("source").and_then(|v| v.as_str()),
616 ) else {
617 continue;
618 };
619 if source == label
620 && let Ok(id) = zenkey::origin::HostId::parse(host_id)
621 {
622 matches.push(BridgeMatch {
623 host_id: id,
624 source: source.to_string(),
625 key: a.key.clone(),
626 });
627 }
628 }
629 matches.dedup_by(|a, b| a.host_id == b.host_id);
630 Ok((matches, seen))
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 /// The rule that had two homes (#207): `roster()` read it off a GET reply
638 /// and the CLI's watch loop re-derived it from a liveliness event.
639 #[test]
640 fn a_token_names_its_origin_and_producer() {
641 assert_eq!(
642 token_identity("acme", "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
643 Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
644 );
645 // A service origin's token carries no producer chunk — the service is
646 // the producer (RFC 06 §5).
647 assert_eq!(
648 token_identity("acme", "acme/v1/@catalog/state/alive"),
649 Some(("@catalog".into(), "catalog".into()))
650 );
651 // The base-less deployment is a real one (RFC v1.6).
652 assert_eq!(
653 token_identity("", "v1/h-3fa9c2d41b7e/state/sysinfo/alive"),
654 Some(("h-3fa9c2d41b7e".into(), "sysinfo".into()))
655 );
656 assert_eq!(token_identity("acme", "demo/not/a/token"), None);
657 }
658
659 /// Idempotent up, subtractive down — what makes seeding safe. The monitor
660 /// replays history-backed tokens that the seeding GET may already have
661 /// returned, and a double `NodeUp` must not double-list the producer or
662 /// force a redundant frame.
663 #[test]
664 fn applying_a_token_reports_only_real_changes() {
665 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
666 let key = "acme/v1/h-3fa9c2d41b7e/state/sysinfo/alive";
667
668 assert!(apply_token(&mut roster, "acme", key, true));
669 assert!(
670 !apply_token(&mut roster, "acme", key, true),
671 "a replayed history token is not a change"
672 );
673 assert_eq!(roster["h-3fa9c2d41b7e"], ["sysinfo"]);
674
675 // A second producer on the same origin sorts in.
676 let other = "acme/v1/h-3fa9c2d41b7e/state/alerts/alive";
677 assert!(apply_token(&mut roster, "acme", other, true));
678 assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts", "sysinfo"]);
679
680 assert!(apply_token(&mut roster, "acme", key, false));
681 assert!(
682 !apply_token(&mut roster, "acme", key, false),
683 "retracting what is already gone is not a change"
684 );
685 assert_eq!(roster["h-3fa9c2d41b7e"], ["alerts"]);
686
687 // The last producer leaving takes the origin with it: an origin with
688 // no producers is not a fact worth rendering.
689 assert!(apply_token(&mut roster, "acme", other, false));
690 assert!(roster.is_empty());
691
692 // An unparseable key changes nothing and does not panic (O1).
693 assert!(!apply_token(&mut roster, "acme", "demo/foreign", true));
694 }
695
696 /// The join is `None`-on-absence, never a default (O4), and an instance
697 /// suffix shares its base slice (RFC 03 §1.5).
698 #[test]
699 fn rows_say_whether_a_slice_was_even_asked_for() {
700 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
701 roster.insert(
702 "h-3fa9c2d41b7e".into(),
703 vec!["sysinfo".into(), "sysinfo-2".into()],
704 );
705
706 let unasked = node_rows(&roster, None);
707 assert!(!unasked.slices_joined, "no join was attempted");
708 assert!(unasked.nodes.iter().all(|n| n.app.is_none()));
709
710 let slice = zenkey::parse_slice(
711 "[registry]\nversion = \"1.0\"\napp = \"demo\"\nconvention = 1\n\
712 [producer]\nname = \"sysinfo\"\n",
713 )
714 .expect("fixture slice parses");
715 let joined = node_rows(&roster, Some(&crate::SliceSet::from_slices(vec![slice])));
716 assert!(joined.slices_joined);
717 assert_eq!(joined.nodes.len(), 2);
718 for row in &joined.nodes {
719 assert_eq!(
720 row.app.as_deref(),
721 Some("demo"),
722 "an instance suffix shares the base producer's slice: {}",
723 row.producer
724 );
725 }
726 assert_eq!(
727 joined.nodes[1].producer, "sysinfo-2",
728 "the row keeps the suffix"
729 );
730 }
731
732 /// The cancellation contract (#328): a poll dropped mid-burst has already
733 /// mutated the roster, so the change it accumulated must survive the drop.
734 /// It used to live in the poll's stack frame and die with it — the roster
735 /// moved, the caller was told nothing, and the display stayed stale until
736 /// some unrelated later token happened to arrive.
737 ///
738 /// Time is paused, so the two windows below are exact rather than raced:
739 /// the token is ready immediately, and the poll is then dropped inside
740 /// [`BURST_QUIET`] while it waits for the rest of the burst.
741 #[tokio::test(start_paused = true)]
742 async fn a_cancelled_poll_keeps_the_change_it_already_applied() {
743 let core = crate::MonitorCore::new(16);
744 let mut events = core.events();
745 let mut roster: BTreeMap<String, Vec<String>> = BTreeMap::new();
746 let mut pending = RosterChange::default();
747
748 core.node_event("v1/h-3fa9c2d41b7e/state/sysinfo/alive".into(), true);
749
750 let cancelled = tokio::time::timeout(
751 BURST_QUIET / 2,
752 next_change_in(&mut events, &mut roster, "", &mut pending),
753 )
754 .await;
755 assert!(cancelled.is_err(), "the poll is still draining the burst");
756 assert!(
757 roster.contains_key("h-3fa9c2d41b7e"),
758 "the token was applied before the drop"
759 );
760
761 // …and the next call reports it, without waiting on the bus for a
762 // second event that may never come.
763 let change = tokio::time::timeout(
764 BURST_QUIET / 2,
765 next_change_in(&mut events, &mut roster, "", &mut pending),
766 )
767 .await
768 .expect("the applied change is reported, not waited on")
769 .expect("a change, not a closed stream");
770 assert_eq!(
771 change,
772 RosterChange {
773 node_up: true,
774 node_down: false
775 }
776 );
777 }
778}