LCOV - code coverage report
Current view: top level - antares-api/src/notify - csource.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.3 % 190 183
Test Date: 2026-11-02 13:21:27 Functions: 67.3 % 104 70

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Context source registration notifications (5.11, csourceNotification): triggers, the fan-out and the initial notification.
       3              : 
       4              : use super::*;
       5              : 
       6              : /// 5.11.7: which csource subs care about a registration change, and why.
       7          178 : pub(super) fn csource_trigger(
       8          178 :     sub: &Value,
       9          178 :     before: Option<&Value>,
      10          178 :     after: Option<&Value>,
      11          178 :     ctx: &Context,
      12          178 : ) -> Option<&'static str> {
      13          356 :     let m = |d: Option<&Value>| {
      14          356 :         d.is_some_and(|d| crate::registry::csr_matches_subscription(sub, d, ctx))
      15          356 :     };
      16          178 :     match (m(before), m(after)) {
      17           62 :         (false, true) => Some("newlyMatching"),
      18           12 :         (true, true) => Some("updated"),
      19           60 :         (true, false) => Some("noLongerMatching"),
      20           44 :         (false, false) => None,
      21              :     }
      22          178 : }
      23              : 
      24              : /// The notification validator reads EntityInfo.type as an array
      25              : /// (`entities[0]["type"][0]`) — normalize to array form in notification data.
      26          315 : pub(super) fn arrayify_entity_types(reg: &mut Value) {
      27          315 :     let Some(infos) = reg.get_mut("information").and_then(Value::as_array_mut) else {
      28            0 :         return;
      29              :     };
      30          315 :     for info in infos {
      31          315 :         let Some(es) = info.get_mut("entities").and_then(Value::as_array_mut) else {
      32            0 :             continue;
      33              :         };
      34          315 :         for e in es {
      35          315 :             if let Some(t) = e.get("type").filter(|t| t.is_string()).cloned() {
      36          315 :                 if let Some(o) = e.as_object_mut() {
      37          315 :                     o.insert("type".into(), Value::Array(vec![t]));
      38          315 :                 }
      39            0 :             }
      40              :         }
      41              :     }
      42          315 : }
      43              : 
      44              : /// One prepared CSource notification, ready to send.
      45              : pub struct CsourceJob {
      46              :     sub: Value,
      47              :     presented: Value,
      48              :     ctx: std::sync::Arc<antares_jsonld::Context>,
      49              :     reason: &'static str,
      50              : }
      51              : 
      52              : /// Registration create/update/delete → CSourceNotification fan-out (5.11.7),
      53              : /// in two phases: `prepare_csource_jobs` runs IN the request path (store
      54              : /// reads + matching + payload build — so job order is the handlers' commit
      55              : /// order even on a slower store), and the caller spawns `send_csource_jobs`
      56              : /// (network only — the ack must not block on the receiver: the ETSI mock
      57              : /// replies only when the robot side wakes).
      58         4256 : pub(crate) async fn prepare_csource_jobs(
      59         4256 :     st: &AppState,
      60         4256 :     tenant: &TenantId,
      61         4256 :     before: Option<Value>,
      62         4256 :     after: Option<Value>,
      63         4256 : ) -> Vec<CsourceJob> {
      64         4256 :     let mut jobs = Vec::new();
      65              :     // 5.8.1.4: the Registration Subscriptions the distributed half owns are
      66              :     // not client resources, so they live under Kind::DistSub beside the
      67              :     // mapping documents — which carry no `type`. A document under the
      68              :     // client kind in the internal id namespace is a leftover of a release
      69              :     // that stored the internal ones there, and drives nothing.
      70         4256 :     let client = read_or_warn(
      71         4256 :         docs_where(st, tenant, Kind::CSourceSubscription, |d| {
      72          152 :             sub_str(d, "id")
      73          152 :                 .is_some_and(|id| crate::registry::csr_kind(id) == Kind::CSourceSubscription)
      74          152 :         })
      75         4256 :         .await,
      76         4256 :         "the Context Source Registration Subscriptions of a changed registration",
      77              :     )
      78         4256 :     .into_iter();
      79         4256 :     let internal = read_or_warn(
      80         4256 :         docs_where(st, tenant, Kind::DistSub, |d| {
      81           28 :             d.get("type").and_then(Value::as_str) == Some("Subscription")
      82           28 :         })
      83         4256 :         .await,
      84         4256 :         "the internal Registration Subscriptions of a changed registration",
      85              :     )
      86         4256 :     .into_iter();
      87         4256 :     for sub in client.chain(internal) {
      88          166 :         if !is_active(&sub) || sub.get("timeInterval").is_some() {
      89           12 :             continue;
      90          154 :         }
      91          154 :         let ctx = sub_context(st, tenant, &sub).await;
      92          154 :         let Some(reason) = csource_trigger(&sub, before.as_ref(), after.as_ref(), &ctx) else {
      93           40 :             continue;
      94              :         };
      95          114 :         let spec = crate::registry::spec_for_subscription(&sub);
      96          114 :         let source = if reason == "noLongerMatching" {
      97           52 :             &before
      98              :         } else {
      99           62 :             &after
     100              :         };
     101          114 :         let Some(reg) = source.as_ref().or(before.as_ref()) else {
     102            0 :             continue;
     103              :         };
     104          114 :         let filtered = filter_csr(&spec, reg, &ctx);
     105          114 :         let mut presented = crate::registry::present_registration(&filtered, &ctx, false);
     106          114 :         arrayify_entity_types(&mut presented);
     107          114 :         jobs.push(CsourceJob {
     108          114 :             sub,
     109          114 :             presented,
     110          114 :             ctx,
     111          114 :             reason,
     112          114 :         });
     113              :     }
     114         4256 :     jobs
     115         4256 : }
     116              : 
     117         4119 : pub(crate) async fn send_csource_jobs(st: &AppState, tenant: &TenantId, jobs: Vec<CsourceJob>) {
     118         4119 :     for job in jobs {
     119              :         // 5.11.7: re-check the subscription still exists right
     120              :         // before the send — a deleted subscription must never notify.
     121          110 :         let sub_id = job
     122          110 :             .sub
     123          110 :             .get("id")
     124          110 :             .and_then(Value::as_str)
     125          110 :             .unwrap_or_default();
     126          110 :         let kind = crate::registry::csr_kind(sub_id);
     127          110 :         if !matches!(st.store.get(tenant, kind, sub_id).await, Ok(Some(_))) {
     128            0 :             continue;
     129          110 :         }
     130          110 :         deliver_as(
     131          110 :             st,
     132          110 :             tenant,
     133          110 :             kind,
     134          110 :             &job.sub,
     135          110 :             "ContextSourceNotification",
     136          110 :             vec![job.presented],
     137          110 :             &job.ctx,
     138          110 :             Some(job.reason),
     139          110 :         )
     140          110 :         .await;
     141              :     }
     142         4119 : }
     143              : 
     144              : /// Registration writes prepare one job per CSource subscription of the
     145              : /// tenant (5.11.7) and every subscription with localOnly != true owns one,
     146              : /// so a registration stream against many subscriptions queues
     147              : /// subscriptions × registrations jobs faster than the sources drain them —
     148              : /// at 10 000 × 100 the queued jobs were a 3 GB broker peak. The permit is
     149              : /// taken in the request path: a write waits for a fan-out slot instead of
     150              : /// stacking jobs, and the prepare order (the handlers' commit order) holds.
     151              : pub(super) const CSOURCE_FANOUT_WIDTH: usize = 64;
     152              : pub(super) static CSOURCE_FANOUT: tokio::sync::Semaphore =
     153              :     tokio::sync::Semaphore::const_new(CSOURCE_FANOUT_WIDTH);
     154              : 
     155              : /// The fan-out slots one tenant may hold at once: a tenant
     156              : /// whose CSource subscription endpoints hang keeps at most this many of the
     157              : /// [`CSOURCE_FANOUT_WIDTH`] busy, so another tenant's registration writes
     158              : /// still find a slot.
     159              : pub(super) const CSOURCE_FANOUT_PER_TENANT: usize = CSOURCE_FANOUT_WIDTH / 8;
     160              : 
     161              : type FanoutShares =
     162              :     std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<tokio::sync::Semaphore>>>;
     163              : static FANOUT_SHARES: std::sync::LazyLock<FanoutShares> =
     164              :     std::sync::LazyLock::new(FanoutShares::default);
     165              : 
     166              : /// Both permits one fan-out holds until its sends settle: the tenant's
     167              : /// share first, as `delivery_permits` takes it, so a tenant at its share
     168              : /// waits there and never inside the broker's slots. A share lives only
     169              : /// while a fan-out holds it: creating one drops every entry nothing holds,
     170              : /// so the map is bounded by the width, not by the tenants ever seen.
     171         4296 : pub(super) async fn fanout_permits(tenant: &TenantId) -> impl Sized {
     172         4296 :     let shares = {
     173              :         // a poisoned map is a past panic; what it holds is rebuildable
     174         4296 :         let mut map = FANOUT_SHARES.lock().unwrap_or_else(|p| p.into_inner());
     175         4296 :         match map.get(tenant.as_str()) {
     176         4164 :             Some(s) => std::sync::Arc::clone(s),
     177              :             None => {
     178          133 :                 map.retain(|_, s| std::sync::Arc::strong_count(s) > 1);
     179          132 :                 let s = std::sync::Arc::new(tokio::sync::Semaphore::new(CSOURCE_FANOUT_PER_TENANT));
     180          132 :                 map.insert(tenant.as_str().to_owned(), std::sync::Arc::clone(&s));
     181          132 :                 s
     182              :             }
     183              :         }
     184              :     };
     185              :     // never closed, so acquire only fails if it were — then send unbounded
     186         4296 :     let share = shares.acquire_owned().await.ok();
     187         4292 :     (share, CSOURCE_FANOUT.acquire().await.ok())
     188         4292 : }
     189              : 
     190              : /// Registration create/update/delete → prepare in the request path, send
     191              : /// spawned (the ack must not block on the receiver), bounded as above.
     192         4256 : pub(crate) async fn csource_fanout(
     193         4256 :     st: &AppState,
     194         4256 :     tenant: &TenantId,
     195         4256 :     before: Option<Value>,
     196         4256 :     after: Option<Value>,
     197         4256 : ) {
     198         4256 :     let permit = fanout_permits(tenant).await;
     199         4256 :     let jobs = prepare_csource_jobs(st, tenant, before, after).await;
     200         4256 :     let (st2, t2) = (st.clone(), tenant.clone());
     201         4256 :     crate::spawn(async move {
     202         4119 :         send_csource_jobs(&st2, &t2, jobs).await;
     203         4119 :         drop(permit);
     204         4119 :     });
     205         4256 : }
     206              : 
     207              : /// POST a CSourceNotification (5.3.2) under the same body bound as every
     208              : /// other one: 5.11.2.4 sends "all matching Context Source Registrations",
     209              : /// and a broker holding 100 000 of them must not turn that into one
     210              : /// unbounded request. Over the cap the set leaves as several notifications,
     211              : /// each carrying whole registrations — the trade [`deliver`] already makes
     212              : /// for entity Notifications.
     213          165 : pub(super) async fn deliver_csource(
     214          165 :     st: &AppState,
     215          165 :     tenant: &TenantId,
     216          165 :     sub: &Value,
     217          165 :     data: Vec<Value>,
     218          165 :     ctx: &Context,
     219          165 :     reason: &str,
     220          165 : ) {
     221          165 :     let kind = crate::registry::csr_kind(sub_str(sub, "id").unwrap_or_default());
     222          169 :     for chunk in chunk_by_bytes(data, *crate::bounds::MAX_BODY_BYTES) {
     223          169 :         deliver_as(
     224          169 :             st,
     225          169 :             tenant,
     226          169 :             kind,
     227          169 :             sub,
     228          169 :             "ContextSourceNotification",
     229          169 :             chunk,
     230          169 :             ctx,
     231          169 :             Some(reason),
     232          169 :         )
     233          169 :         .await;
     234              :     }
     235          129 : }
     236              : 
     237              : /// Initial / post-update CSourceNotification with all currently matching
     238              : /// registrations (5.11.2.4 / 5.11.3.4).
     239         1186 : pub(crate) async fn csource_initial(st: &AppState, tenant: &TenantId, sub_id: &str) {
     240         1186 :     let Some(sub) = st
     241         1186 :         .store
     242         1186 :         .get(tenant, crate::registry::csr_kind(sub_id), sub_id)
     243         1186 :         .await
     244         1186 :         .ok()
     245         1186 :         .flatten()
     246              :     else {
     247            0 :         return;
     248              :     };
     249         1186 :     if !is_active(&sub) {
     250            8 :         return;
     251         1178 :     }
     252         1178 :     let ctx = sub_context(st, tenant, &sub).await;
     253         1178 :     let spec = crate::registry::spec_for_subscription(&sub);
     254         1178 :     let data: Vec<Value> = read_or_warn(
     255         1178 :         docs_where(st, tenant, Kind::Registration, |r| {
     256          188 :             crate::registry::csr_matches_subscription(&sub, r, &ctx)
     257          188 :         })
     258         1178 :         .await,
     259         1178 :         "the registrations an initial Context Source Notification carries",
     260              :     )
     261         1178 :     .into_iter()
     262         1178 :     .map(|r| {
     263          165 :         let mut p =
     264          165 :             crate::registry::present_registration(&filter_csr(&spec, &r, &ctx), &ctx, false);
     265          165 :         arrayify_entity_types(&mut p);
     266          165 :         p
     267          165 :     })
     268         1178 :     .collect();
     269         1178 :     if data.is_empty() {
     270         1045 :         return; // nothing currently matching ⇒ no initial notification
     271          133 :     }
     272          133 :     deliver_csource(st, tenant, &sub, data, &ctx, "newlyMatching").await;
     273         1182 : }
     274              : 
     275              : /// Registration copy reduced to the matching RegistrationInfo elements
     276              : /// (5.10.2.5 / 5.11.7 "filtered Context Source Registrations").
     277          315 : pub(super) fn filter_csr(spec: &crate::registry::CsrSpec, reg: &Value, ctx: &Context) -> Value {
     278          315 :     let mut out = reg.clone();
     279          315 :     let matching: Vec<Value> = crate::registry::matching_infos(spec, reg, ctx)
     280          315 :         .into_iter()
     281          315 :         .cloned()
     282          315 :         .collect();
     283          315 :     if !matching.is_empty() {
     284          315 :         if let Some(o) = out.as_object_mut() {
     285          315 :             o.insert("information".into(), Value::Array(matching));
     286          315 :         }
     287            0 :     }
     288          315 :     out
     289          315 : }
        

Generated by: LCOV version 2.0-1