LCOV - code coverage report
Current view: top level - antares-api/src/notify - matching.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 88.9 % 422 375
Test Date: 2026-11-02 13:21:27 Functions: 45.9 % 447 205

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Candidate selection and full evaluation of a change against subscriptions: attribute diff, linked-entity and view projection, the per-subscription @context.
       3              : 
       4              : use super::*;
       5              : 
       6              : /// Where the matcher reads candidates from: the indexed mirror (both bus
       7              : /// modes wire one), with the store scan only as the never-wired
       8              : /// fallback so a missing mirror degrades to correct-but-slow.
       9       140905 : pub(super) async fn subs_for(
      10       140905 :     st: &AppState,
      11       140905 :     tenant: &TenantId,
      12       140905 :     types: &[&str],
      13       140905 :     changed: &[&str],
      14       140905 :     queued: u64,
      15       140905 : ) -> Vec<Arc<Value>> {
      16       140905 :     match &st.sub_mirror {
      17       140903 :         Some(m) => m.candidates_before(tenant.as_str(), types, changed, queued),
      18              :         // The scan reads subscriptions as they are now, so a change
      19              :         // drained late may reach one made after it; only the never-wired path.
      20              :         // The scan is the fallback, so its own failure may not be read as
      21              :         // "this tenant has no subscriptions" either: that is the silence
      22              :         // the mirror seed refuses to install.
      23            2 :         None => match st.store.list(tenant, Kind::Subscription).await {
      24            2 :             Ok(subs) => subs.into_iter().map(Arc::new).collect(),
      25            0 :             Err(e) => {
      26            0 :                 tracing::error!(
      27              :                     "subscription scan failed for tenant {}: {e}; no candidate matched this change",
      28            0 :                     tenant.as_str()
      29              :                 );
      30            0 :                 Vec::new()
      31              :             }
      32              :         },
      33              :     }
      34       140905 : }
      35              : 
      36              : /// Whether two attribute instances are the same once the volatile members
      37              : /// — the timestamps the broker itself stamps, and the instance identity —
      38              : /// are set aside. Compared in place: this runs per changed attribute per
      39              : /// matching subscription per event, and materializing two stripped copies of
      40              : /// every JSON tree to throw them away one comparison later is the cost that
      41              : /// multiplies by.
      42       441006 : pub(super) fn stable_eq(a: &Value, b: &Value) -> bool {
      43      2094708 :     fn volatile(k: &str) -> bool {
      44      2094708 :         matches!(k, "createdAt" | "modifiedAt" | "instanceId")
      45      2094708 :     }
      46       441006 :     match (a, b) {
      47       161636 :         (Value::Object(oa), Value::Object(ob)) => {
      48      1602116 :             let count = |o: &Map<String, Value>| o.keys().filter(|k| !volatile(k)).count();
      49              :             // by lookup rather than by zipped iteration: which order a
      50              :             // serde_json Map yields is a cargo feature away from changing,
      51              :             // and features are additive
      52       161636 :             count(oa) == count(ob)
      53       161440 :                 && oa
      54       161440 :                     .iter()
      55       492592 :                     .filter(|(k, _)| !volatile(k))
      56       172268 :                     .all(|(k, va)| ob.get(k).is_some_and(|vb| stable_eq(va, vb)))
      57              :         }
      58       108470 :         (Value::Array(aa), Value::Array(ab)) => {
      59       109706 :             aa.len() == ab.len() && aa.iter().zip(ab).all(|(x, y)| stable_eq(x, y))
      60              :         }
      61       170900 :         _ => a == b,
      62              :     }
      63       441006 : }
      64              : 
      65       556858 : pub(super) fn attr_keys(doc: &Value) -> Vec<String> {
      66       556858 :     doc.as_object()
      67       556858 :         .map(|o| {
      68       556858 :             o.keys()
      69      1257535 :                 .filter(|k| !crate::repr::ENTITY_META.contains(&k.as_str()))
      70       556858 :                 .cloned()
      71       556858 :                 .collect()
      72       556858 :         })
      73       556858 :         .unwrap_or_default()
      74       556858 : }
      75              : 
      76              : /// Per-attribute change classification between two internal docs.
      77       278423 : pub(super) fn diff(before: Option<&Value>, after: Option<&Value>) -> Vec<(String, ChangeClass)> {
      78       278423 :     let empty = Value::Object(Map::new());
      79       278423 :     let b = before.unwrap_or(&empty);
      80       278423 :     let a = after.unwrap_or(&empty);
      81       278423 :     let mut keys = attr_keys(b);
      82       364846 :     for k in attr_keys(a) {
      83       364846 :         if !keys.contains(&k) {
      84       257456 :             keys.push(k);
      85       257456 :         }
      86              :     }
      87       278423 :     let mut out = Vec::new();
      88       372664 :     for k in keys {
      89       372664 :         let bv = b.get(&k);
      90       372664 :         let av = a.get(&k);
      91       372664 :         match (bv, av) {
      92       257456 :             (None, Some(_)) => out.push((k, ChangeClass::Created)),
      93         7818 :             (Some(_), None) => out.push((k, ChangeClass::Deleted)),
      94       107390 :             (Some(x), Some(y)) => {
      95              :                 // instance-level deletion (by datasetId) counts as
      96              :                 // attributeDeleted even when other instances survive (5.8.6)
      97       107390 :                 let bx: Vec<&Value> = x.as_array().map(|a| a.iter().collect()).unwrap_or_default();
      98       107390 :                 let by: Vec<&Value> = y.as_array().map(|a| a.iter().collect()).unwrap_or_default();
      99       107390 :                 let removed = bx
     100       107390 :                     .iter()
     101       109174 :                     .any(|bi| !by.iter().any(|ai| instance_ds(ai) == instance_ds(bi)));
     102       107390 :                 if removed {
     103          252 :                     out.push((k.clone(), ChangeClass::Deleted));
     104          284 :                     let survivors_changed = by.iter().any(|ai| {
     105          464 :                         match bx.iter().find(|bi| instance_ds(bi) == instance_ds(ai)) {
     106          284 :                             Some(bi) => !stable_eq(bi, ai),
     107            0 :                             None => true,
     108              :                         }
     109          284 :                     });
     110          252 :                     if survivors_changed {
     111            4 :                         out.push((k, ChangeClass::Updated));
     112          248 :                     }
     113       107138 :                 } else if !stable_eq(x, y) {
     114       103066 :                     out.push((k, ChangeClass::Updated));
     115       104510 :                 }
     116              :             }
     117            0 :             _ => {}
     118              :         }
     119              :     }
     120       278423 :     out
     121       278423 : }
     122              : 
     123              : /// One string-valued member of a stored Subscription, absent or of another
     124              : /// JSON type reading the same as absent.
     125        11111 : pub(super) fn sub_str<'a>(sub: &'a Value, key: &str) -> Option<&'a str> {
     126        11111 :     sub.get(key).and_then(Value::as_str)
     127        11111 : }
     128              : 
     129              : /// 4.9 EXAMPLE 13/14: linked-entity q terms (`attr{path}`) resolve through
     130              : /// the local store, same tenant. The evaluator is synchronous and the store
     131              : /// is not, so `eval` runs against a cache: a URI the cache misses is
     132              : /// recorded, reads as absent for that pass, and is fetched before the next
     133              : /// one. The first pass that misses nothing is the answer. Every miss of a
     134              : /// pass is fetched together, so the pass count is the depth of the link
     135              : /// chain rather than the number of entities it touches.
     136              : ///
     137              : /// `narrow` is the caller's narrowing (ADR-0020) with the @context its
     138              : /// policy q is read against: a hop into an Entity it does not admit reads
     139              : /// as a hop into a missing one, and a resolved target loses the Attributes
     140              : /// its projection hides before the evaluator sees it.
     141         9674 : pub(crate) async fn linked_eval<F>(
     142         9674 :     st: &AppState,
     143         9674 :     tenant: &TenantId,
     144         9674 :     narrow: Option<(&crate::repr::Repr, &Context)>,
     145         9674 :     mut eval: F,
     146         9674 : ) -> bool
     147         9674 : where
     148         9674 :     F: for<'a> FnMut(antares_ql::eval::EntityLookup<'a>) -> bool,
     149         9674 : {
     150         9674 :     let mut cache: HashMap<String, Option<Value>> = HashMap::new();
     151              :     // The evaluator's own lookup budget bounds how many distinct
     152              :     // URIs one expression can name, so it bounds the passes too; a chain
     153              :     // deeper than that reads as unresolved, which is what exhausting the
     154              :     // budget inside the evaluator already does.
     155         9674 :     for _ in 0..antares_ql::eval::MAX_Q_LINK_LOOKUPS {
     156         9726 :         let missed = std::cell::RefCell::new(Vec::new());
     157         9726 :         let out = eval(&|uri: &str| match cache.get(uri) {
     158           52 :             Some(doc) => doc.clone(),
     159              :             None => {
     160           52 :                 missed.borrow_mut().push(uri.to_owned());
     161           52 :                 None
     162              :             }
     163          104 :         });
     164         9726 :         let missed = missed.into_inner();
     165         9726 :         if missed.is_empty() {
     166         9674 :             return out;
     167           52 :         }
     168           52 :         for uri in missed {
     169           52 :             let mut doc = st
     170           52 :                 .store
     171           52 :                 .get(tenant, Kind::Entity, &uri)
     172           52 :                 .await
     173           52 :                 .ok()
     174           52 :                 .flatten();
     175           52 :             if let (Some(d), Some((r, ctx))) = (&doc, narrow) {
     176              :                 // boxed: the admission check evaluates the policy's own q,
     177              :                 // which comes back here
     178           28 :                 let admitted = Box::pin(crate::repr::policy_admits(
     179           28 :                     st,
     180           28 :                     tenant,
     181           28 :                     ctx,
     182           28 :                     r.policy_reach.as_deref(),
     183           28 :                     d,
     184           28 :                 ))
     185           28 :                 .await;
     186           28 :                 doc = admitted.then(|| crate::repr::policy_view(d, r));
     187           24 :             }
     188           52 :             cache.insert(uri, doc);
     189              :         }
     190              :     }
     191            0 :     eval(&|uri: &str| cache.get(uri).cloned().flatten())
     192         9674 : }
     193              : 
     194              : /// What each Entity of a notification's `data` (and each one it embeds)
     195              : /// shows under the expanded `rules` (every Attribute without rules), read
     196              : /// on its stored state now, names compacted the way `data` is. An Entity the store no longer holds, that
     197              : /// `reach` does not admit, or that no rule holds for, maps to `None`.
     198            4 : pub(super) async fn shown_now(
     199            4 :     st: &AppState,
     200            4 :     tenant: &TenantId,
     201            4 :     rules: Option<&crate::policy::Filter>,
     202            4 :     reach: Option<&crate::repr::PolicyReach>,
     203            4 :     data: &Value,
     204            4 :     ctx: &Context,
     205            4 : ) -> HashMap<String, Option<crate::policy::Shown<'static>>> {
     206           78 :     fn ids<'a>(v: &'a Value, out: &mut std::collections::BTreeSet<&'a str>) {
     207           78 :         match v {
     208           20 :             Value::Object(o) => {
     209           20 :                 if let (Some(id), Some(_)) = (o.get("id").and_then(Value::as_str), o.get("type")) {
     210            4 :                     out.insert(id);
     211           16 :                 }
     212           62 :                 o.values().for_each(|v| ids(v, out));
     213              :             }
     214           12 :             Value::Array(a) => a.iter().for_each(|v| ids(v, out)),
     215           50 :             _ => {}
     216              :         }
     217           78 :     }
     218            4 :     let mut wanted = std::collections::BTreeSet::new();
     219            4 :     ids(data, &mut wanted);
     220              :     // one read for every Entity of the notification, shared with every
     221              :     // other delivery of the tenant waiting for a read: one by one, a
     222              :     // folded notification of 256 Entities was 256 round trips under a
     223              :     // delivery slot, and a fan-out to 1 000 subscriptions of one
     224              :     // view read the same Entities 1 000 times. One failed read hides every
     225              :     // Entity of the notification; the failure is logged, not silent.
     226            4 :     let ids: Vec<String> = wanted.iter().map(|id| (*id).to_owned()).collect();
     227            4 :     let stored_now = super::coalesce::read_entities(st, tenant, ids).await;
     228            4 :     let mut out = HashMap::new();
     229            4 :     for id in wanted {
     230            4 :         let mut stored = stored_now.as_ref().and_then(|m| m.get(id));
     231            4 :         if let (Some(d), Some(_)) = (stored, reach) {
     232            0 :             let ctx = crate::repr::policy_context();
     233            0 :             if !crate::repr::policy_admits(st, tenant, ctx, reach, d).await {
     234            0 :                 stored = None;
     235            0 :             }
     236            4 :         }
     237            4 :         let shown = stored
     238            4 :             .and_then(|d| crate::repr::shown(rules, d))
     239            4 :             .map(|s| s.map_names(|n| ctx.compact_iri(n)));
     240            4 :         out.insert(id.to_owned(), shown);
     241              :     }
     242            4 :     out
     243            4 : }
     244              : 
     245              : /// The entry of `shown` for the Entity `e`; `None` when `e` is no Entity.
     246            8 : pub(super) fn entity_shown<'a>(
     247            8 :     shown: &'a HashMap<String, Option<crate::policy::Shown<'static>>>,
     248            8 :     e: &Value,
     249            8 : ) -> Option<Option<&'a crate::policy::Shown<'static>>> {
     250            8 :     let id = e.get("id").and_then(Value::as_str)?;
     251            8 :     Some(shown.get(id).and_then(Option::as_ref))
     252            8 : }
     253              : 
     254              : /// Strip a compacted notified Entity to what [`shown_now`] says it shows,
     255              : /// and every Entity it embeds the same way: an embedded one no rule holds
     256              : /// for is not embedded, its Relationship stays.
     257            4 : pub(super) fn project_shown(
     258            4 :     shown: &HashMap<String, Option<crate::policy::Shown<'static>>>,
     259            4 :     e: &mut Value,
     260            4 :     key_values: bool,
     261            4 : ) {
     262            4 :     let allowed = entity_shown(shown, e).flatten().cloned();
     263            4 :     let Some(obj) = e.as_object_mut() else {
     264            0 :         return;
     265              :     };
     266            4 :     let allowed = allowed.unwrap_or_else(crate::policy::Shown::nothing);
     267           20 :     obj.retain(|k, _| k == "@context" || antares_model::is_meta(k) || allowed.allows(k));
     268           12 :     for v in obj.values_mut() {
     269           12 :         let instances: Vec<&mut Value> = match v {
     270            0 :             Value::Array(a) => a.iter_mut().collect(),
     271           12 :             other => vec![other],
     272              :         };
     273           12 :         for inst in instances {
     274           12 :             if key_values {
     275            0 :                 if inst.get("id").is_some() && inst.get("type").is_some() {
     276            0 :                     match entity_shown(shown, inst) {
     277            0 :                         Some(Some(_)) => project_shown(shown, inst, key_values),
     278            0 :                         _ => {
     279            0 :                             let id = inst.get("id").cloned().unwrap_or(Value::Null);
     280            0 :                             *inst = id;
     281            0 :                         }
     282              :                     }
     283            0 :                 }
     284            0 :                 continue;
     285           12 :             }
     286           12 :             let Some(o) = inst.as_object_mut() else {
     287            8 :                 continue;
     288              :             };
     289            4 :             if let Some(j) = o.get("entity") {
     290            0 :                 if matches!(entity_shown(shown, j), Some(Some(_))) {
     291            0 :                     if let Some(j) = o.get_mut("entity") {
     292            0 :                         project_shown(shown, j, key_values);
     293            0 :                     }
     294            0 :                 } else {
     295            0 :                     o.remove("entity");
     296            0 :                 }
     297            4 :             }
     298            4 :             if let Some(Value::Array(a)) = o.get_mut("entityList") {
     299            0 :                 a.retain(|j| matches!(entity_shown(shown, j), Some(Some(_))));
     300            0 :                 for j in a.iter_mut() {
     301            0 :                     project_shown(shown, j, key_values);
     302            0 :                 }
     303            4 :             }
     304              :         }
     305              :     }
     306            4 : }
     307              : 
     308              : /// ADR-0020: a narrowing reaches every document the answer carries, so the
     309              : /// projection runs on each Entity `join=inline` embedded (4.5.23.2): under
     310              : /// `entity`/`entityList` normalized, in place of the object URI simplified.
     311           18 : pub(super) fn project_joined(f: &crate::policy::Filter, e: &mut Value, key_values: bool) {
     312           18 :     f.project(e);
     313           18 :     let Some(obj) = e.as_object_mut() else {
     314            0 :         return;
     315              :     };
     316           62 :     for v in obj.values_mut() {
     317           62 :         let instances: Vec<&mut Value> = match v {
     318            0 :             Value::Array(a) => a.iter_mut().collect(),
     319           62 :             other => vec![other],
     320              :         };
     321           62 :         for inst in instances {
     322           62 :             if key_values {
     323            0 :                 if inst.get("id").is_some() && inst.get("type").is_some() {
     324            0 :                     project_joined(f, inst, key_values);
     325            0 :                 }
     326            0 :                 continue;
     327           62 :             }
     328           62 :             let Some(o) = inst.as_object_mut() else {
     329           36 :                 continue;
     330              :             };
     331           52 :             for m in ["entity", "entityList"] {
     332           52 :                 match o.get_mut(m) {
     333            0 :                     Some(Value::Array(a)) => {
     334            0 :                         for j in a {
     335            0 :                             project_joined(f, j, key_values);
     336            0 :                         }
     337              :                     }
     338            4 :                     Some(j @ Value::Object(_)) => project_joined(f, j, key_values),
     339           48 :                     _ => {}
     340              :                 }
     341              :             }
     342              :         }
     343              :     }
     344           18 : }
     345              : 
     346              : /// Whether the Subscription's q follows a Relationship (4.9
     347              : /// LinkedEntityRelation).
     348           16 : pub(super) fn linked_condition(sub: &Value) -> bool {
     349           16 :     sub_str(sub, "q")
     350           16 :         .is_some_and(|q| antares_ql::regex::q_node(q).is_some_and(|n| n.has_linked_paths()))
     351           16 : }
     352              : 
     353              : /// The @context governing a subscription's notifications (5.8.6): the
     354              : /// jsonldContext member if set, else the @context of the creating request.
     355         6800 : pub(crate) async fn sub_context(st: &AppState, tenant: &TenantId, sub: &Value) -> Arc<Context> {
     356              :     // Borrowed: the resolver only reads it, and this is on the per-candidate
     357              :     // path, where cloning an inline @context copied the whole document.
     358         6800 :     let source = sub.get("jsonldContext").or_else(|| sub.get("__context"));
     359         6340 :     match source {
     360              :         // 5.5.10: the Subscription belongs to one Tenant, so the @context it
     361              :         // names resolves within that Tenant — a Hosted @context another Tenant
     362              :         // stored (5.13.1) is not in scope here and falls back to the core
     363              :         // context rather than compacting this Notification against it.
     364         6340 :         Some(v) if !v.is_null() => st
     365         6340 :             .loader
     366         6340 :             .resolve_quiet_for(tenant, v)
     367         6340 :             .await
     368         6340 :             .unwrap_or_else(|_| st.loader.core()),
     369          460 :         _ => st.loader.core(),
     370              :     }
     371         6800 : }
     372              : 
     373              : /// A resolved `@context` per Tenant, then per the `@context` URL the
     374              : /// Subscription names. Keyed by Tenant first: 5.5.10 scopes a resolution to
     375              : /// one Tenant, so one Tenant's resolved context is never reachable under
     376              : /// another's identifier even where both name the same URL.
     377              : pub(super) type CtxMemo = HashMap<String, HashMap<String, Arc<Context>>>;
     378              : 
     379              : /// `sub_context` over one drain's memo. Resolving is a cache lookup per call,
     380              : /// and this runs once per candidate subscription per change: a drain of
     381              : /// `CHANGE_BATCH` changes resolves the same handful of `@context` URLs
     382              : /// thousands of times over. The memo lives for the one drain that owns it, so
     383              : /// an `@context` a request replaces or deletes meanwhile is picked up by the
     384              : /// next drain. A Subscription naming an inline `@context` rather than a URL
     385              : /// resolves as before — it has no cheap identity to key on.
     386         9078 : pub(super) async fn sub_context_memo(
     387         9078 :     st: &AppState,
     388         9078 :     tenant: &TenantId,
     389         9078 :     tenant_str: &str,
     390         9078 :     sub: &Value,
     391         9078 :     memo: &mut CtxMemo,
     392         9078 : ) -> Arc<Context> {
     393         9078 :     let url = sub
     394         9078 :         .get("jsonldContext")
     395         9078 :         .or_else(|| sub.get("__context"))
     396         9078 :         .and_then(Value::as_str);
     397         9078 :     let Some(url) = url else {
     398          264 :         return sub_context(st, tenant, sub).await;
     399              :     };
     400         8814 :     if let Some(hit) = memo.get(tenant_str).and_then(|by_url| by_url.get(url)) {
     401         4126 :         return Arc::clone(hit);
     402         4688 :     }
     403         4688 :     let ctx = sub_context(st, tenant, sub).await;
     404         4688 :     memo.entry(tenant_str.to_owned())
     405         4688 :         .or_default()
     406         4688 :         .insert(url.to_owned(), Arc::clone(&ctx));
     407         4688 :     ctx
     408         9078 : }
     409              : 
     410              : /// The notification triggers of one subscription, in the form the matcher
     411              : /// compares against (Table 5.2.12-1): absent means the default combination
     412              : /// `"attributeCreated"` + `"attributeUpdated"`, and `"entityUpdated"` "is
     413              : /// equivalent to the combination `"attributeCreated"`, `"attributeUpdated"`
     414              : /// and `"attributeDeleted"`" — so it is expanded here, at the single point
     415              : /// the list is read, rather than at each comparison.
     416         9104 : pub(super) fn triggers_of(sub: &Value) -> Vec<&str> {
     417              :     // Borrowed from the Subscription, not copied out of it: this runs once
     418              :     // per candidate per change, and the default arm used to allocate a
     419              :     // String for each of DEFAULT_TRIGGERS, which are already 'static.
     420         9104 :     let mut triggers: Vec<&str> = sub
     421         9104 :         .get("notificationTrigger")
     422         9104 :         .and_then(Value::as_array)
     423         9104 :         .map(|a| a.iter().filter_map(Value::as_str).collect())
     424         9104 :         .unwrap_or_else(|| DEFAULT_TRIGGERS.to_vec());
     425         9104 :     if triggers.contains(&"entityUpdated") {
     426           36 :         for t in DEFAULT_TRIGGERS.iter().copied().chain(["attributeDeleted"]) {
     427           36 :             if !triggers.contains(&t) {
     428           24 :                 triggers.push(t);
     429           24 :             }
     430              :         }
     431         9092 :     }
     432         9104 :     triggers
     433         9104 : }
     434              : 
     435       140905 : pub(super) async fn matches_for(
     436       140905 :     st: &AppState,
     437       140905 :     tenant_str: &str,
     438       140905 :     before: Option<Value>,
     439       140905 :     after: Option<Value>,
     440       140905 :     queued: u64,
     441       140905 :     ctx_memo: &mut CtxMemo,
     442       140905 : ) -> Vec<Matched> {
     443       140905 :     let mut out = Vec::new();
     444              :     // The tenant of the write that fired the change hook — this broker's own
     445              :     // value, and a write inside a Snapshot (5.5.15) carries the synthetic one.
     446       140905 :     let Ok(tenant) = TenantId::new_internal(tenant_str) else {
     447            0 :         return out;
     448              :     };
     449       140905 :     let changes = diff(before.as_ref(), after.as_ref());
     450       140905 :     let entity_trigger = match (&before, &after) {
     451       123244 :         (None, Some(_)) => "entityCreated",
     452         3371 :         (Some(_), None) => "entityDeleted",
     453        14290 :         _ => "entityUpdated",
     454              :     };
     455       140905 :     let eval_doc = after.as_ref().or(before.as_ref());
     456       140905 :     let Some(eval_doc) = eval_doc else { return out };
     457              :     // Candidate lookup by the entity's types and the changed attribute
     458              :     // IRIs — no linear scan over all subscriptions.
     459       140905 :     let types: Vec<&str> = eval_doc
     460       140905 :         .get("type")
     461       140905 :         .and_then(Value::as_array)
     462       140905 :         .map(|a| a.iter().filter_map(Value::as_str).collect())
     463       140905 :         .unwrap_or_default();
     464       187932 :     let changed_keys: Vec<&str> = changes.iter().map(|(k, _)| k.as_str()).collect();
     465       140905 :     let subs = subs_for(st, &tenant, &types, &changed_keys, queued).await;
     466              :     // one instant for the change, so equal shapes render one payload
     467       140905 :     let now = now_iso();
     468       140905 :     let mut built: HashMap<String, Vec<Value>> = HashMap::new();
     469       140905 :     for sub in subs {
     470         9108 :         if !is_active(&sub) || sub.get("timeInterval").is_some() {
     471           24 :             continue;
     472         9084 :         }
     473         9084 :         let narrow = None;
     474         9084 :         let triggers = triggers_of(&sub);
     475              :         // which attribute-level changes this sub cares about
     476         9084 :         let watched: Option<Vec<&str>> = sub
     477         9084 :             .get("watchedAttributes")
     478         9084 :             .and_then(Value::as_array)
     479         9084 :             .map(|a| a.iter().filter_map(Value::as_str).collect());
     480              :         #[expect(unused_mut)]
     481         9084 :         let mut relevant: Vec<&(String, ChangeClass)> = changes
     482         9084 :             .iter()
     483         9224 :             .filter(|(k, _)| watched.as_ref().is_none_or(|w| w.contains(&k.as_str())))
     484         9084 :             .collect();
     485         9146 :         let attr_trigger_fired = relevant.iter().any(|(_, c)| {
     486         9146 :             let t = match c {
     487         8856 :                 ChangeClass::Created => "attributeCreated",
     488          142 :                 ChangeClass::Updated => "attributeUpdated",
     489          148 :                 ChangeClass::Deleted => "attributeDeleted",
     490              :             };
     491         9146 :             triggers.contains(&t)
     492         9146 :         });
     493         9084 :         let entity_trigger_fired =
     494         9084 :             triggers.contains(&entity_trigger) && (watched.is_none() || !relevant.is_empty());
     495         9084 :         if !attr_trigger_fired && !entity_trigger_fired {
     496           22 :             continue;
     497         9062 :         }
     498         9062 :         let ctx = sub_context_memo(st, &tenant, tenant_str, &sub, ctx_memo).await;
     499         9062 :         if !selector_match(&sub, eval_doc, &ctx) {
     500            4 :             continue;
     501         9058 :         }
     502         9074 :         if !linked_eval(st, &tenant, narrow, |l| {
     503         9074 :             conditions_match(&sub, eval_doc, &ctx, l)
     504         9074 :         })
     505         9058 :         .await
     506              :         {
     507           14 :             continue;
     508         9044 :         }
     509         9044 :         if throttled(&sub) {
     510            6 :             continue;
     511         9038 :         }
     512         9038 :         let deleted: Vec<String> = if triggers.contains(&"attributeDeleted") {
     513          100 :             relevant
     514          100 :                 .iter()
     515          100 :                 .filter(|(_, c)| *c == ChangeClass::Deleted)
     516          100 :                 .map(|(k, _)| k.clone())
     517          100 :                 .collect()
     518              :         } else {
     519         8938 :             Vec::new()
     520              :         };
     521         9038 :         let entity_deleted_fired = after.is_none() && triggers.contains(&"entityDeleted");
     522         9038 :         let key = payload_key(&sub, &ctx, &deleted, entity_deleted_fired);
     523         9038 :         let data = match built.get(&key) {
     524         4338 :             Some(data) => data.clone(),
     525              :             None => {
     526         4700 :                 let data = build_data(
     527         4700 :                     st,
     528         4700 :                     &tenant,
     529         4700 :                     &sub,
     530         4700 :                     &ctx,
     531         4700 :                     before.as_ref(),
     532         4700 :                     after.as_ref(),
     533         4700 :                     &deleted,
     534         4700 :                     entity_deleted_fired,
     535         4700 :                     &now,
     536         4700 :                 )
     537         4700 :                 .await;
     538         4700 :                 built.insert(key, data.clone());
     539         4700 :                 data
     540              :             }
     541              :         };
     542         9038 :         out.push(Matched {
     543         9038 :             tenant: tenant.clone(),
     544         9038 :             sub,
     545         9038 :             ctx,
     546         9038 :             data,
     547         9038 :         });
     548              :     }
     549       140905 :     out
     550       140905 : }
     551              : 
     552              : /// The exact Entity ids a subscription's `entities` selector pins down, or
     553              : /// `None` when it leaves any of them open. Table 5.2.33-1: `id` is a String
     554              : /// or a String[] and "id takes precedence over idPattern", so an entry
     555              : /// carrying an id constrains the read exactly — while one entry without an id
     556              : /// (a bare type, or an idPattern no store column can answer) admits every id
     557              : /// and forfeits the narrowing for the whole OR-ed selector.
     558          204 : pub(super) fn selector_ids(sub: &Value) -> Option<Vec<String>> {
     559          204 :     let sel = sub.get("entities").and_then(Value::as_array)?;
     560          192 :     let mut ids = Vec::new();
     561          200 :     for e in sel {
     562          200 :         match e.get("id") {
     563           40 :             Some(Value::String(i)) => ids.push(i.clone()),
     564            8 :             Some(Value::Array(a)) => {
     565           16 :                 for v in a {
     566           16 :                     ids.push(v.as_str()?.to_owned());
     567              :                 }
     568              :             }
     569          152 :             _ => return None,
     570              :         }
     571              :     }
     572           40 :     (!ids.is_empty()).then_some(ids)
     573          204 : }
     574              : 
     575              : /// A store read on a delivery path has no caller to fail: the sweep is a
     576              : /// timer, the fan-out is spawned, and both answer `()`. Silence is what makes
     577              : /// a failure dangerous here — a subscription that stops firing because the
     578              : /// store could not be read looks exactly like one with nothing to send — so
     579              : /// the failure is named and the path continues on the empty set it would
     580              : /// have continued on anyway.
     581              : /// Every document of `kind` in `tenant` that `keep` accepts, read a page
     582              : /// at a time: the whole-tenant `list` carries the client-query
     583              : /// row ceiling (5.5.6), and past it a notification path would read nothing
     584              : /// and send an empty or no notification. Only the kept documents are held.
     585        21941 : pub(crate) async fn docs_where(
     586        21941 :     st: &AppState,
     587        21941 :     tenant: &TenantId,
     588        21941 :     kind: Kind,
     589        21941 :     keep: impl Fn(&Value) -> bool,
     590        21941 : ) -> Result<Vec<Value>, antares_model::NgsiError> {
     591        21941 :     let mut out = Vec::new();
     592        24502 :     crate::csource::walk_docs(st, tenant, kind, |doc| {
     593        20785 :         if keep(&doc) {
     594        10661 :             out.push(doc);
     595        10661 :         }
     596        20785 :         Ok(())
     597        20785 :     })
     598        21941 :     .await?;
     599        21941 :     Ok(out)
     600        21941 : }
     601              : 
     602        22524 : pub(super) fn read_or_warn<T>(res: Result<Vec<T>, antares_model::NgsiError>, what: &str) -> Vec<T> {
     603        22524 :     res.unwrap_or_else(|e| {
     604            0 :         tracing::warn!("notification path: reading {what} failed: {e}");
     605            0 :         Vec::new()
     606            0 :     })
     607        22524 : }
        

Generated by: LCOV version 2.0-1