Skip to main content

zenkey_fleet/model/
consumers.rs

1//! The consumers join (#224): declared readers, related to a target by key
2//! algebra and attributed to origins on admin evidence — from values in
3//! hand, no session.
4//!
5//! "Who consumes this subject?" decides whether a schema change is safe,
6//! and on this bus it is a join over data other sweeps already fetch:
7//! [`crate::declared_entities`] enumerates every declared subscriber,
8//! querier and queryable with its verbatim keyexpr per zid;
9//! `zenoh-keyexpr` gives exact `includes`/`intersects`;
10//! [`crate::origin_attachments`] maps zid → origin on token evidence; the
11//! registry names the subject. Nothing here is matching status (RFC 12 §9,
12//! deferred permanently): a declared subscriber is a declaration, not proof
13//! of use, and the vocabulary says so.
14
15use zenoh::key_expr::keyexpr;
16
17use crate::report::{
18    Attribution, ConsumerRow, DeclaredEntities, DeprecationFact, EntityKind, OriginAttachment,
19    Relation, TopologyReport,
20};
21
22/// Relate every declared subscriber and querier to `target`, one row per
23/// declaring session, ranked most-specific first.
24///
25/// * A relation is computed with `zenoh-keyexpr`: the target including the
26///   declaration is [`Relation::Narrower`], the reverse [`Relation::Wider`],
27///   both [`Relation::Exact`], neither but overlapping
28///   [`Relation::Intersects`]; a declaration that includes the whole base
29///   (`**`, or any prefix that includes `<base>/v1/**`) is
30///   [`Relation::Total`] — it intersects everything and is shown as such.
31///   A declaration that does not intersect the target is not a row.
32/// * The zid comes from the entity's admin `sources` when they name any
33///   session ([`Attribution::Session`], one row each); when they name none
34///   the row carries the reporting node's zid as
35///   [`Attribution::ReportedOnly`] — stated, never guessed.
36/// * Origins are the attachments whose `session_zid` is the row's zid;
37///   `whatami` comes from the topology's roster when it heard of the zid.
38///
39/// A `target` that is not a key expression yields no rows; the bus layer
40/// refuses it before asking anything ([`crate::consumers`]).
41pub fn join_consumers(
42    target: &str,
43    declared: &DeclaredEntities,
44    attachments: &[OriginAttachment],
45    topology: Option<&TopologyReport>,
46    self_zid: &str,
47) -> Vec<ConsumerRow> {
48    let Ok(target_ke) = keyexpr::new(target) else {
49        return Vec::new();
50    };
51    let base_tree = base_tree_of(target);
52    let base_ke = keyexpr::new(base_tree.as_str()).ok();
53
54    let mut rows: Vec<ConsumerRow> = Vec::new();
55    for entity in &declared.entities {
56        if !matches!(entity.kind, EntityKind::Subscriber | EntityKind::Querier) {
57            continue;
58        }
59        let Ok(declared_ke) = keyexpr::new(entity.keyexpr.as_str()) else {
60            // The admin surface is version-dependent; a shape this build
61            // cannot read is skipped, never a failure.
62            continue;
63        };
64        let Some(relation) = relate(target_ke, declared_ke, base_ke) else {
65            continue;
66        };
67        let zids = crate::bus::admin::source_zids(&entity.sources);
68        let attributed: Vec<(String, Attribution)> = if zids.is_empty() {
69            vec![(entity.node_zid.clone(), Attribution::ReportedOnly)]
70        } else {
71            zids.into_iter()
72                .map(|z| (z, Attribution::Session))
73                .collect()
74        };
75        for (zid, attribution) in attributed {
76            // Several admin spaces can report one declaration (a router and
77            // the declaring peer both serve it): one row per (session,
78            // kind, keyexpr), not one per reporter.
79            if rows
80                .iter()
81                .any(|r| r.zid == zid && r.kind == entity.kind && r.keyexpr == entity.keyexpr)
82            {
83                continue;
84            }
85            let mut origins: Vec<String> = attachments
86                .iter()
87                .filter(|a| a.session_zid.as_deref() == Some(zid.as_str()))
88                .map(|a| a.origin.clone())
89                .collect();
90            origins.sort();
91            origins.dedup();
92            let whatami = topology
93                .and_then(|t| t.nodes.iter().find(|n| n.zid == zid))
94                .map(|n| n.whatami.clone());
95            rows.push(ConsumerRow {
96                is_self: zid == self_zid,
97                zid,
98                whatami,
99                origins,
100                attribution,
101                kind: entity.kind,
102                keyexpr: entity.keyexpr.clone(),
103                relation,
104                total_wildcard: relation == Relation::Total,
105            });
106        }
107    }
108    rows.sort_by(|a, b| {
109        a.relation
110            .cmp(&b.relation)
111            .then_with(|| a.keyexpr.cmp(&b.keyexpr))
112            .then_with(|| a.zid.cmp(&b.zid))
113    });
114    rows
115}
116
117/// The relation of one declaration to the target, or `None` when the two
118/// do not intersect at all.
119fn relate(target: &keyexpr, declared: &keyexpr, base: Option<&keyexpr>) -> Option<Relation> {
120    if !declared.intersects(target) {
121        return None;
122    }
123    if base.is_some_and(|b| declared.includes(b)) {
124        return Some(Relation::Total);
125    }
126    let wider = declared.includes(target);
127    let narrower = target.includes(declared);
128    Some(match (wider, narrower) {
129        (true, true) => Relation::Exact,
130        (false, true) => Relation::Narrower,
131        (true, false) => Relation::Wider,
132        (false, false) => Relation::Intersects,
133    })
134}
135
136/// The whole-base tree the target sits in: everything up to and including
137/// the `v1` chunk, then `**` (`acme/v1/**`, or `v1/**` at the bus root). A
138/// target outside the grammar has no base to speak of, and only a bare `**`
139/// is total for it.
140fn base_tree_of(target: &str) -> String {
141    let chunks: Vec<&str> = target.split('/').collect();
142    match chunks.iter().position(|c| *c == "v1") {
143        Some(i) => {
144            let mut prefix = chunks[..=i].join("/");
145            prefix.push_str("/**");
146            prefix
147        }
148        None => "**".to_string(),
149    }
150}
151
152/// Count the distinct sessions declaring an entity of `kind` that
153/// intersects `target` — the `impact` report's publisher and queryable
154/// columns. A session is its `sources` zids, or the reporting node when the
155/// sources name none, exactly as [`join_consumers`] attributes a row.
156pub fn declaring_sessions(target: &str, declared: &DeclaredEntities, kind: EntityKind) -> usize {
157    let Ok(target_ke) = keyexpr::new(target) else {
158        return 0;
159    };
160    let mut zids: Vec<String> = Vec::new();
161    for entity in declared.entities.iter().filter(|e| e.kind == kind) {
162        let Ok(declared_ke) = keyexpr::new(entity.keyexpr.as_str()) else {
163            continue;
164        };
165        if !declared_ke.intersects(target_ke) {
166            continue;
167        }
168        let sources = crate::bus::admin::source_zids(&entity.sources);
169        if sources.is_empty() {
170            zids.push(entity.node_zid.clone());
171        } else {
172            zids.extend(sources);
173        }
174    }
175    zids.sort();
176    zids.dedup();
177    zids.len()
178}
179
180/// One registry subject resolved to the wire (#224): the selector its
181/// family answers under `base`, its class, and its ledger entry.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct SubjectTarget {
184    /// The class chunk; `*` for a path known only from the ledger.
185    pub class: String,
186    /// The full wire selector, composed through `with_base` so the empty
187    /// base stays a valid keyexpr.
188    pub selector: String,
189    pub deprecated: Option<DeprecationFact>,
190}
191
192/// Resolve `<producer>/<path>` against the slices.
193///
194/// `None` when no slice names the producer, or when neither its subjects
195/// nor its `[[deprecated]]` ledger carry the path. A retired subject that
196/// survives only in the ledger still resolves — "who still reads it" is
197/// exactly the question the ledger exists for (RFC 08 §3) — with the class
198/// wildcarded, because a ledger entry declares none.
199pub fn subject_target(
200    slices: &crate::SliceSet,
201    base: &str,
202    producer: &str,
203    path: &str,
204) -> Option<SubjectTarget> {
205    let slice = slices.get(producer)?;
206    let deprecated = slice
207        .deprecated
208        .iter()
209        .find(|d| d.path == path && d.kind == zenkey::slice::DeprecatedKind::Subject)
210        .map(|d| DeprecationFact {
211            since: d.since.clone(),
212            replaced_by: d.replaced_by.clone(),
213        });
214    let subject = slice.subjects.iter().find(|s| s.path == path);
215    let class = match subject {
216        Some(s) => s.class.token().to_string(),
217        None => {
218            deprecated.as_ref()?;
219            "*".to_string()
220        }
221    };
222    let tail = zenkey::pattern::SubjectPattern::parse(path)
223        .ok()?
224        .selector_tail();
225    // A service's keys carry no producer chunk (RFC 06 §5); a host
226    // producer's origin is any host — `*` never matches a service origin
227    // (RFC 03 §4 D4), which is what makes this selector the family and not
228    // more.
229    let relative = match &slice.service_origin {
230        Some(origin) => format!("v1/{origin}/{class}/{tail}"),
231        None => format!("v1/*/{class}/{}/{tail}", slice.name),
232    };
233    Some(SubjectTarget {
234        class,
235        selector: zenkey::grammar::with_base(base, relative),
236        deprecated,
237    })
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::report::{DeclaredEntity, TopologyNode};
244    use serde_json::json;
245
246    fn entity(
247        kind: EntityKind,
248        keyexpr: &str,
249        node: &str,
250        sources: serde_json::Value,
251    ) -> DeclaredEntity {
252        DeclaredEntity {
253            kind,
254            keyexpr: keyexpr.into(),
255            node_zid: node.into(),
256            sources,
257        }
258    }
259
260    fn peers(zids: &[&str]) -> serde_json::Value {
261        json!({ "routers": [], "peers": zids, "clients": [] })
262    }
263
264    const TARGET: &str = "v1/h-cccccccccccc/state/demo/**";
265
266    /// The acceptance fixture: one narrow subscriber and one `**`
267    /// subscriber rank narrower-then-total, and the wildcard is flagged.
268    #[test]
269    fn a_narrow_and_a_total_subscriber_rank_by_relation() {
270        let declared = DeclaredEntities {
271            entities: vec![
272                entity(
273                    EntityKind::Subscriber,
274                    "**",
275                    "router1",
276                    peers(&["zid-wide"]),
277                ),
278                entity(
279                    EntityKind::Subscriber,
280                    "v1/h-cccccccccccc/state/demo/health",
281                    "router1",
282                    peers(&["zid-narrow"]),
283                ),
284                // A publisher is not a consumer.
285                entity(
286                    EntityKind::Publisher,
287                    "v1/h-cccccccccccc/state/demo/health",
288                    "router1",
289                    peers(&["zid-pub"]),
290                ),
291                // Unrelated: not a row.
292                entity(
293                    EntityKind::Subscriber,
294                    "v1/h-dddddddddddd/state/demo/health",
295                    "router1",
296                    peers(&["zid-other"]),
297                ),
298            ],
299        };
300        let attachments = vec![OriginAttachment {
301            origin: "h-cccccccccccc".into(),
302            session_zid: Some("zid-narrow".into()),
303            reporter_zid: "router1".into(),
304            token_key: "v1/h-cccccccccccc/state/demo/alive".into(),
305        }];
306        let rows = join_consumers(TARGET, &declared, &attachments, None, "me");
307        assert_eq!(rows.len(), 2, "{rows:?}");
308        assert_eq!(rows[0].relation, Relation::Narrower);
309        assert_eq!(rows[0].zid, "zid-narrow");
310        assert_eq!(rows[0].origins, ["h-cccccccccccc"]);
311        assert_eq!(rows[0].attribution, Attribution::Session);
312        assert!(!rows[0].total_wildcard);
313        assert_eq!(rows[1].relation, Relation::Total);
314        assert!(rows[1].total_wildcard);
315        assert!(rows[1].origins.is_empty(), "session only, unattributed");
316    }
317
318    /// Exact, wider and intersects each get their relation; a prefix that
319    /// includes the whole base is total even when it is not a bare `**`.
320    #[test]
321    fn every_relation_is_reachable() {
322        let declared = DeclaredEntities {
323            entities: vec![
324                entity(EntityKind::Subscriber, TARGET, "n", peers(&["exact"])),
325                entity(
326                    EntityKind::Querier,
327                    "v1/*/state/demo/**",
328                    "n",
329                    peers(&["wider"]),
330                ),
331                entity(
332                    EntityKind::Subscriber,
333                    "v1/h-cccccccccccc/*/demo/health",
334                    "n",
335                    peers(&["intersects"]),
336                ),
337                entity(EntityKind::Subscriber, "v1/**", "n", peers(&["total"])),
338            ],
339        };
340        let rows = join_consumers(TARGET, &declared, &[], None, "me");
341        let got: Vec<(&str, Relation)> =
342            rows.iter().map(|r| (r.zid.as_str(), r.relation)).collect();
343        assert_eq!(
344            got,
345            [
346                ("exact", Relation::Exact),
347                ("wider", Relation::Wider),
348                ("intersects", Relation::Intersects),
349                ("total", Relation::Total),
350            ]
351        );
352        assert_eq!(rows[1].kind, EntityKind::Querier);
353    }
354
355    /// Sources naming nobody: the reporter's zid, marked reported-only. Two
356    /// reporters of one declaration are one row. The tool's own session is
357    /// named. The topology supplies `whatami` only for zids it heard of.
358    #[test]
359    fn attribution_and_self_are_stated_not_guessed() {
360        let declared = DeclaredEntities {
361            entities: vec![
362                entity(EntityKind::Subscriber, TARGET, "reporter", json!({})),
363                entity(EntityKind::Subscriber, TARGET, "router1", peers(&["me"])),
364                entity(EntityKind::Subscriber, TARGET, "router2", peers(&["me"])),
365            ],
366        };
367        let topology = TopologyReport {
368            nodes: vec![TopologyNode {
369                zid: "me".into(),
370                whatami: "peer".into(),
371                version: None,
372                locators: vec![],
373                locators_via_links: vec![],
374                answered: true,
375            }],
376            edges: vec![],
377            asked: "@/*/*".into(),
378            answered: 1,
379            self_zid: "me".into(),
380        };
381        let rows = join_consumers(TARGET, &declared, &[], Some(&topology), "me");
382        assert_eq!(rows.len(), 2, "{rows:?}");
383        let me = rows.iter().find(|r| r.zid == "me").unwrap();
384        assert!(me.is_self);
385        assert_eq!(me.whatami.as_deref(), Some("peer"));
386        assert_eq!(me.attribution, Attribution::Session);
387        let reported = rows.iter().find(|r| r.zid == "reporter").unwrap();
388        assert_eq!(reported.attribution, Attribution::ReportedOnly);
389        assert!(reported.whatami.is_none(), "not heard of is not a kind");
390    }
391
392    /// `**` never crosses an `@`-chunk (RFC 03 §4 D2): a total subscriber
393    /// is not a row for a verbatim-plane target, and the base tree of a
394    /// service origin's key is still the `v1` tree.
395    #[test]
396    fn a_total_wildcard_does_not_reach_a_verbatim_plane() {
397        let declared = DeclaredEntities {
398            entities: vec![entity(EntityKind::Subscriber, "**", "n", peers(&["wide"]))],
399        };
400        let rows = join_consumers("v1/@catalog/state/entity/**", &declared, &[], None, "me");
401        assert!(rows.is_empty(), "{rows:?}");
402        assert_eq!(base_tree_of("acme/v1/@catalog/state/**"), "acme/v1/**");
403        assert_eq!(base_tree_of("@/*/*"), "**");
404    }
405
406    #[test]
407    fn declaring_sessions_count_distinct_zids() {
408        let declared = DeclaredEntities {
409            entities: vec![
410                entity(EntityKind::Publisher, TARGET, "n", peers(&["a", "b"])),
411                entity(EntityKind::Publisher, TARGET, "n", peers(&["a"])),
412                entity(EntityKind::Publisher, TARGET, "reporter", json!({})),
413                entity(EntityKind::Queryable, "v1/**", "n", peers(&["q"])),
414                entity(EntityKind::Queryable, "other/**", "n", peers(&["r"])),
415            ],
416        };
417        assert_eq!(
418            declaring_sessions(TARGET, &declared, EntityKind::Publisher),
419            3
420        );
421        assert_eq!(
422            declaring_sessions(TARGET, &declared, EntityKind::Queryable),
423            1
424        );
425    }
426
427    fn slices() -> crate::SliceSet {
428        let toml = r#"
429[registry]
430version = "1.0"
431app = "acme"
432convention = 1
433[producer]
434name = "sysinfo"
435
436[[subject]]
437path = "disk/{mount}/used"
438class = "telemetry"
439type = "TelemetryPoint"
440
441[[subject]]
442path = "health"
443class = "state"
444type = "HealthSnapshot"
445
446[[deprecated]]
447path = "ingest/legacy_total"
448since = "2.0"
449replaced_by = "disk/{mount}/bytes_used"
450"#;
451        let slice = zenkey::parse_slice(toml).expect("a slice");
452        crate::SliceSet::from_slices(vec![slice])
453    }
454
455    #[test]
456    fn a_subject_resolves_to_its_family_selector() {
457        let set = slices();
458        let t = subject_target(&set, "acme", "sysinfo", "disk/{mount}/used").unwrap();
459        assert_eq!(t.class, "telemetry");
460        assert_eq!(t.selector, "acme/v1/*/telemetry/sysinfo/disk/*/used");
461        assert!(t.deprecated.is_none());
462        let t = subject_target(&set, "", "sysinfo", "health").unwrap();
463        assert_eq!(t.selector, "v1/*/state/sysinfo/health");
464        // Ledger-only: class wildcarded, the fact carried.
465        let t = subject_target(&set, "", "sysinfo", "ingest/legacy_total").unwrap();
466        assert_eq!(t.class, "*");
467        assert_eq!(t.selector, "v1/*/*/sysinfo/ingest/legacy_total");
468        assert_eq!(
469            t.deprecated,
470            Some(DeprecationFact {
471                since: Some("2.0".into()),
472                replaced_by: Some("disk/{mount}/bytes_used".into()),
473            })
474        );
475        assert!(subject_target(&set, "", "sysinfo", "nope").is_none());
476        assert!(subject_target(&set, "", "logs", "health").is_none());
477    }
478}