Skip to main content

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}