LCOV - code coverage report
Current view: top level - antares-api/src/notify - delivery.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 92.5 % 398 368
Test Date: 2026-11-02 13:21:27 Functions: 57.1 % 168 96

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! One notification on the wire: cooldown, endpoint options, receiverInfo, in-process transports and the outbound send.
       3              : 
       4              : use super::*;
       5              : 
       6              : /// POST the Notification (5.3.1) and write the 5.2.14.2 bookkeeping back.
       7         4865 : pub(crate) async fn deliver(
       8         4865 :     st: &AppState,
       9         4865 :     tenant: &TenantId,
      10         4865 :     sub: &Value,
      11         4865 :     data: Vec<Value>,
      12         4865 :     ctx: &Context,
      13         4865 : ) {
      14              :     // a notification body is bounded the way an inbound body is (6.3.4 wall,
      15              :     // MAX_BODY_BYTES): a grouped delivery over the cap leaves as several
      16              :     // notifications, each whole entities, never one unbounded POST
      17         4865 :     for chunk in chunk_by_bytes(data, *crate::bounds::MAX_BODY_BYTES) {
      18         4865 :         deliver_as(
      19         4865 :             st,
      20         4865 :             tenant,
      21         4865 :             Kind::Subscription,
      22         4865 :             sub,
      23         4865 :             "Notification",
      24         4865 :             chunk,
      25         4865 :             ctx,
      26         4865 :             None,
      27         4865 :         )
      28         4865 :         .await;
      29              :     }
      30         4748 : }
      31              : 
      32              : /// Split `data` into runs whose serialized sizes stay under `cap`, cutting
      33              : /// only at whole items; one item alone over the cap still travels alone.
      34         5042 : pub(super) fn chunk_by_bytes(data: Vec<Value>, cap: usize) -> Vec<Vec<Value>> {
      35         5042 :     let mut out: Vec<Vec<Value>> = Vec::new();
      36         5042 :     let mut size = 0usize;
      37         5433 :     for item in data {
      38         5433 :         let n = serde_json::to_vec(&item).map(|b| b.len()).unwrap_or(0);
      39         5433 :         match out.last_mut() {
      40          395 :             Some(run) if size.saturating_add(n) <= cap => {
      41          379 :                 run.push(item);
      42          379 :                 size = size.saturating_add(n);
      43          379 :             }
      44         5054 :             _ => {
      45         5054 :                 out.push(vec![item]);
      46         5054 :                 size = n;
      47         5054 :             }
      48              :         }
      49              :     }
      50         5042 :     out
      51         5042 : }
      52              : 
      53              : /// Table 5.2.15-1 `timeout`: per-endpoint delivery deadline in milliseconds.
      54              : /// "The NGSI-LD system can override this value" — clamped to [100 ms, 30 s]
      55              : /// so one subscription cannot park a delivery task for minutes. Default 5 s
      56              : /// (the previous hard-coded deadline). HTTP only: the clause scopes it to
      57              : /// bindings that "always return a response".
      58         5086 : pub(super) fn endpoint_timeout_ms(ep: &serde_json::Map<String, Value>) -> u32 {
      59         5086 :     ep.get("timeout")
      60         5086 :         .and_then(Value::as_f64)
      61         5086 :         .filter(|t| *t > 0.0)
      62         5086 :         .map(|t| (t as u32).clamp(100, 30_000))
      63         5086 :         .unwrap_or(5_000)
      64         5086 : }
      65              : 
      66              : /// Table 5.2.15-1 `cooldown`: "Once a failure has occurred, minimum period of
      67              : /// time in milliseconds which shall elapse before attempting to make a
      68              : /// subsequent notification to the same endpoint after failure. If requests
      69              : /// are received before the cooldown period has expired, no notification is
      70              : /// sent." — i.e. matches inside the window are DROPPED, not queued.
      71         5106 : pub(super) fn in_cooldown(sub: &Value, now: chrono::DateTime<chrono::Utc>) -> bool {
      72         5106 :     let n = sub.get("notification");
      73         5106 :     let Some(cd) = n
      74         5106 :         .and_then(|n| n.get("endpoint"))
      75         5106 :         .and_then(|e| e.get("cooldown"))
      76         5106 :         .and_then(Value::as_f64)
      77         5106 :         .filter(|c| *c > 0.0)
      78              :     else {
      79         5094 :         return false;
      80              :     };
      81              :     // the gate exists only "once a failure has occurred" and only until a
      82              :     // success clears it — notification.status tracks exactly that (5.2.14.2)
      83           12 :     if n.and_then(|n| n.get("status")).and_then(Value::as_str) != Some("failed") {
      84            4 :         return false;
      85            8 :     }
      86            8 :     let Some(lf) = n.and_then(|n| n.get("lastFailure")).and_then(Value::as_str) else {
      87            0 :         return false;
      88              :     };
      89            8 :     let Ok(t) = chrono::DateTime::parse_from_rfc3339(lf) else {
      90            0 :         return false;
      91              :     };
      92            8 :     let elapsed = now
      93            8 :         .signed_duration_since(t.with_timezone(&chrono::Utc))
      94            8 :         .num_milliseconds();
      95            8 :     (elapsed as f64) < cd
      96         5106 : }
      97              : 
      98              : #[allow(clippy::too_many_arguments)] // one param per notification dimension
      99         5196 : pub(super) async fn deliver_as(
     100         5196 :     st: &AppState,
     101         5196 :     tenant: &TenantId,
     102         5196 :     kind: Kind,
     103         5196 :     sub: &Value,
     104         5196 :     ntype: &str,
     105         5196 :     data: Vec<Value>,
     106         5196 :     ctx: &Context,
     107         5196 :     trigger_reason: Option<&str>,
     108         5196 : ) {
     109              :     // 5.2.12: a paused subscription (isActive false) and an expired one send
     110              :     // nothing. Every caller that assembles data locally checks this first;
     111              :     // the ones that arrive with data already assembled — the 5.8.6 inbound
     112              :     // notification among them — did not, so the check belongs here too.
     113         5196 :     if !is_active(sub) {
     114            0 :         return;
     115         5196 :     }
     116         5196 :     let sub_id = sub_str(sub, "id").unwrap_or_default().to_owned();
     117         5196 :     let Some(ep) = sub
     118         5196 :         .get("notification")
     119         5196 :         .and_then(|n| n.get("endpoint"))
     120         5196 :         .and_then(Value::as_object)
     121              :     else {
     122            0 :         return;
     123              :     };
     124         5196 :     let Some(uri) = ep.get("uri").and_then(Value::as_str) else {
     125            0 :         return;
     126              :     };
     127              :     // 5.8.1.4 consumer half: the internal CSR subscription's notifications
     128              :     // are handled in-process (urn:antares:distsub:{tenant}\n{own sub id})
     129         5196 :     if let Some(own) = uri.strip_prefix("urn:antares:distsub:") {
     130          104 :         if let (Some((_, own_id)), Some(handler)) =
     131          106 :             (own.split_once('\n'), st.csource_notification.as_ref())
     132              :         {
     133          104 :             handler(st, tenant, own_id, trigger_reason, &data).await;
     134            2 :         }
     135          102 :         return;
     136         5090 :     }
     137              :     // 6.3.8: the binding comes from the registry and nowhere else. Creation
     138              :     // rejects an endpoint whose scheme no sink serves, so a stored row that
     139              :     // still names one was hand-edited — it is dropped, never delivered
     140              :     // through some other binding.
     141         5090 :     if st.sinks.sink_for_uri(uri).is_none() {
     142            0 :         tracing::warn!(
     143              :             "subscription {sub_id} endpoint {} has no registered binding",
     144            0 :             redact_userinfo(uri)
     145              :         );
     146            0 :         return;
     147         5090 :     }
     148              :     // endpoint.cooldown — drop (never queue) while the window is open.
     149              :     // Before any bookkeeping: a suppressed notification was never sent, so
     150              :     // timesSent/lastNotification must not move.
     151         5090 :     if in_cooldown(sub, chrono::Utc::now()) {
     152            0 :         tracing::debug!("subscription {sub_id} in cooldown; notification suppressed (5.2.15)");
     153            0 :         return;
     154         5090 :     }
     155              :     // An open circuit is the same class of self-inflicted suppression: no
     156              :     // request leaves the process, so Table 5.2.14.2-1 timesSent ("number of
     157              :     // times that the notification has been sent") and lastNotification ("the
     158              :     // instant when the last notification has been sent") must not move
     159              :     // either. `is_open` returning false IS the half-open probe, so the
     160              :     // check stays exactly once per attempt.
     161              :     // A binding that opens no socket has no destination for the policy or
     162              :     // the breaker to judge, nor has a delivery the forward transport makes
     163              :     // in process; every other network binding is policed below.
     164         5090 :     let policed =
     165         5090 :         in_process(st, uri).is_none() && st.sinks.sink_for_uri(uri).is_some_and(|s| s.network());
     166         5090 :     if policed && st.egress.is_open(tenant.as_str(), uri) {
     167            8 :         tracing::debug!(
     168              :             "notification to {} short-circuited (breaker open)",
     169            0 :             redact_userinfo(uri)
     170              :         );
     171            8 :         return;
     172         5082 :     }
     173         5082 :     let accept = ep
     174         5082 :         .get("accept")
     175         5082 :         .and_then(Value::as_str)
     176         5082 :         .unwrap_or("application/json");
     177         5082 :     let now = now_iso();
     178         5082 :     let mut body = json!({
     179         5082 :         "id": format!("urn:ngsi-ld:{ntype}:{}", uuid::Uuid::new_v4()),
     180         5082 :         "type": ntype,
     181         5082 :         "subscriptionId": sub_id,
     182         5082 :         "notifiedAt": now,
     183         5082 :         "data": data,
     184              :     });
     185         5082 :     if let (Some(r), Some(body)) = (trigger_reason, body.as_object_mut()) {
     186          173 :         body.insert("triggerReason".into(), Value::String(r.into()));
     187         4909 :     }
     188              :     // ADR-0020: the engine sees the notification the broker means to send,
     189              :     // before it is encoded — the data entities are still the 5.2.14.1
     190              :     // documents the subscription asked for, not a FeatureCollection or a
     191              :     // set of @context-injected ones, so a projection lands on entities the
     192              :     // engine can name. Before the bookkeeping below, because 5.8.6 moves
     193              :     // timesSent for a notification that "shall be sent": one that is
     194              :     // dropped here never was, exactly like a cooldown or an open breaker.
     195              :     // The subject is the subscriber's, stored when the subscription was
     196              :     // created — delivery is broker-initiated and there is no request here
     197              :     // to read one off.
     198              :     // With no engine attached the answer is Deliver and the subject is
     199              :     // never read out of the subscription — this runs once per notification
     200              :     // per subscription.
     201         5082 :     if let Some(engine) = &st.policy {
     202           36 :         match crate::policy::pre_notify(
     203           36 :             engine.as_ref(),
     204           36 :             &crate::policy::stored_subject(tenant, sub),
     205           36 :             sub,
     206           36 :             &mut body,
     207           36 :         ) {
     208           14 :             crate::policy::NotifyDecision::Deliver => {}
     209              :             crate::policy::NotifyDecision::Drop => {
     210            2 :                 tracing::debug!("subscription {sub_id} notification dropped by the policy engine");
     211            2 :                 return;
     212              :             }
     213           20 :             crate::policy::NotifyDecision::Filter(f) => {
     214              :                 // a condition the notification path cannot re-run is not a
     215              :                 // narrowing it can claim to have applied
     216           20 :                 if f.q.is_some() || f.scope_q.is_some() || f.areas().next().is_some() {
     217            4 :                     tracing::error!(
     218              :                         "policy engine {} narrowed a notification with a query; dropping it",
     219            0 :                         engine.name()
     220              :                     );
     221            4 :                     return;
     222           16 :                 }
     223              :                 // a linked condition already read the target unprojected:
     224              :                 // the narrowing came too late to apply to it
     225           16 :                 if (!f.omit.is_empty() || !f.pick.is_empty() || f.has_rules())
     226           16 :                     && linked_condition(sub)
     227              :                 {
     228            2 :                     tracing::debug!(
     229              :                         "subscription {sub_id} has a linked q under a projecting narrowing; dropping"
     230              :                     );
     231            2 :                     return;
     232           14 :                 }
     233           14 :                 let rules = crate::repr::type_rules(&f).filter(|r| !r.rules.is_empty());
     234           14 :                 let f = crate::repr::compacted_filter(&f, ctx);
     235           14 :                 let n = sub.get("notification");
     236           14 :                 let key_values = matches!(
     237           14 :                     n.and_then(|n| n.get("format")).and_then(Value::as_str),
     238            0 :                     Some("keyValues" | "simplified")
     239              :                 );
     240           14 :                 if let Some(arr) = body.get_mut("data").and_then(Value::as_array_mut) {
     241           14 :                     for e in arr.iter_mut() {
     242           14 :                         project_joined(&f, e, key_values);
     243           14 :                     }
     244            0 :                 }
     245              :                 // the rules read each Entity's stored state
     246              :                 // now, so one that left zone A carries no `speed`, nor a
     247              :                 // `previousValue` of it, and one no rule holds for is not
     248              :                 // notified at all
     249           14 :                 if let Some(rules) = rules {
     250            4 :                     let shown = shown_now(
     251            4 :                         st,
     252            4 :                         tenant,
     253            4 :                         Some(&rules),
     254            4 :                         None,
     255            4 :                         body.get("data").unwrap_or(&Value::Null),
     256            4 :                         ctx,
     257            4 :                     )
     258            4 :                     .await;
     259            4 :                     if let Some(arr) = body.get_mut("data").and_then(Value::as_array_mut) {
     260            4 :                         arr.retain(|e| matches!(entity_shown(&shown, e), Some(Some(_))));
     261            4 :                         for e in arr.iter_mut() {
     262            4 :                             project_shown(&shown, e, key_values);
     263            4 :                         }
     264            4 :                         if arr.is_empty() {
     265            0 :                             tracing::debug!(
     266              :                                 "subscription {sub_id}: no notified Entity is in the narrowing now; dropping"
     267              :                             );
     268            0 :                             return;
     269            4 :                         }
     270            0 :                     }
     271           10 :                 }
     272              :             }
     273              :         }
     274         5046 :     }
     275              :     // 5.8.6: a subscription's ngsildConformance pins the notification format —
     276              :     // amend the data entities per the 4.3.6.8 fallbacks.
     277         5074 :     if let Some(ver) = sub_str(sub, "ngsildConformance").and_then(crate::conformance::parse_version)
     278              :     {
     279            0 :         if let Some(d) = body.get_mut("data") {
     280            0 :             crate::conformance::amend_payload(d, ver);
     281            0 :         }
     282         5074 :     }
     283         5074 :     if accept == "application/ld+json" {
     284              :         // JSON-LD notifications carry the @context inside each data entity
     285              :         // (046_14: data[0] must contain @context; no Link header) — same rule
     286              :         // over MQTT: with ld+json the @context travels in the body (7.2).
     287           14 :         if let Some(arr) = body.get_mut("data").and_then(Value::as_array_mut) {
     288           14 :             for e in arr.iter_mut() {
     289           14 :                 *e = inject_context(e.clone(), ctx);
     290           14 :             }
     291            0 :         }
     292         5060 :     }
     293         5074 :     if accept == "application/geo+json" {
     294              :         // Table 5.3.1-1: with endpoint.accept application/geo+json, data is
     295              :         // a FeatureCollection (5.2.30); if receiverInfo carries
     296              :         // Prefer=body=json the FeatureCollection carries no @context.
     297            4 :         let prefer_body_json = ep
     298            4 :             .get("receiverInfo")
     299            4 :             .and_then(Value::as_array)
     300            4 :             .is_some_and(|ri| {
     301            2 :                 ri.iter().any(|kv| {
     302            2 :                     kv.get("key").and_then(Value::as_str) == Some("Prefer")
     303            2 :                         && kv.get("value").and_then(Value::as_str) == Some("body=json")
     304            2 :                 })
     305            2 :             });
     306            4 :         let entities = body
     307            4 :             .get("data")
     308            4 :             .and_then(Value::as_array)
     309            4 :             .cloned()
     310            4 :             .unwrap_or_default();
     311            4 :         let mut fc = crate::repr::to_geojson_collection(entities, None);
     312            4 :         if let (false, Some(fc)) = (prefer_body_json, fc.as_object_mut()) {
     313            2 :             fc.insert("@context".into(), crate::negotiate::served_context(ctx));
     314            2 :         }
     315            4 :         if let Some(body) = body.as_object_mut() {
     316            4 :             body.insert("data".into(), fc);
     317            4 :         }
     318         5070 :     }
     319         5074 :     let receiver_info = kv_pairs(ep.get("receiverInfo"));
     320              : 
     321              :     // 6.3.22: a subscription living under a snapshot's synthetic tenant
     322              :     // notifies with the NGSILD-Snapshot header and the OWNER tenant — the
     323              :     // internal "snap-…" tenant never leaks.
     324         5074 :     let (hdr_tenant, snapshot_id) =
     325         5074 :         match crate::snapshots::snapshot_of_synth(st, tenant.as_str()).await {
     326            2 :             Some((owner, sid)) => (owner, Some(sid)),
     327         5072 :             None => (tenant.clone(), None),
     328              :         };
     329              : 
     330              :     // Prepared BEFORE the bookkeeping writeback so the optimistic stamp
     331              :     // covers only the in-flight attempt (046_12_01 race). The parts are
     332              :     // transport-neutral: the sink registered for the endpoint's scheme turns
     333              :     // them into HTTP headers (6.3.8) or an MQTT metadata object (Table
     334              :     // 7.2-2).
     335         5074 :     let mut info = receiver_info;
     336         5074 :     strip_reserved_markers(&mut info);
     337         5074 :     if hdr_tenant.as_str() != "default" {
     338          284 :         info.push(("NGSILD-Tenant".into(), hdr_tenant.as_str().to_owned()));
     339         4790 :     }
     340         5074 :     if let Some(sid) = &snapshot_id {
     341            2 :         info.push(("NGSILD-Snapshot".into(), sid.clone()));
     342         5072 :     }
     343         5074 :     let outbound = Outbound {
     344         5074 :         body,
     345         5074 :         accept: accept.to_owned(),
     346         5074 :         link: link_header_value(ctx),
     347         5074 :         receiver_info: info,
     348         5074 :         notifier_info: kv_pairs(ep.get("notifierInfo")),
     349         5074 :     };
     350              :     // Bookkeeping BEFORE the send (5.8.6/5.2.14.2: lastNotification is the
     351              :     // instant the notification is sent). The ETSI mock unblocks the test the
     352              :     // moment the request ARRIVES, so a post-response-only writeback races the
     353              :     // test's immediate Retrieve Subscription (CI flake on 046_12_01).
     354              :     // Optimistic ok; a failed attempt is corrected right below — the transient
     355              :     // window is the in-flight attempt itself, and the failure TPs wait for the
     356              :     // attempt to resolve before asserting.
     357              :     // One store call: the stamp is a fixed mutation, so a backend can write
     358              :     // it as a single statement instead of locking the row across a round
     359              :     // trip. At fan-out that lock is what serializes delivery.
     360              :     // An in-process delivery (a socket, the forward transport) books with
     361              :     // every other one of its tenant in one statement; a network one
     362              :     // keeps its own stamp right before its own send (046_12_01).
     363         5074 :     let booked = if !policed && kind == Kind::Subscription {
     364          116 :         super::coalesce::book_in_process(st, tenant, &sub_id, &now).await
     365              :     } else {
     366         4958 :         st.store
     367         4958 :             .record_delivery(tenant, kind, &sub_id, &now)
     368         4958 :             .await
     369         4958 :             .unwrap_or_else(|e| {
     370            0 :                 tracing::warn!("bookkeeping writeback failed: {e}");
     371            0 :                 None
     372            0 :             })
     373              :     };
     374              :     // 5.8.6: notifications are sent for the subscriptions the broker holds.
     375              :     // No row to book against means the subscription was deleted (or the
     376              :     // store failed) between matching and delivery — nothing may be sent.
     377         5074 :     let Some(booked) = booked else {
     378            8 :         return;
     379              :     };
     380         5066 :     let mut prev_success = booked.prev_success;
     381         5066 :     mirror_bookkeeping(st, tenant, kind, &sub_id, Some(booked.doc));
     382              :     // The notification endpoint is an egress destination like any other
     383              :     // — policy check once, breaker consulted before the attempt.
     384              :     // A refusal is a delivery failure for bookkeeping (status "failed",
     385              :     // lastSuccess rolled back below) but never breaker state: the policy
     386              :     // verdict says nothing about the endpoint's health.
     387         5066 :     let refused = policed
     388         4950 :         && match st.egress.check_destination(uri).await {
     389         4942 :             Ok(()) => false,
     390            8 :             Err(e) => {
     391            8 :                 tracing::warn!(
     392              :                     "notification endpoint {} refused by egress policy: {e}",
     393            0 :                     redact_userinfo(uri)
     394              :                 );
     395            8 :                 true
     396              :             }
     397              :         };
     398              :     // (delivered, timed_out): only a TIMEOUT-class failure feeds the breaker
     399              :     // — that protects against peers that eat the deadline. An endpoint
     400              :     // that ANSWERS (any status) is alive, costs only its own response time,
     401              :     // and 6.3.8 says the notification shall be sent — suppressing sends to a
     402              :     // responding host:port starves unrelated subscriptions sharing it.
     403         5066 :     let timeout_ms = endpoint_timeout_ms(ep);
     404         5066 :     let first = if refused {
     405            8 :         Err((false, "refused by egress policy".to_owned()))
     406              :     } else {
     407         5058 :         send_outbound(st, tenant, uri, timeout_ms, &outbound).await
     408              :     };
     409         4917 :     let (ok, timed_out) = match &first {
     410         4764 :         Ok(()) => (true, false),
     411          153 :         Err((t, _)) => (false, *t),
     412              :     };
     413         4917 :     if policed && !refused {
     414         4795 :         if ok {
     415         4650 :             st.egress.record_success(tenant.as_str(), uri);
     416         4650 :         } else if timed_out {
     417           36 :             st.egress.record_failure(tenant.as_str(), uri);
     418          109 :         } else {
     419          109 :             // the destination responded (or refused fast): alive — clear
     420          109 :             // any stale consecutive-timeout state
     421          109 :             st.egress.record_success(tenant.as_str(), uri);
     422          109 :         }
     423          122 :     }
     424              :     // Delivery counters by binding (facade — no-op without the broker's
     425              :     // recorder). The label is the sink's first scheme, so the two members of
     426              :     // a family share one series.
     427         4917 :     let scheme = st
     428         4917 :         .sinks
     429         4917 :         .sink_for_uri(uri)
     430         4917 :         .and_then(|s| s.schemes().first().copied())
     431         4917 :         .unwrap_or("unknown");
     432         4917 :     if ok {
     433         4764 :         metrics::counter!("antares_notifications_sent_total", "scheme" => scheme).increment(1);
     434         4764 :     } else {
     435          153 :         metrics::counter!("antares_notifications_failed_total", "scheme" => scheme).increment(1);
     436          153 :     }
     437         4917 :     if !ok {
     438              :         // 5.8.6 / 5.11.7: subscription status → "failed" on delivery failure;
     439              :         // roll back the optimistic lastSuccess stamp.
     440          153 :         let ts = now_iso();
     441              :         // An in-process failure (a socket queue that overflowed) waits in
     442              :         // its tenant's queue like its booking did: a fan-out of overflowing
     443              :         // sockets holds one write-back at a time, not one per socket.
     444          153 :         let failed_doc = if !policed && kind == Kind::Subscription {
     445            0 :             super::coalesce::fail_in_process(st, tenant, &sub_id, prev_success.take(), &ts).await
     446              :         } else {
     447          153 :             record_failure(st, tenant, kind, &sub_id, prev_success.take(), &ts).await
     448              :         };
     449          153 :         mirror_bookkeeping(st, tenant, kind, &sub_id, failed_doc);
     450              :         // Retries are transport, not new notifications: they run on their
     451              :         // own task (never on the request path, never delaying another
     452              :         // subscription's delivery) and book only the final outcome — a
     453              :         // success sets lastSuccess/status ok without touching timesSent;
     454              :         // an exhausted policy leaves a dead letter.
     455              :         #[cfg(not(target_arch = "wasm32"))]
     456          153 :         if !refused && st.delivery.attempts > 1 {
     457           16 :             let first_err = first.err().map(|(_, e)| e).unwrap_or_default();
     458           16 :             let (st, tenant, uri) = (st.clone(), tenant.clone(), uri.to_owned());
     459           16 :             crate::spawn(async move {
     460           16 :                 retry_and_settle(
     461           16 :                     &st, &tenant, kind, &sub_id, &uri, timeout_ms, outbound, first_err,
     462           16 :                 )
     463           16 :                 .await;
     464           12 :             });
     465          137 :         }
     466         4764 :     }
     467         5043 : }
     468              : 
     469              : /// 5.8.6 / 5.11.7 after a failed attempt: status "failed", the optimistic
     470              : /// `lastSuccess` rolled back to `prev_success`, `lastFailure` and
     471              : /// `timesFailed` moved. The stored document as it now stands, `None` when the
     472              : /// row is gone or the write failed (logged).
     473          153 : pub(super) async fn record_failure(
     474          153 :     st: &AppState,
     475          153 :     tenant: &TenantId,
     476          153 :     kind: Kind,
     477          153 :     sub_id: &str,
     478          153 :     mut prev_success: Option<Value>,
     479          153 :     ts: &str,
     480          153 : ) -> Option<Value> {
     481          153 :     let mut failed_doc: Option<Value> = None;
     482          153 :     st.store
     483          153 :         .mutate(tenant, kind, sub_id, |doc| {
     484          133 :             if let Some(o) = doc.as_object_mut() {
     485          133 :                 o.insert("status".into(), Value::String("failed".into()));
     486          133 :             }
     487          133 :             if let Some(n) = doc
     488          133 :                 .as_object_mut()
     489          133 :                 .and_then(|o| o.get_mut("notification"))
     490          133 :                 .and_then(Value::as_object_mut)
     491              :             {
     492          133 :                 match prev_success.take() {
     493           20 :                     Some(v) => n.insert("lastSuccess".into(), v),
     494          113 :                     None => n.remove("lastSuccess"),
     495              :                 };
     496          133 :                 n.insert("lastFailure".into(), Value::String(ts.to_owned()));
     497              :                 // Table 5.2.14.2-1 timesFailed: "Number of times an
     498              :                 // unsuccessful response (or timeout) has been received
     499              :                 // when delivering the notification" — an output-only
     500              :                 // member implementations shall generate.
     501          133 :                 let failed = n.get("timesFailed").and_then(Value::as_i64).unwrap_or(0);
     502          133 :                 n.insert("timesFailed".into(), json!(failed.saturating_add(1)));
     503          133 :                 n.insert("status".into(), Value::String("failed".into()));
     504            0 :             }
     505          133 :             failed_doc = Some(doc.clone());
     506          133 :             Ok::<(), antares_model::NgsiError>(())
     507          133 :         })
     508          153 :         .await
     509          153 :         .unwrap_or_else(|e| {
     510            0 :             tracing::warn!("failure-status writeback failed: {e}");
     511            0 :             None
     512            0 :         });
     513          153 :     failed_doc
     514          153 : }
     515              : 
     516              : /// A `KeyValuePair[]` member of `endpoint` (Table 5.2.15-1) as owned pairs.
     517              : /// A member that is not an array of well-formed pairs contributes nothing:
     518              : /// 5.2.12 validation at creation already refused a malformed one.
     519        12216 : pub(crate) fn kv_pairs(v: Option<&Value>) -> Vec<(String, String)> {
     520        12216 :     v.and_then(Value::as_array)
     521        12216 :         .map(|a| {
     522           82 :             a.iter()
     523          106 :                 .filter_map(|kv| {
     524              :                     Some((
     525          106 :                         kv.get("key")?.as_str()?.to_owned(),
     526          106 :                         kv.get("value")?.as_str()?.to_owned(),
     527              :                     ))
     528          106 :                 })
     529           82 :                 .collect()
     530           82 :         })
     531        12216 :         .unwrap_or_default()
     532        12216 : }
     533              : 
     534              : /// 6.3.22 / 6.3.8: `NGSILD-Tenant` and `NGSILD-Snapshot` on a notification
     535              : /// are the broker's own statement of where the data came from, appended to
     536              : /// the `receiverInfo` pairs. The HTTP binding appends every pair it is
     537              : /// handed, so a subscriber naming one of the two in `receiverInfo` would
     538              : /// put a second value of it on the wire beside the broker's, and a receiver
     539              : /// reading "the" tenant of a notification could not tell which one the
     540              : /// broker meant. Ordinary custom headers are untouched.
     541         5078 : pub(super) fn strip_reserved_markers(info: &mut Vec<(String, String)>) {
     542         5078 :     info.retain(|(k, _)| {
     543           36 :         !k.eq_ignore_ascii_case("NGSILD-Tenant") && !k.eq_ignore_ascii_case("NGSILD-Snapshot")
     544           36 :     });
     545         5078 : }
     546              : 
     547              : /// The forward transport, when it delivers the notifications sent to `uri`
     548              : /// in process.
     549        14843 : pub(super) fn in_process<'a>(
     550        14843 :     st: &'a AppState,
     551        14843 :     uri: &str,
     552        14843 : ) -> Option<&'a Arc<dyn crate::ForwardTransport>> {
     553        14843 :     st.forward_transport.get().filter(|t| t.notifies(uri))
     554        14843 : }
     555              : 
     556              : /// One attempt on the wire, through the binding the registry holds for the
     557              : /// endpoint's scheme (6.3.8), or in process when the forward transport
     558              : /// takes the endpoint, on every attempt, retries and
     559              : /// replays included. `Err((timed_out, why))`: only a timeout-class failure
     560              : /// feeds the breaker — an endpoint that answers is alive.
     561         5080 : pub(super) async fn send_outbound(
     562         5080 :     st: &AppState,
     563         5080 :     tenant: &TenantId,
     564         5080 :     uri: &str,
     565         5080 :     timeout_ms: u32,
     566         5080 :     outbound: &Outbound,
     567         5080 : ) -> Result<(), (bool, String)> {
     568         5080 :     if let Some(transport) = in_process(st, uri) {
     569           10 :         return transport
     570           10 :             .notify(tenant, uri, &outbound.body)
     571           10 :             .await
     572           10 :             .map_err(|e| (false, e));
     573         5070 :     }
     574         5070 :     let Some(sink) = st.sinks.sink_for_uri(uri) else {
     575            2 :         return Err((
     576            2 :             false,
     577            2 :             format!(
     578            2 :                 "no notification binding registered for {}",
     579            2 :                 redact_userinfo(uri)
     580            2 :             ),
     581            2 :         ));
     582              :     };
     583         5068 :     sink.deliver(
     584         5068 :         uri,
     585         5068 :         outbound,
     586         5068 :         std::time::Duration::from_millis(u64::from(timeout_ms)),
     587         5068 :     )
     588         5068 :     .await
     589         4919 :     .map_err(|e| (e.timed_out, e.message))
     590         4931 : }
     591              : 
     592              : /// The matcher reads subscriptions from the
     593              : /// SubMirror, so every notification bookkeeping writeback must be applied
     594              : /// there too — otherwise the mirror copy never gains
     595              : /// `notification.lastNotification` and 5.2.12 `throttling` suppresses
     596              : /// nothing. In-process apply only: a KV write per notification would not
     597              : /// scale to the 100k-sub target, so in bus=nats multi-pod deployments the
     598              : /// throttling window is per-pod approximate.
     599              : /// Known ceiling: exact distributed throttling = per-notification KV sync or a
     600              : /// store read in `throttled()`; add if a deployment needs the strict window.
     601              : /// The mirror learns the counters from the document the writeback just
     602              : /// committed under the row lock; `None` (no row) leaves it untouched.
     603         5223 : pub(super) fn mirror_bookkeeping(
     604         5223 :     st: &AppState,
     605         5223 :     tenant: &TenantId,
     606         5223 :     kind: Kind,
     607         5223 :     sub_id: &str,
     608         5223 :     doc: Option<Value>,
     609         5223 : ) {
     610         5223 :     if kind != Kind::Subscription {
     611          214 :         return;
     612         5009 :     }
     613         5009 :     if let (Some(m), Some(doc)) = (&st.sub_mirror, doc) {
     614         4721 :         m.apply(tenant.as_str(), sub_id, Some(doc));
     615         4721 :     }
     616         5223 : }
        

Generated by: LCOV version 2.0-1