LCOV - code coverage report
Current view: top level - antares-broker/src - wiring.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 39.7 % 1091 433
Test Date: 2026-11-02 13:21:27 Functions: 26.2 % 202 53

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Bus wiring: roles × bus. The composition root is the ONLY
       3              : //! place that knows both the bus variant and which consumers exist.
       4              : //!
       5              : //! bus=local — single process, all roles: the store's change hook feeds the
       6              : //! in-process matcher (`antares_api::wire`), temporal recording stays synchronous
       7              : //! in the write path. Exactly v0's behaviour; the ETSI pipeline runs this.
       8              : //!
       9              : //! bus=nats — the scale-out spine:
      10              : //!   api role      produces: same-tx outbox rows, the
      11              : //!                 outbox drain publishing to `ANTARES_CHANGES` with
      12              : //!                 `Nats-Msg-Id` dedup, subscription CUD → KV,
      13              : //!                 registration CUD → `ANTARES_REGISTRY`, and the
      14              : //!                 per-instance registration mirror its federation path reads.
      15              : //!   matcher /     one shared DURABLE ("matcher"): decode → process_change →
      16              : //!   notifier      ack AFTER processing; the KV-watched subscription mirror;
      17              : //!                 the interval loop (single-winner by row-lock claim).
      18              : //!   temporal      no bus consumer — auto-recording is synchronous in the
      19              : //!                 write path; the role only carries the
      20              : //!                 plain-mode partition job, wired in main.rs.
      21              : //!
      22              : //! Concurrent drains on N api pods double-publish only within the stream's
      23              : //! duplicate window, where `Nats-Msg-Id` = outbox seq absorbs them — that is
      24              : //! the design, not an accident (at-least-once, engineered idempotent).
      25              : 
      26              : use antares_api::AppState;
      27              : use antares_bus::nats::{self, NatsBus};
      28              : use antares_bus::ChangeEvent;
      29              : use antares_model::TenantId;
      30              : use antares_sql::store::pg::doc::DocKind;
      31              : use antares_sql::store::pg::seal::{self, SecretKey};
      32              : use antares_sql::store::Kind;
      33              : use futures_util::StreamExt;
      34              : use std::sync::Arc;
      35              : 
      36              : #[derive(Clone, Copy, Debug)]
      37              : pub struct Roles {
      38              :     pub api: bool,
      39              :     pub matcher: bool,
      40              :     pub notifier: bool,
      41              :     pub temporal: bool,
      42              :     pub registry: bool,
      43              : }
      44              : 
      45              : impl Roles {
      46           46 :     pub fn parse(spec: &str) -> Result<Self, String> {
      47           46 :         if spec == "all" {
      48           16 :             return Ok(Self {
      49           16 :                 api: true,
      50           16 :                 matcher: true,
      51           16 :                 notifier: true,
      52           16 :                 temporal: true,
      53           16 :                 registry: true,
      54           16 :             });
      55           30 :         }
      56           30 :         let mut r = Self {
      57           30 :             api: false,
      58           30 :             matcher: false,
      59           30 :             notifier: false,
      60           30 :             temporal: false,
      61           30 :             registry: false,
      62           30 :         };
      63           44 :         for part in spec.split(',') {
      64           44 :             match part.trim() {
      65           44 :                 "api" => r.api = true,
      66           36 :                 "matcher" => r.matcher = true,
      67           28 :                 "notifier" => r.notifier = true,
      68           22 :                 "temporal" => r.temporal = true,
      69           18 :                 "registry" => r.registry = true,
      70           16 :                 other => {
      71           16 :                     return Err(format!(
      72           16 :                         "unknown role {other:?} (api|matcher|notifier|temporal|registry|all)"
      73           16 :                     ))
      74              :                 }
      75              :             }
      76              :         }
      77           14 :         Ok(r)
      78           46 :     }
      79              : 
      80           26 :     pub fn all(&self) -> bool {
      81           26 :         self.api && self.matcher && self.notifier && self.temporal && self.registry
      82           26 :     }
      83              : }
      84              : 
      85              : /// ANTARES_OUTBOX_DRAIN: `on` (the default) or `off`, and nothing else. A
      86              : /// typo'd `of` read as "on" under the old permissive parse, quietly defeating
      87              : /// the dedicated-drainer split and the crash drill it exists for.
      88           71 : pub fn outbox_drain_enabled() -> Result<bool, String> {
      89           71 :     match std::env::var("ANTARES_OUTBOX_DRAIN") {
      90           53 :         Err(std::env::VarError::NotPresent) => Ok(true),
      91            0 :         Err(e) => Err(format!("ANTARES_OUTBOX_DRAIN is unreadable: {e}")),
      92           18 :         Ok(v) => match v.as_str() {
      93           18 :             "on" => Ok(true),
      94           16 :             "off" => Ok(false),
      95           14 :             other => Err(format!(
      96           14 :                 "ANTARES_OUTBOX_DRAIN must be on|off, got {other:?}"
      97           14 :             )),
      98              :         },
      99              :     }
     100           71 : }
     101              : 
     102              : /// KV key for one mirrored subscription: tenant verbatim (token-safe by
     103              : /// construction), id hashed (URNs carry `:` — illegal in KV keys). The VALUE
     104              : /// carries the real tenant/id, so the key only needs uniqueness. Kind-scoped:
     105              : /// a Subscription and a Context Source Registration Subscription may carry the
     106              : /// same client-chosen id in one tenant (5.5.10), and sharing a key would let
     107              : /// one overwrite the other's mirror entry.
     108           16 : fn kv_key(tenant: &str, kind: Kind, id: &str) -> String {
     109           16 :     format!(
     110              :         "{tenant}.{}{:016x}",
     111           16 :         if kind == Kind::CSourceSubscription {
     112            2 :             "c"
     113              :         } else {
     114           14 :             ""
     115              :         },
     116           16 :         antares_bus::subjects::fnv1a64(id.as_bytes())
     117              :     )
     118           16 : }
     119              : 
     120              : /// Wire everything bus=nats needs onto the state. Async: connects, hydrates
     121              : /// mirrors, creates consumers, asserts topology — all before the broker
     122              : /// starts accepting traffic, so a mis-shapen topology is a startup failure,
     123              : /// never a silent runtime drift. The bus is handed back for the channels
     124              : /// that ride its core request/reply (a relay between pods).
     125              : ///
     126              : /// `secret_key` is `ANTARES_SECRET_KEY`: the subscription KV mirror carries
     127              : /// each document's notification credentials sealed under it, as the store's
     128              : /// row does ([`seal`]), and the matcher side opens them where it applies an
     129              : /// entry.
     130            0 : pub async fn wire_nats(
     131            0 :     state: &mut AppState,
     132            0 :     url: &str,
     133            0 :     roles: Roles,
     134            0 :     secret_key: Option<SecretKey>,
     135            0 : ) -> Result<Arc<NatsBus>, Box<dyn std::error::Error>> {
     136            0 :     let secret_key = secret_key.map(Arc::new);
     137            0 :     let bus = Arc::new(NatsBus::connect(url).await?);
     138              :     // /q/health `bus` member: live connection state + reconnect count.
     139              :     // Installed HERE so bus=local never carries the member at all.
     140              :     {
     141            0 :         let b = bus.clone();
     142            0 :         state.bus_stats = Some(Arc::new(move || {
     143            0 :             serde_json::json!({
     144            0 :                 "mode": "nats",
     145            0 :                 "connected": b.connected(),
     146            0 :                 "reconnects": b.reconnects(),
     147              :             })
     148            0 :         }));
     149              :     }
     150              :     // Multi-process mode: interval firings need the single-winner claim
     151              :     // — keyed off this flag, not off mirror presence (local mode wires a
     152              :     // mirror too).
     153            0 :     state.nats = true;
     154              :     // Entity writes now enqueue their events in the write transaction.
     155            0 :     state.store.set_outbox(true);
     156              :     // Auto-recording stays SYNCHRONOUS in the write path in every bus mode:
     157              :     // every write goes through an api-role pod that has the
     158              :     // shared store, so recording in-request gives read-your-writes — the
     159              :     // ETSI suite asserts history immediately after a write — and kills the
     160              :     // late-replay resurrection race (a consumer re-applying a pre-delete
     161              :     // event AFTER a direct temporal delete). The recorder consumer this
     162              :     // replaced double-applied by design; it bought nothing but the races.
     163              : 
     164              :     // The drain nudge: a same-process write pokes its own drain, so publish
     165              :     // latency is ~1 ms, not the idle-poll interval. Cross-pod writes are
     166              :     // still covered by each pod's own nudge; the poll below stays as the
     167              :     // crash-recovery fallback.
     168            0 :     let nudge = Arc::new(tokio::sync::Notify::new());
     169              :     {
     170            0 :         let n = nudge.clone();
     171              :         // Auto-recording rides the same synchronous hook here as in bus=local
     172              :         // (one choke point for every write, no handler can forget), then nudges
     173              :         // the outbox drain.
     174            0 :         let st_rec = state.clone();
     175            0 :         state.store.set_change_hook(Arc::new(
     176              :             move |tenant: &TenantId,
     177              :                   before: Option<serde_json::Value>,
     178              :                   after: Option<serde_json::Value>,
     179              :                   _version: antares_store::ChangeVersion|
     180            0 :                   -> antares_store::HookFuture<'_> {
     181            0 :                 let st_rec = st_rec.clone();
     182            0 :                 let n = n.clone();
     183            0 :                 Box::pin(async move {
     184            0 :                     antares_api::notify::record_temporal_change(
     185            0 :                         &st_rec,
     186            0 :                         tenant,
     187            0 :                         before.as_ref(),
     188            0 :                         after.as_ref(),
     189            0 :                     )
     190            0 :                     .await;
     191            0 :                     n.notify_one();
     192            0 :                 })
     193            0 :             },
     194              :         ));
     195              :     }
     196              : 
     197            0 :     let mut durables: Vec<&'static str> = Vec::new();
     198              : 
     199            0 :     if roles.api {
     200              :         // Subscription write side: subscription CUD → KV (tombstone = null doc).
     201            0 :         let kv = bus.subs_kv().await?;
     202            0 :         let kv_for_hook = kv.clone();
     203            0 :         let store_for_hook = state.store.clone();
     204            0 :         let key_for_hook = secret_key.clone();
     205            0 :         state.sub_sync = Some(Arc::new(
     206            0 :             move |tenant: &TenantId, kind: Kind, id: &str, _doc| {
     207            0 :                 let kv = kv_for_hook.clone();
     208            0 :                 let store = store_for_hook.clone();
     209            0 :                 let secret = key_for_hook.clone();
     210            0 :                 let key = kv_key(tenant.as_str(), kind, id);
     211            0 :                 let (tenant, id) = (tenant.clone(), id.to_owned());
     212            0 :                 tokio::spawn(async move {
     213              :                     // The store row is already committed. A lost put leaves this
     214              :                     // subscription invisible to every matcher pod — silently, and
     215              :                     // until the next restart, because mirrors hydrate from the
     216              :                     // store only at process start. So retry rather than warn once.
     217              :                     // Named ceiling: after the last attempt the divergence stands
     218              :                     // until a restart; closing that needs periodic reconciliation.
     219              :                     //
     220              :                     // What is put is the row as the store holds it NOW, under a
     221              :                     // compare-and-set on the revision read before the store: each
     222              :                     // write's sync is its own task with its own retries, and a
     223              :                     // document captured at write time, put late, overwrote a
     224              :                     // newer write — in the KV every matcher pod replays after its
     225              :                     // hydrate. A sync that loses the race re-reads both, so the
     226              :                     // last one to succeed carries the store's last write.
     227            0 :                     for attempt in 0..MIRROR_SYNC_ATTEMPTS {
     228            0 :                         let revision = match kv.entry(key.clone()).await {
     229            0 :                             Ok(e) => e.map_or(0, |e| e.revision),
     230            0 :                             Err(e) => {
     231            0 :                                 tracing::warn!("sub KV sync attempt {} failed: {e}", attempt + 1);
     232            0 :                                 tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     233            0 :                                 continue;
     234              :                             }
     235              :                         };
     236              :                         // A Context Source Registration Subscription's entry
     237              :                         // only wakes the sweep (`apply_delta`): it carries no
     238              :                         // document, so none is read, sealed or shipped.
     239            0 :                         let read = if kind == Kind::CSourceSubscription {
     240            0 :                             Ok(None)
     241              :                         } else {
     242            0 :                             store.get(&tenant, kind, &id).await
     243              :                         };
     244            0 :                         let doc = match read {
     245            0 :                             Ok(d) => d,
     246            0 :                             Err(e) => {
     247            0 :                                 tracing::warn!("sub KV sync attempt {} failed: {e}", attempt + 1);
     248            0 :                                 tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     249            0 :                                 continue;
     250              :                             }
     251              :                         };
     252              :                         // The store answered the credentials opened; the
     253              :                         // bucket gets them sealed again, as the row holds them.
     254            0 :                         let row = seal::Row {
     255            0 :                             kind: sub_doc_kind(kind == Kind::CSourceSubscription),
     256            0 :                             tenant: tenant.as_str(),
     257            0 :                             id: &id,
     258            0 :                         };
     259            0 :                         let doc = match doc
     260            0 :                             .map(|d| {
     261            0 :                                 seal::sealed(secret.as_deref(), row, &d).map(|c| c.into_owned())
     262            0 :                             })
     263            0 :                             .transpose()
     264              :                         {
     265            0 :                             Ok(d) => d,
     266              :                             Err(_) => {
     267            0 :                                 tracing::warn!(
     268              :                                     "sub KV sync attempt {} failed: the credentials could \
     269              :                                      not be sealed",
     270            0 :                                     attempt + 1
     271              :                                 );
     272            0 :                                 tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     273            0 :                                 continue;
     274              :                             }
     275              :                         };
     276            0 :                         let value = serde_json::json!({
     277            0 :                             "tenant": tenant.as_str(), "id": id, "doc": doc,
     278            0 :                             "csub": kind == Kind::CSourceSubscription,
     279              :                         });
     280            0 :                         let bytes = serde_json::to_vec(&value).unwrap_or_default();
     281            0 :                         match kv.update(key.clone(), bytes.into(), revision).await {
     282            0 :                             Ok(_) => return,
     283            0 :                             Err(e) => {
     284            0 :                                 tracing::warn!("sub KV sync attempt {} failed: {e}", attempt + 1);
     285            0 :                                 tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     286              :                             }
     287              :                         }
     288              :                     }
     289            0 :                     tracing::error!(
     290              :                         "sub KV sync gave up for {key} — this subscription is not mirrored \
     291              :                      until the next restart"
     292              :                     );
     293            0 :                 });
     294            0 :             },
     295              :         ));
     296              : 
     297              :         // An entry put before ANTARES_SECRET_KEY was set still holds its
     298              :         // credentials in plaintext, and `sub_sync` rewrites an entry only
     299              :         // when its subscription is written again: seal them once, here.
     300            0 :         if let Some(secret) = secret_key.clone() {
     301            0 :             let (kv, store) = (kv.clone(), state.store.clone());
     302            0 :             tokio::spawn(async move {
     303            0 :                 let keys: Vec<String> = match kv.keys().await {
     304            0 :                     Ok(keys) => keys.filter_map(|k| async move { k.ok() }).collect().await,
     305            0 :                     Err(e) => {
     306            0 :                         tracing::warn!("sealing the subscription KV: listing failed: {e}");
     307            0 :                         return;
     308              :                     }
     309              :                 };
     310            0 :                 seal_kv_entries(&kv, &*store, &secret, keys).await;
     311            0 :             });
     312            0 :         }
     313              : 
     314              :         // Registration write side: registration CUD → ANTARES_REGISTRY delta.
     315            0 :         let bus_for_reg = bus.clone();
     316            0 :         let store_for_reg = state.store.clone();
     317            0 :         state.reg_sync = Some(Arc::new(move |tenant: &TenantId, id: &str, _doc| {
     318            0 :             let bus = bus_for_reg.clone();
     319            0 :             let store = store_for_reg.clone();
     320            0 :             let (tenant, id) = (tenant.clone(), id.to_owned());
     321            0 :             tokio::spawn(async move {
     322              :                 // Same contract as the subscription mirror: the row is
     323              :                 // committed, so a lost delta makes the registration invisible
     324              :                 // to every federation path until a restart re-hydrates.
     325              :                 //
     326              :                 // And the same ordering fix: what is published is the row as
     327              :                 // the store holds it NOW, under a compare-and-set on the
     328              :                 // registration's newest delta read before the store. A
     329              :                 // document captured at write time, published late, landed
     330              :                 // after a newer write's in every pod's mirror. A publish that
     331              :                 // loses the race re-reads both, so the last one to succeed
     332              :                 // carries the store's last write.
     333            0 :                 for attempt in 0..MIRROR_SYNC_ATTEMPTS {
     334            0 :                     let revision = match bus.registration_revision(tenant.as_str(), &id).await {
     335            0 :                         Ok(r) => r,
     336            0 :                         Err(e) => {
     337            0 :                             tracing::warn!(
     338              :                                 "registry delta publish attempt {} failed: {e}",
     339            0 :                                 attempt + 1
     340              :                             );
     341            0 :                             tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     342            0 :                             continue;
     343              :                         }
     344              :                     };
     345            0 :                     let doc = match store.get(&tenant, Kind::Registration, &id).await {
     346            0 :                         Ok(d) => d,
     347            0 :                         Err(e) => {
     348            0 :                             tracing::warn!(
     349              :                                 "registry delta publish attempt {} failed: {e}",
     350            0 :                                 attempt + 1
     351              :                             );
     352            0 :                             tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     353            0 :                             continue;
     354              :                         }
     355              :                     };
     356            0 :                     let delta = serde_json::json!({
     357            0 :                         "tenant": tenant.as_str(), "id": id, "doc": doc,
     358              :                     });
     359            0 :                     match bus
     360            0 :                         .publish_registration(tenant.as_str(), &id, &delta, revision)
     361            0 :                         .await
     362              :                     {
     363            0 :                         Ok(()) => return,
     364            0 :                         Err(e) => {
     365            0 :                             tracing::warn!(
     366              :                                 "registry delta publish attempt {} failed: {e}",
     367            0 :                                 attempt + 1
     368              :                             );
     369            0 :                             tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     370              :                         }
     371              :                     }
     372              :                 }
     373            0 :                 tracing::error!(
     374              :                     "registry delta publish gave up for {id} — this registration is not \
     375              :                      mirrored until the next restart"
     376              :                 );
     377            0 :             });
     378            0 :         }));
     379              : 
     380              :         // 5.2.34 write side: a cooldown stamp is broadcast to the other api
     381              :         // pods on the registry stream (seconds-scale state, deliberately not
     382              :         // persisted) — per-process stamps re-dial a failed source from every
     383              :         // pod behind the LB.
     384            0 :         let bus_for_cool = bus.clone();
     385            0 :         state.reg_fail_sync = Some(Arc::new(move |reg_id: &str, ok: bool| {
     386            0 :             let bus = bus_for_cool.clone();
     387            0 :             let delta = serde_json::json!({"cooldownReg": reg_id, "ok": ok});
     388            0 :             tokio::spawn(async move {
     389            0 :                 if let Err(e) = bus.publish_registry("cooldown", &delta).await {
     390            0 :                     tracing::warn!("cooldown stamp publish failed: {e}");
     391            0 :                 }
     392            0 :             });
     393            0 :         }));
     394              : 
     395              :         // Registration read side: the ONE compiled registration mirror this instance's
     396              :         // federation path reads. Consumer created BEFORE the hydrate so no
     397              :         // delta can fall between them; last-writer-wins per key converges.
     398            0 :         let reg_mirror = Arc::new(antares_api::mirror::DocMirror::default());
     399            0 :         let reg_consumer = bus.consume_registry_broadcast().await?;
     400              :         // Installed only if it is whole. A mirror that is present and SHORT
     401              :         // is read as the truth — `reg_docs` asks it and never the store — so
     402              :         // half a hydrate silently drops Context Sources for the life of the
     403              :         // process. Left uninstalled, federation matching falls back to the
     404              :         // store's own indexed narrowing: correct, and merely slower.
     405            0 :         match antares_api::notify::seed_mirror(
     406            0 :             &*state.store,
     407            0 :             reg_mirror.as_ref(),
     408            0 :             Kind::Registration,
     409              :         )
     410            0 :         .await
     411              :         {
     412            0 :             Ok(()) => state.reg_mirror = Some(reg_mirror.clone()),
     413            0 :             Err(e) => tracing::error!(
     414              :                 "registration mirror hydrate failed ({e}); \
     415              :                  federation matching falls back to a store read per request"
     416              :             ),
     417              :         }
     418            0 :         tokio::spawn(watch_registry(
     419            0 :             bus.clone(),
     420            0 :             reg_consumer,
     421            0 :             reg_mirror,
     422            0 :             state.store.clone(),
     423            0 :             state.egress.clone(),
     424              :         ));
     425              : 
     426              :         // The outbox drain. Runs on every api pod; concurrent drains are
     427              :         // absorbed by Nats-Msg-Id dedup within the duplicate window.
     428              :         // ANTARES_OUTBOX_DRAIN=off leaves the rows for another pod's drain —
     429              :         // the crash-drill lever and the dedicated-drainer split.
     430            0 :         let drain_on = outbox_drain_enabled()?;
     431            0 :         if !drain_on {
     432            0 :             tracing::warn!("outbox drain OFF on this pod (ANTARES_OUTBOX_DRAIN=off)");
     433            0 :         }
     434            0 :         if drain_on {
     435            0 :             let store = state.store.clone();
     436            0 :             let bus_for_drain = bus.clone();
     437            0 :             tokio::spawn(async move {
     438              :                 loop {
     439            0 :                     let rows = match store.outbox_peek(64).await {
     440            0 :                         Ok(r) => r,
     441            0 :                         Err(e) => {
     442            0 :                             tracing::warn!("outbox peek failed: {e}");
     443            0 :                             tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     444            0 :                             continue;
     445              :                         }
     446              :                     };
     447            0 :                     if rows.is_empty() {
     448              :                         // woken by the same-process write hook, or the fallback
     449              :                         // poll for rows another pod failed to publish
     450            0 :                         let _ = tokio::time::timeout(
     451            0 :                             std::time::Duration::from_millis(250),
     452            0 :                             nudge.notified(),
     453              :                         )
     454            0 :                         .await;
     455            0 :                         continue;
     456            0 :                     }
     457              :                     // Ack the EXACT published seqs — a blanket
     458              :                     // up-to-max delete loses a lower-seq row whose
     459              :                     // transaction commits between peek and ack.
     460            0 :                     let mut acked: Vec<i64> = Vec::new();
     461              :                     // Rows whose bodies were too big for the bus: the message
     462              :                     // carries a reference, and this row is the only copy of
     463              :                     // what it references. Kept, not deleted, and taken out of
     464              :                     // the next page by the same stamp.
     465            0 :                     let mut retained: Vec<(TenantId, i64)> = Vec::new();
     466            0 :                     for (seq, _tenant, event) in rows {
     467            0 :                         match serde_json::from_value::<ChangeEvent>(event) {
     468            0 :                             Ok(mut ev) => {
     469            0 :                                 ev.seq = seq;
     470            0 :                                 let checked = ev.claim_checked_at(antares_bus::CLAIM_CHECK_BYTES);
     471            0 :                                 match bus_for_drain.publish(&ev).await {
     472            0 :                                     Ok(()) if checked => retained.push((ev.tenant, seq)),
     473            0 :                                     Ok(()) => acked.push(seq),
     474            0 :                                     Err(e) => {
     475            0 :                                         tracing::warn!("outbox publish of seq {seq} failed: {e}");
     476            0 :                                         break; // retry from here next round
     477              :                                     }
     478              :                                 }
     479              :                             }
     480            0 :                             Err(e) => {
     481              :                                 // an undecodable row would wedge the drain forever
     482            0 :                                 tracing::error!("outbox row {seq} undecodable ({e}) — skipped");
     483            0 :                                 acked.push(seq);
     484              :                             }
     485              :                         }
     486              :                     }
     487              :                     // Retain BEFORE ack: both statements are separate
     488              :                     // transactions, and a crash between them must leave a
     489              :                     // claim-check row alive rather than published and gone.
     490              :                     // One statement per row, under that row's tenant — the
     491              :                     // outbox UPDATE takes no service escape (0005), and an
     492              :                     // event over the bus ceiling is rare enough that grouping
     493              :                     // the page by tenant would cost more code than statements.
     494            0 :                     for (tenant, seq) in &retained {
     495            0 :                         if let Err(e) = store.outbox_retain(tenant, &[*seq]).await {
     496            0 :                             tracing::warn!("outbox retain of seq {seq} failed: {e}");
     497            0 :                         }
     498              :                     }
     499            0 :                     if !acked.is_empty() {
     500            0 :                         if let Err(e) = store.outbox_ack(&acked).await {
     501            0 :                             tracing::warn!("outbox ack {acked:?} failed: {e}");
     502            0 :                         }
     503            0 :                     }
     504              :                 }
     505              :             });
     506            0 :         }
     507            0 :     }
     508              : 
     509            0 :     if roles.matcher || roles.notifier {
     510              :         // Subscription read side: consumer-before-hydrate, same convergence argument.
     511            0 :         let sub_mirror = Arc::new(antares_api::mirror::SubMirror::default());
     512            0 :         let kv = bus.subs_kv().await?;
     513            0 :         let watch = kv.watch_all().await?;
     514            0 :         let key = secret_key.clone();
     515              :         // Same rule as the registration mirror, and the same one `bus=local`
     516              :         // applies in `antares_api::wire`: a subscription absent from an installed
     517              :         // mirror never fires again, because the matcher reads candidates
     518              :         // from the mirror alone.
     519            0 :         match antares_api::notify::seed_mirror(
     520            0 :             &*state.store,
     521            0 :             sub_mirror.as_ref(),
     522            0 :             Kind::Subscription,
     523              :         )
     524            0 :         .await
     525              :         {
     526            0 :             Ok(()) => state.sub_mirror = Some(sub_mirror.clone()),
     527            0 :             Err(e) => tracing::error!(
     528              :                 "subscription mirror hydrate failed ({e}); \
     529              :                  matching falls back to a store scan per change"
     530              :             ),
     531              :         }
     532            0 :         tokio::spawn(async move {
     533              :             // Same restart contract as the registry consumer: the watch ends
     534              :             // on a NATS restart, and a task that returns there stops seeing
     535              :             // every subscription change — i.e. this pod silently stops
     536              :             // notifying — for the process lifetime. `watch_all` replays the
     537              :             // bucket's current values, so re-opening also re-converges the
     538              :             // mirror over the gap.
     539            0 :             let mut watch = watch;
     540              :             loop {
     541            0 :                 while let Some(entry) = watch.next().await {
     542            0 :                     let Ok(entry) = entry else { continue };
     543            0 :                     if let Ok(mut v) = serde_json::from_slice::<serde_json::Value>(&entry.value) {
     544            0 :                         open_sub_delta(key.as_deref(), &mut v);
     545            0 :                         apply_delta(sub_mirror.as_ref(), &v);
     546            0 :                     }
     547              :                 }
     548            0 :                 tracing::warn!("subscription KV watch ended — reopening");
     549            0 :                 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     550            0 :                 match kv.watch_all().await {
     551            0 :                     Ok(w) => watch = w,
     552            0 :                     Err(e) => tracing::warn!("subscription KV watch reopen failed: {e}"),
     553              :                 }
     554              :             }
     555              :         });
     556              : 
     557              :         // The balanced matcher durable: decode → process_change → ack AFTER,
     558              :         // with progress heartbeats for every message this pod holds while
     559              :         // one of them is processed (a slow endpoint must not let ack_wait
     560              :         // pass and have the server hand them all out a second time).
     561            0 :         durables.push("matcher");
     562            0 :         let consumer = bus.consume_balanced("matcher").await?;
     563            0 :         let st = state.clone();
     564            0 :         tokio::spawn(async move {
     565              :             loop {
     566            0 :                 let run = nats::run_balanced(&consumer, |msg| {
     567              :                     // Change lag = stream-publish → matcher-processing
     568              :                     // age, from the JetStream metadata timestamp.
     569            0 :                     if let Ok(info) = msg.info() {
     570            0 :                         note_final_delivery(
     571            0 :                             msg.subject.as_str(),
     572            0 :                             info.stream_sequence,
     573            0 :                             info.delivered,
     574              :                         );
     575              :                         // OffsetDateTime → SystemTime (impl in `time`/std),
     576              :                         // so no direct `time` dependency here.
     577            0 :                         let published: std::time::SystemTime = info.published.into();
     578            0 :                         if let Ok(age) = published.elapsed() {
     579            0 :                             metrics::histogram!("antares_change_lag_seconds")
     580            0 :                                 .record(age.as_secs_f64());
     581            0 :                         }
     582            0 :                     }
     583            0 :                     let ev = nats::decode(msg);
     584            0 :                     let st = st.clone();
     585            0 :                     async move {
     586            0 :                         if let Some(ev) = ev {
     587            0 :                             let (before, after) = resolve_payloads(&st, &ev).await;
     588            0 :                             antares_api::notify::process_change(
     589            0 :                                 &st,
     590            0 :                                 ev.tenant.as_str(),
     591            0 :                                 before,
     592            0 :                                 after,
     593            0 :                             )
     594            0 :                             .await;
     595            0 :                         }
     596            0 :                     }
     597            0 :                 })
     598            0 :                 .await;
     599            0 :                 if let Err(e) = run {
     600            0 :                     tracing::warn!("matcher consumer stream failed: {e}");
     601            0 :                 }
     602            0 :                 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     603              :             }
     604              :         });
     605              : 
     606              :         // Interval subscriptions: every matcher pod ticks; the row-lock claim
     607              :         // in interval_tick makes each firing single-winner.
     608            0 :         let st = state.clone();
     609            0 :         tokio::spawn(async move {
     610            0 :             let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
     611              :             loop {
     612            0 :                 tick.tick().await;
     613            0 :                 antares_api::notify::interval_tick(&st).await;
     614              :             }
     615              :         });
     616            0 :     }
     617              : 
     618              :     // The temporal role carries no bus consumer: auto-recording is
     619              :     // synchronous in the write path (see above), and plain-mode partition
     620              :     // maintenance runs from main.rs regardless of bus mode.
     621              : 
     622              :     // The server must agree these are shared durables.
     623            0 :     bus.assert_topology(&durables).await?;
     624            0 :     tracing::info!(?roles, "bus=nats wired");
     625            0 :     Ok(bus)
     626            0 : }
     627              : 
     628              : /// Whether a change message is on its last delivery: the matcher durable
     629              : /// gives a message up after `nats::MAX_DELIVER` deliveries, so if this
     630              : /// attempt does not finish the server drops the change and the
     631              : /// notifications it should produce. Logged at error level (subject and
     632              : /// stream sequence, never the payload) and counted, before the work, so such
     633              : /// a loss is never silent.
     634            6 : fn note_final_delivery(subject: &str, stream_seq: u64, delivered: i64) -> bool {
     635            6 :     if delivered < nats::MAX_DELIVER {
     636            4 :         return false;
     637            2 :     }
     638            2 :     tracing::error!(
     639              :         subject,
     640              :         stream_seq,
     641              :         delivered,
     642              :         "change message on its final delivery: if this attempt fails, the server drops it"
     643              :     );
     644            2 :     metrics::counter!("antares_change_final_delivery_total").increment(1);
     645            2 :     true
     646            6 : }
     647              : 
     648              : /// The registration mirror's read side (bus=nats, api role): every
     649              : /// registry delta into `mirror`, every 5.2.34 cooldown stamp into `egress`.
     650            0 : async fn watch_registry(
     651            0 :     bus: Arc<NatsBus>,
     652            0 :     reg_consumer: nats::PullConsumer,
     653            0 :     reg_mirror: Arc<antares_api::mirror::DocMirror>,
     654            0 :     store: Arc<dyn antares_store::CurrentStateDriver>,
     655            0 :     egress: Arc<antares_api::egress::Egress>,
     656            0 : ) {
     657              :     // The consumer is ephemeral, so a NATS restart or an inactivity
     658              :     // gap deletes it server-side and the next pull errors. Ending the
     659              :     // task there froze this pod's registration mirror — and with it
     660              :     // all federation matching — for the process lifetime, while
     661              :     // /q/health still reported the bus connected. Re-open instead,
     662              :     // and re-hydrate from the store because a fresh consumer starts
     663              :     // at NEW and never replays what the gap dropped.
     664              :     // The gap ends on a NEW consumer: the handle of a deleted one pulls
     665              :     // nothing ever again.
     666            0 :     let mut reg_consumer = reg_consumer;
     667              :     loop {
     668            0 :         match reg_consumer.messages().await {
     669            0 :             Ok(mut msgs) => {
     670            0 :                 while let Some(delta) = nats::next_delta(&mut msgs).await {
     671              :                     // 5.2.34 read side: a cooldown stamp updates this pod's
     672              :                     // map (a marker delta has no tenant/id — apply_delta
     673              :                     // ignores it on pods that predate the member).
     674            0 :                     if let Some(rid) = delta.get("cooldownReg").and_then(serde_json::Value::as_str)
     675              :                     {
     676            0 :                         egress.reg_record(rid, delta["ok"].as_bool().unwrap_or(false));
     677            0 :                         continue;
     678            0 :                     }
     679            0 :                     apply_delta(reg_mirror.as_ref(), &delta);
     680              :                 }
     681            0 :                 tracing::warn!("registry broadcast consumer stream ended — reopening");
     682              :             }
     683            0 :             Err(e) => tracing::warn!("registry broadcast consumer stream failed: {e}"),
     684              :         }
     685              :         // Consumer before re-hydrate, the startup order: no delta can
     686              :         // fall between the two.
     687              :         loop {
     688            0 :             tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     689            0 :             match bus.consume_registry_broadcast().await {
     690            0 :                 Ok(c) => {
     691            0 :                     reg_consumer = c;
     692            0 :                     break;
     693              :                 }
     694            0 :                 Err(e) => tracing::warn!("registry broadcast consumer reopen failed: {e}"),
     695              :             }
     696              :         }
     697              :         // Already installed, so this one cannot be withheld. A failed
     698              :         // re-hydrate leaves the mirror holding what it had before the
     699              :         // gap, which the federation path will serve as current: say
     700              :         // so at error level rather than let it pass as a warning.
     701            0 :         if let Err(e) = rehydrate_registrations(&*store, &reg_mirror).await {
     702            0 :             tracing::error!(
     703              :                 "registration mirror re-hydrate failed ({e}); \
     704              :                      this pod is matching against registrations from before the gap"
     705              :             );
     706            0 :         }
     707              :     }
     708              : }
     709              : 
     710              : /// Seal the subscription KV entries named by `keys` that still hold a
     711              : /// plaintext credential, from the store's row and under a compare-and-set
     712              : /// on the entry read: a sync that wrote meanwhile put its own sealed entry,
     713              : /// and wins. An entry whose subscription this store does not hold is left to
     714              : /// the next write's sync. Answers how many entries it sealed.
     715              : ///
     716              : /// ponytail: one pass at boot, sequential, one entry read per key of the
     717              : /// bucket (one key per subscription).
     718            0 : async fn seal_kv_entries(
     719            0 :     kv: &nats::KvStore,
     720            0 :     store: &dyn antares_store::CurrentStateDriver,
     721            0 :     secret: &SecretKey,
     722            0 :     keys: Vec<String>,
     723            0 : ) -> usize {
     724            0 :     let mut sealed = 0;
     725            0 :     for key in keys {
     726            0 :         let Ok(Some(entry)) = kv.entry(key.clone()).await else {
     727            0 :             continue;
     728              :         };
     729            0 :         let Ok(v) = serde_json::from_slice::<serde_json::Value>(&entry.value) else {
     730            0 :             continue;
     731              :         };
     732            0 :         let csub = v.get("csub").and_then(serde_json::Value::as_bool) == Some(true);
     733            0 :         let (Some(tenant), Some(id), Some(doc)) = (
     734            0 :             v.get("tenant").and_then(serde_json::Value::as_str),
     735            0 :             v.get("id").and_then(serde_json::Value::as_str),
     736            0 :             v.get("doc").filter(|d| !d.is_null()),
     737              :         ) else {
     738            0 :             continue;
     739              :         };
     740            0 :         if !seal::holds_plaintext(sub_doc_kind(csub), doc) {
     741            0 :             continue;
     742            0 :         }
     743            0 :         let Ok(tenant_id) = TenantId::new_internal(tenant) else {
     744            0 :             continue;
     745              :         };
     746              :         // a Context Source Registration Subscription's entry needs no document
     747            0 :         let doc = if csub {
     748            0 :             None
     749              :         } else {
     750            0 :             match store.get(&tenant_id, Kind::Subscription, id).await {
     751            0 :                 Ok(Some(d)) => {
     752            0 :                     let row = seal::Row {
     753            0 :                         kind: sub_doc_kind(false),
     754            0 :                         tenant,
     755            0 :                         id,
     756            0 :                     };
     757            0 :                     match seal::sealed(Some(secret), row, &d) {
     758            0 :                         Ok(d) => Some(d.into_owned()),
     759            0 :                         Err(_) => continue,
     760              :                     }
     761              :                 }
     762            0 :                 _ => continue,
     763              :             }
     764              :         };
     765            0 :         let value = serde_json::json!({"tenant": tenant, "id": id, "doc": doc, "csub": csub});
     766            0 :         let bytes = serde_json::to_vec(&value).unwrap_or_default();
     767            0 :         match kv.update(key.clone(), bytes.into(), entry.revision).await {
     768            0 :             Ok(_) => sealed += 1,
     769            0 :             Err(e) => tracing::warn!("sealing the subscription KV entry {key} failed: {e}"),
     770              :         }
     771              :     }
     772            0 :     sealed
     773            0 : }
     774              : 
     775              : /// Re-seed the registration mirror after a gap in its deltas. The gap may
     776              : /// have swallowed deletions too, so the store's set is seeded into a fresh
     777              : /// mirror and replaces the old contents whole; seeding over the old mirror
     778              : /// kept every registration deleted during the gap, and federation kept
     779              : /// forwarding to it. On an error the old contents stay.
     780            2 : async fn rehydrate_registrations(
     781            2 :     store: &dyn antares_store::CurrentStateDriver,
     782            2 :     reg_mirror: &antares_api::mirror::DocMirror,
     783            2 : ) -> Result<(), antares_model::NgsiError> {
     784            2 :     let fresh = antares_api::mirror::DocMirror::default();
     785            2 :     antares_api::notify::seed_mirror(store, &fresh, Kind::Registration).await?;
     786            2 :     reg_mirror.replace_with(fresh);
     787            2 :     Ok(())
     788            2 : }
     789              : 
     790              : /// Hydrate a mirror from the system of record (Postgres) at startup.
     791              : /// Attempts a mirror write gets before the divergence is logged as an error.
     792              : const MIRROR_SYNC_ATTEMPTS: u32 = 5;
     793              : 
     794              : /// Exponential backoff between mirror-sync attempts: 0.2 s doubling to 3.2 s,
     795              : /// so the whole ladder outlives a bus reconnect without holding a task for
     796              : /// minutes.
     797            0 : fn mirror_sync_backoff(attempt: u32) -> std::time::Duration {
     798            0 :     std::time::Duration::from_millis(200u64 << attempt.min(4))
     799            0 : }
     800              : 
     801              : /// Apply one `{tenant, id, doc|null, csub}` delta to a mirror. A Context
     802              : /// Source Registration Subscription carries no document into the mirror: it
     803              : /// is matched against registrations rather than entities, so the delta only
     804              : /// wakes the interval sweep (5.11.7).
     805           34 : fn apply_delta(mirror: &dyn antares_api::mirror::Mirror, delta: &serde_json::Value) {
     806           34 :     if delta.get("csub").and_then(serde_json::Value::as_bool) == Some(true) {
     807            2 :         mirror.csub_written();
     808            2 :         return;
     809           32 :     }
     810           12 :     let (Some(tenant), Some(id)) = (
     811           32 :         delta.get("tenant").and_then(serde_json::Value::as_str),
     812           32 :         delta.get("id").and_then(serde_json::Value::as_str),
     813              :     ) else {
     814           20 :         return;
     815              :     };
     816              :     // Hydration validates the tenant before it touches the mirror; the delta
     817              :     // path must agree. A name outside the grammar can only add entries that no
     818              :     // lookup will ever hit, so an unvalidated one is unbounded growth keyed by
     819              :     // whatever reached the bus. The grammar is the whole check here: what
     820              :     // arrives is a tenant a peer broker wrote, and a write inside a Snapshot
     821              :     // (5.5.15) carries the synthetic tenant a client may not name — refusing
     822              :     // that one would drop the mirror delta the snapshot-scoped subscription
     823              :     // matches on.
     824           12 :     if TenantId::new_internal(tenant).is_err() {
     825            8 :         return;
     826            4 :     }
     827            4 :     let doc = delta.get("doc").filter(|d| !d.is_null()).cloned();
     828            4 :     mirror.apply(tenant, id, doc);
     829           34 : }
     830              : 
     831              : /// The doc kind a subscription KV entry's credentials are sealed for.
     832            0 : fn sub_doc_kind(csub: bool) -> DocKind {
     833            0 :     if csub {
     834            0 :         DocKind::CSourceSubscription
     835              :     } else {
     836            0 :         DocKind::Subscription
     837              :     }
     838            0 : }
     839              : 
     840              : /// Open the credentials of one subscription KV entry in place, before
     841              : /// [`apply_delta`]. An entry this pod cannot open (no key, another key, a
     842              : /// damaged value) becomes a tombstone: the subscription leaves this pod's
     843              : /// mirror rather than notify with ciphertext in its headers or its URI.
     844              : /// A plaintext entry (one put before the key was set) opens as it is.
     845            0 : fn open_sub_delta(key: Option<&SecretKey>, delta: &mut serde_json::Value) {
     846            0 :     let csub = delta.get("csub").and_then(serde_json::Value::as_bool) == Some(true);
     847            0 :     let (Some(tenant), Some(id)) = (
     848            0 :         delta.get("tenant").and_then(serde_json::Value::as_str),
     849            0 :         delta.get("id").and_then(serde_json::Value::as_str),
     850              :     ) else {
     851            0 :         return;
     852              :     };
     853            0 :     let (tenant, id) = (tenant.to_owned(), id.to_owned());
     854            0 :     let Some(doc) = delta.get_mut("doc").filter(|d| !d.is_null()) else {
     855            0 :         return;
     856              :     };
     857            0 :     let row = seal::Row {
     858            0 :         kind: sub_doc_kind(csub),
     859            0 :         tenant: &tenant,
     860            0 :         id: &id,
     861            0 :     };
     862            0 :     if seal::open(key, row, doc).is_err() {
     863            0 :         tracing::error!(
     864              :             %tenant, %id,
     865              :             "a mirrored subscription's notification credentials cannot be opened: \
     866              :              ANTARES_SECRET_KEY is unset or differs from the key it was sealed with; \
     867              :              this pod drops the subscription from its mirror"
     868              :         );
     869            0 :         *doc = serde_json::Value::Null;
     870            0 :     }
     871            0 : }
     872              : 
     873              : /// Resolve claim-check references: bodies the bus could not carry come back
     874              : /// from the outbox row the drain kept, read by the event's own `seq`.
     875              : ///
     876              : /// The store's current row is NOT that source. It answers with the entity as
     877              : /// it stands now, which is the after-image: resolving `prev_payload_ref` from
     878              : /// it hands the matcher two copies of the same document, `diff` finds nothing
     879              : /// changed and the change reaches no subscriber. It stays the fallback for
     880              : /// `payload_ref` alone, where being newer than the referenced version is the
     881              : /// ordinary at-least-once reality the matcher already tolerates.
     882           12 : async fn resolve_payloads(
     883           12 :     st: &AppState,
     884           12 :     ev: &ChangeEvent,
     885           12 : ) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
     886           12 :     if ev.payload_ref.is_none() && ev.prev_payload_ref.is_none() {
     887            4 :         return (ev.prev_payload.clone(), ev.payload.clone());
     888            8 :     }
     889              :     // 0 is the local bus, which never claim-checks: it hands the payloads to
     890              :     // the matcher in process.
     891            8 :     let kept = match ev.seq {
     892            6 :         0 => None,
     893            2 :         seq => st
     894            2 :             .store
     895            2 :             .outbox_event(seq, &ev.tenant)
     896            2 :             .await
     897            2 :             .ok()
     898            2 :             .flatten()
     899            2 :             .and_then(|v| serde_json::from_value::<ChangeEvent>(v).ok()),
     900              :     };
     901            8 :     if let Some(kept) = kept {
     902            2 :         return (kept.prev_payload, kept.payload);
     903            6 :     }
     904              :     // Past the retention window, or a deployment whose store keeps no outbox.
     905              :     // The after-image is still recoverable; the before-image is not, and a
     906              :     // guess in its place is a notification that reports a change that did not
     907              :     // happen.
     908            6 :     let after = match ev.payload.clone() {
     909            0 :         Some(v) => Some(v),
     910            6 :         None => match ev.payload_ref.as_ref() {
     911            6 :             Some(r) => st
     912            6 :                 .store
     913            6 :                 .get(&ev.tenant, Kind::Entity, r.entity_id.as_str())
     914            6 :                 .await
     915            6 :                 .ok()
     916            6 :                 .flatten(),
     917            0 :             None => None,
     918              :         },
     919              :     };
     920            6 :     if ev.prev_payload_ref.is_some() {
     921            0 :         metrics::counter!("antares_claim_check_unresolved_total").increment(1);
     922            0 :         tracing::warn!(
     923              :             "claim-check row for seq {} is gone: the change to {} notifies nobody",
     924              :             ev.seq,
     925            0 :             ev.entity_id.as_str()
     926              :         );
     927            6 :     }
     928            6 :     (ev.prev_payload.clone(), after)
     929           12 : }
     930              : 
     931              : #[cfg(test)]
     932              : mod tests {
     933              :     use super::*;
     934              :     use antares_api::mirror::DocMirror;
     935              :     use antares_bus::{ChangeOp, PayloadRef};
     936              :     use antares_model::EntityId;
     937              :     use serde_json::json;
     938              : 
     939              :     /// The matcher durable drops a change after MAX_DELIVER deliveries: the
     940              :     /// last one is reported, the ones before it are not.
     941              :     #[test]
     942            2 :     fn only_the_final_delivery_of_a_change_is_reported() {
     943            2 :         assert!(!note_final_delivery("antares.changes.t", 7, 1));
     944            2 :         assert!(!note_final_delivery(
     945            2 :             "antares.changes.t",
     946            2 :             7,
     947            2 :             nats::MAX_DELIVER - 1
     948            2 :         ));
     949            2 :         assert!(note_final_delivery(
     950            2 :             "antares.changes.t",
     951              :             7,
     952              :             nats::MAX_DELIVER
     953              :         ));
     954            2 :     }
     955              : 
     956              :     #[test]
     957            2 :     fn roles_parse_accepts_the_role_set_and_refuses_anything_else() {
     958            2 :         assert!(Roles::parse("all").expect("all").all());
     959            2 :         let r = Roles::parse("api").expect("api");
     960            2 :         assert!(r.api && !r.matcher && !r.notifier && !r.temporal && !r.registry);
     961            2 :         assert!(
     962            2 :             !r.all(),
     963              :             "a single role must never claim to be the full set"
     964              :         );
     965            2 :         let r = Roles::parse(" matcher , notifier ").expect("padded list");
     966            2 :         assert!(r.matcher && r.notifier && !r.api);
     967              :         // The enumerated full set is the same thing as "all" — a role split
     968              :         // that happens to name every role must still pass the bus=local gate.
     969            2 :         assert!(Roles::parse("api,matcher,notifier,temporal,registry")
     970            2 :             .expect("full list")
     971            2 :             .all());
     972              : 
     973           14 :         for bad in ["", "api,", "ALL", "apis", "api;matcher", "worker", " "] {
     974           14 :             let err = Roles::parse(bad).expect_err(&format!("ANTARES_ROLES={bad:?} must be fatal"));
     975           14 :             assert!(err.starts_with("unknown role"), "{bad:?}: {err}");
     976              :         }
     977            2 :     }
     978              : 
     979              :     /// The KV key must be legal for a NATS KV bucket (`:` from a URN is not),
     980              :     /// stable across calls, and collision-free per id, per tenant and per
     981              :     /// kind — 5.5.10 leaves the id to the client, so one URN can name both a
     982              :     /// Subscription and a Context Source Registration Subscription.
     983              :     #[test]
     984            2 :     fn kv_key_is_bucket_legal_and_stable() {
     985            2 :         let sub = Kind::Subscription;
     986            2 :         let k = kv_key("default", sub, "urn:ngsi-ld:Subscription:1");
     987            2 :         assert_eq!(
     988              :             k,
     989            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
     990              :             "stable"
     991              :         );
     992            2 :         assert!(
     993            2 :             k.bytes()
     994           48 :                 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-'),
     995              :             "illegal KV key character in {k:?}"
     996              :         );
     997            2 :         assert!(k.starts_with("default."), "tenant scoping lost: {k}");
     998            2 :         assert_ne!(
     999            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
    1000            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:2")
    1001              :         );
    1002            2 :         assert_ne!(
    1003            2 :             kv_key("t1", sub, "urn:ngsi-ld:Subscription:1"),
    1004            2 :             kv_key("t2", sub, "urn:ngsi-ld:Subscription:1")
    1005              :         );
    1006            2 :         assert_ne!(
    1007            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
    1008            2 :             kv_key(
    1009            2 :                 "default",
    1010            2 :                 Kind::CSourceSubscription,
    1011            2 :                 "urn:ngsi-ld:Subscription:1"
    1012              :             ),
    1013              :             "one id naming both kinds must not share a mirror entry"
    1014              :         );
    1015            2 :     }
    1016              : 
    1017              :     /// A Context Source Registration Subscription delta carries no document:
    1018              :     /// it wakes the interval sweep and touches nothing the matcher reads.
    1019              :     #[test]
    1020            2 :     fn a_csub_delta_only_wakes_the_sweep() {
    1021            2 :         let m = antares_api::mirror::SubMirror::default();
    1022            2 :         apply_delta(
    1023            2 :             &m,
    1024            2 :             &serde_json::json!({
    1025            2 :                 "tenant": "default", "id": "urn:ngsi-ld:CSourceSubscription:1",
    1026            2 :                 "doc": {"id": "urn:ngsi-ld:CSourceSubscription:1", "timeInterval": 5},
    1027            2 :                 "csub": true,
    1028            2 :             }),
    1029              :         );
    1030            2 :         assert!(
    1031            2 :             m.docs("default").is_empty(),
    1032              :             "a csource subscription must not enter the candidate index"
    1033              :         );
    1034            2 :     }
    1035              : 
    1036              :     /// The KV/registry mirrors are fed from the bus. A malformed or hostile
    1037              :     /// delta must be dropped — never panic a consumer task, and never grow
    1038              :     /// the mirror under a key no request can ever address (the tenant is
    1039              :     /// validated on every request, so an unvalidated one is pure ballast).
    1040              :     #[test]
    1041            2 :     fn apply_delta_ignores_malformed_and_hostile_deltas() {
    1042            2 :         let m = DocMirror::default();
    1043            2 :         apply_delta(
    1044            2 :             &m,
    1045            2 :             &json!({"tenant": "default", "id": "urn:x:1", "doc": {"id": "urn:x:1"}}),
    1046              :         );
    1047            2 :         assert_eq!(m.docs("default").len(), 1, "a good delta must apply");
    1048              : 
    1049              :         // tombstone
    1050            2 :         apply_delta(
    1051            2 :             &m,
    1052            2 :             &json!({"tenant": "default", "id": "urn:x:1", "doc": null}),
    1053              :         );
    1054            2 :         assert!(m.docs("default").is_empty(), "null doc must delete");
    1055            2 :         assert!(m.tenants().is_empty(), "an emptied tenant must not linger");
    1056              : 
    1057              :         // Shapes that must be ignored without panicking.
    1058           20 :         for junk in [
    1059            2 :             json!({}),
    1060            2 :             json!(null),
    1061            2 :             json!(42),
    1062            2 :             json!("scalar"),
    1063            2 :             json!([1, 2, 3]),
    1064            2 :             json!({"tenant": "default"}),
    1065            2 :             json!({"id": "urn:x:1"}),
    1066            2 :             json!({"tenant": 7, "id": "urn:x:1", "doc": {}}),
    1067            2 :             json!({"tenant": "default", "id": null, "doc": {}}),
    1068            2 :             json!({"cooldownReg": "urn:reg:1", "ok": false}),
    1069           20 :         ] {
    1070           20 :             apply_delta(&m, &junk);
    1071           20 :         }
    1072            2 :         assert!(m.tenants().is_empty(), "junk deltas grew the mirror");
    1073              : 
    1074              :         // Hostile tenants: not addressable by any request (the header is
    1075              :         // validated to [A-Za-z0-9_-]{1,64}), so they may not take memory.
    1076            8 :         for hostile in [
    1077            2 :             "a".repeat(4096),
    1078            2 :             "../../etc".into(),
    1079            2 :             "a b".into(),
    1080            2 :             "".into(),
    1081            8 :         ] {
    1082            8 :             apply_delta(
    1083            8 :                 &m,
    1084            8 :                 &json!({"tenant": hostile, "id": "urn:x:1", "doc": {"id": "urn:x:1"}}),
    1085            8 :             );
    1086            8 :         }
    1087            2 :         assert!(
    1088            2 :             m.tenants().is_empty(),
    1089              :             "an unaddressable tenant grew the mirror without bound: {:?}",
    1090            0 :             m.tenants()
    1091              :         );
    1092            2 :     }
    1093              : 
    1094            6 :     async fn state_with(entity: Option<serde_json::Value>) -> AppState {
    1095            6 :         let st = AppState::new("antares".into());
    1096            6 :         if let Some(doc) = entity {
    1097            4 :             let id = doc["id"].as_str().expect("id").to_owned();
    1098            4 :             st.store
    1099            4 :                 .create(&TenantId::default(), Kind::Entity, &id, doc)
    1100            4 :                 .await
    1101            4 :                 .expect("seed");
    1102            2 :         }
    1103            6 :         st
    1104            6 :     }
    1105              : 
    1106           10 :     fn event(payload: Option<serde_json::Value>, r#ref: Option<PayloadRef>) -> ChangeEvent {
    1107           10 :         ChangeEvent {
    1108           10 :             tenant: TenantId::default(),
    1109           10 :             entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
    1110           10 :             types: vec!["T".into()],
    1111           10 :             op: ChangeOp::Update,
    1112           10 :             changed_attrs: vec![],
    1113           10 :             payload,
    1114           10 :             prev_payload: None,
    1115           10 :             version: 1,
    1116           10 :             incarnation: String::new(),
    1117           10 :             seq: 0,
    1118           10 :             payload_ref: r#ref,
    1119           10 :             prev_payload_ref: None,
    1120           10 :         }
    1121           10 :     }
    1122              : 
    1123              :     /// Claim-check resolution: inline wins, a reference is fetched, and a
    1124              :     /// reference to a row that is gone resolves to None instead of panicking
    1125              :     /// the matcher task.
    1126              :     #[tokio::test]
    1127            2 :     async fn resolve_payloads_prefers_inline_and_tolerates_a_dangling_reference() {
    1128            2 :         let doc = json!({"id": "urn:ngsi-ld:T:1", "type": "T"});
    1129            2 :         let st = state_with(Some(doc.clone())).await;
    1130              : 
    1131            2 :         let (before, after) =
    1132            2 :             resolve_payloads(&st, &event(Some(json!({"inline": true})), None)).await;
    1133            2 :         assert_eq!(after, Some(json!({"inline": true})), "inline payload wins");
    1134            2 :         assert_eq!(before, None);
    1135              : 
    1136            2 :         let r = PayloadRef {
    1137            2 :             entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
    1138            2 :             version: 1,
    1139            2 :         };
    1140            2 :         let (_, after) = resolve_payloads(&st, &event(None, Some(r.clone()))).await;
    1141            2 :         assert_eq!(
    1142            2 :             after.as_ref().and_then(|a| a["id"].as_str()),
    1143              :             Some("urn:ngsi-ld:T:1"),
    1144              :             "a claim-check reference must be fetched from the store"
    1145              :         );
    1146              : 
    1147              :         // The row was deleted between publish and consumption.
    1148            2 :         let gone = state_with(None).await;
    1149            2 :         let (before, after) = resolve_payloads(&gone, &event(None, Some(r))).await;
    1150            2 :         assert_eq!(after, None, "a dangling reference must resolve to None");
    1151            2 :         assert_eq!(before, None);
    1152              : 
    1153            2 :         let (before, after) = resolve_payloads(&st, &event(None, None)).await;
    1154            2 :         assert!(before.is_none() && after.is_none(), "no payload, no fetch");
    1155            2 :     }
    1156              : 
    1157              :     /// A claim-check fetch is scoped to the EVENT's tenant: a reference must
    1158              :     /// never resolve against another tenant's row of the same id.
    1159              :     #[tokio::test]
    1160            2 :     async fn resolve_payloads_never_crosses_a_tenant_boundary() {
    1161            2 :         let st = state_with(Some(json!({"id": "urn:ngsi-ld:T:1", "type": "T"}))).await;
    1162            2 :         let mut ev = event(
    1163            2 :             None,
    1164            2 :             Some(PayloadRef {
    1165            2 :                 entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
    1166            2 :                 version: 1,
    1167            2 :             }),
    1168              :         );
    1169            2 :         ev.tenant = TenantId::new("other").expect("tenant");
    1170            2 :         let (_, after) = resolve_payloads(&st, &ev).await;
    1171            2 :         assert_eq!(
    1172            2 :             after, None,
    1173            2 :             "a reference resolved another tenant's entity: {after:?}"
    1174            2 :         );
    1175            2 :     }
    1176              : 
    1177              :     /// A change whose bodies were both too big for the bus reaches the
    1178              :     /// matcher with the before-image the write actually replaced. Resolving
    1179              :     /// the reference against the store's current row instead hands back the
    1180              :     /// after-image twice, `diff` finds nothing and the change notifies
    1181              :     /// nobody. Skips without ANTARES_TEST_DATABASE_URL — the outbox is a
    1182              :     /// Postgres table, and the memory arm has none.
    1183              :     #[tokio::test(flavor = "multi_thread")]
    1184            2 :     async fn a_retained_row_gives_the_matcher_the_before_image_the_write_replaced() {
    1185            2 :         let Ok(url) = std::env::var("ANTARES_TEST_DATABASE_URL") else {
    1186            0 :             eprintln!("SKIP: ANTARES_TEST_DATABASE_URL not set");
    1187            0 :             return;
    1188              :         };
    1189            2 :         let pool = antares_sql::store::pg::connect(&url, 5)
    1190            2 :             .await
    1191            2 :             .expect("connect");
    1192            2 :         let tenant = TenantId::new("claimcheck").expect("tenant");
    1193            2 :         antares_sql::store::pg::ensure_tenant(&pool, &tenant)
    1194            2 :             .await
    1195            2 :             .expect("tenant row");
    1196              : 
    1197            2 :         let id = "urn:ngsi-ld:T:oversized";
    1198            4 :         let wide = |v: &str| {
    1199            4 :             json!({"id": id, "type": ["T"],
    1200            4 :                    "https://uri.etsi.org/ngsi-ld/default-context/note":
    1201            4 :                        [{"type": "Property", "value": format!("{v}{}", "x".repeat(300 * 1024))}]})
    1202            4 :         };
    1203            2 :         let before_doc = wide("before-");
    1204            2 :         let after_doc = wide("after-");
    1205              : 
    1206            2 :         let mut ev = ChangeEvent {
    1207            2 :             tenant: tenant.clone(),
    1208            2 :             entity_id: EntityId::new(id).expect("id"),
    1209            2 :             types: vec!["T".into()],
    1210            2 :             op: ChangeOp::Update,
    1211            2 :             changed_attrs: vec![],
    1212            2 :             payload: Some(after_doc.clone()),
    1213            2 :             prev_payload: Some(before_doc.clone()),
    1214            2 :             version: 2,
    1215            2 :             incarnation: String::new(),
    1216            2 :             seq: 0,
    1217            2 :             payload_ref: None,
    1218            2 :             prev_payload_ref: None,
    1219            2 :         };
    1220            2 :         let mut tx = pool.begin().await.expect("tx");
    1221            2 :         antares_sql::store::pg::set_tenant(&mut tx, &tenant)
    1222            2 :             .await
    1223            2 :             .expect("set tenant");
    1224            2 :         let seq = antares_sql::store::pg::outbox::enqueue(
    1225            2 :             &mut tx,
    1226            2 :             &tenant,
    1227            2 :             &serde_json::to_value(&ev).expect("event json"),
    1228            2 :         )
    1229            2 :         .await
    1230            2 :         .expect("enqueue");
    1231            2 :         tx.commit().await.expect("commit");
    1232            2 :         antares_sql::store::pg::outbox::retain(&pool, &tenant, &[seq])
    1233            2 :             .await
    1234            2 :             .expect("retain");
    1235              : 
    1236            2 :         let st = AppState::with_store(
    1237            2 :             "antares".into(),
    1238            2 :             std::sync::Arc::new(antares_sql::store::any::AnyStore::Pg(
    1239            2 :                 antares_sql::store::any::PgBackend::new(pool.clone()),
    1240            2 :             )),
    1241            2 :             "postgres",
    1242              :         );
    1243              :         // The current row is the AFTER image — the document the old
    1244              :         // resolution handed back for both halves.
    1245            2 :         let _ = st.store.delete(&tenant, Kind::Entity, id).await;
    1246            2 :         st.store
    1247            2 :             .create(&tenant, Kind::Entity, id, after_doc.clone())
    1248            2 :             .await
    1249            2 :             .expect("seed current row");
    1250              : 
    1251            2 :         ev.seq = seq;
    1252            2 :         let wire = ev.claim_check(antares_bus::CLAIM_CHECK_BYTES);
    1253            2 :         assert!(
    1254            2 :             wire.prev_payload_ref.is_some() && wire.payload_ref.is_some(),
    1255              :             "the fixture must be over the claim-check ceiling"
    1256              :         );
    1257            2 :         let (before, after) = resolve_payloads(&st, &wire).await;
    1258            2 :         assert_eq!(before.as_ref(), Some(&before_doc), "before-image lost");
    1259            2 :         assert_eq!(after.as_ref(), Some(&after_doc));
    1260            2 :         assert_ne!(
    1261              :             before, after,
    1262              :             "both halves resolved to the current row: the change notifies nobody"
    1263              :         );
    1264              : 
    1265            2 :         let _ = st.store.delete(&tenant, Kind::Entity, id).await;
    1266            2 :         let _ = antares_sql::store::pg::outbox::reap_published(&pool, 0).await;
    1267            2 :     }
    1268              : 
    1269              :     /// The outbox-drain switch is a config value like any other: the two
    1270              :     /// documented spellings decide, anything else is fatal instead of
    1271              :     /// silently leaving the drain on (a typo'd `ANTARES_OUTBOX_DRAIN=of`
    1272              :     /// would otherwise read as "on" and quietly defeat the crash drill).
    1273              :     #[test]
    1274            2 :     fn outbox_drain_switch_is_total() {
    1275            2 :         std::env::remove_var("ANTARES_OUTBOX_DRAIN");
    1276            2 :         assert!(outbox_drain_enabled().expect("default"), "default is on");
    1277            2 :         std::env::set_var("ANTARES_OUTBOX_DRAIN", "off");
    1278            2 :         assert!(!outbox_drain_enabled().expect("off"));
    1279            2 :         std::env::set_var("ANTARES_OUTBOX_DRAIN", "on");
    1280            2 :         assert!(outbox_drain_enabled().expect("on"));
    1281           12 :         for bad in ["", "of", "false", "0", "OFF", "no"] {
    1282           12 :             std::env::set_var("ANTARES_OUTBOX_DRAIN", bad);
    1283           12 :             let err = outbox_drain_enabled()
    1284           12 :                 .expect_err(&format!("ANTARES_OUTBOX_DRAIN={bad:?} must be fatal"));
    1285           12 :             assert!(err.contains("ANTARES_OUTBOX_DRAIN"), "{err}");
    1286              :         }
    1287            2 :         std::env::remove_var("ANTARES_OUTBOX_DRAIN");
    1288            2 :     }
    1289              : 
    1290              :     /// Live NATS for the mirror-sync tests; they skip without it.
    1291           12 :     fn nats_url() -> Option<String> {
    1292           12 :         let url = std::env::var("ANTARES_TEST_NATS_URL").ok();
    1293           12 :         if url.is_none() {
    1294           12 :             eprintln!("SKIP: ANTARES_TEST_NATS_URL not set");
    1295           12 :         }
    1296           12 :         url
    1297           12 :     }
    1298              : 
    1299            0 :     async fn wait_until<F: std::future::Future<Output = bool>>(
    1300            0 :         what: &str,
    1301            0 :         mut f: impl FnMut() -> F,
    1302            0 :     ) {
    1303            0 :         let rounds = 100 * antares_api::state::slow_factor();
    1304            0 :         for _ in 0..rounds {
    1305            0 :             if f().await {
    1306            0 :                 return;
    1307            0 :             }
    1308            0 :             tokio::time::sleep(std::time::Duration::from_millis(100)).await;
    1309              :         }
    1310            0 :         panic!("timed out waiting for {what}");
    1311            0 :     }
    1312              : 
    1313              :     /// The registry broadcast consumer is ephemeral: a NATS restart or an
    1314              :     /// inactivity gap deletes it on the server. The watcher has to come back
    1315              :     /// on a NEW consumer; pulling again on the deleted one never delivers,
    1316              :     /// so every later delta (a deleted registration included) is lost to
    1317              :     /// this pod's mirror for the life of the process.
    1318              :     #[tokio::test(flavor = "multi_thread")]
    1319            2 :     async fn the_registry_watcher_survives_its_consumer_being_deleted() {
    1320            2 :         let Some(url) = nats_url() else { return };
    1321            0 :         let bus = Arc::new(NatsBus::connect(&url).await.expect("connect"));
    1322            0 :         let st = AppState::new("antares".into());
    1323            0 :         let mirror = Arc::new(DocMirror::default());
    1324            0 :         let consumer = bus.consume_registry_broadcast().await.expect("consumer");
    1325            0 :         let name = consumer.cached_info().name.clone();
    1326            0 :         let watcher = tokio::spawn(watch_registry(
    1327            0 :             bus.clone(),
    1328            0 :             consumer,
    1329            0 :             mirror.clone(),
    1330            0 :             st.store.clone(),
    1331            0 :             st.egress.clone(),
    1332              :         ));
    1333            0 :         let tenant = format!("regwatch{}", std::process::id());
    1334            0 :         let delta = |id: &str| json!({"tenant": tenant, "id": id, "doc": {"id": id, "type": "ContextSourceRegistration"}});
    1335            0 :         let held = |id: &'static str| {
    1336            0 :             let (m, t) = (mirror.clone(), tenant.clone());
    1337            0 :             move || {
    1338            0 :                 let (m, t) = (m.clone(), t.clone());
    1339            0 :                 async move { m.docs(&t).iter().any(|d| d["id"] == id) }
    1340            0 :             }
    1341            0 :         };
    1342            0 :         bus.publish_registry(&tenant, &delta("urn:reg:before"))
    1343            0 :             .await
    1344            0 :             .expect("publish");
    1345            0 :         wait_until("the delta before the gap", held("urn:reg:before")).await;
    1346              : 
    1347            0 :         bus.delete_registry_consumer(&name).await.expect("delete");
    1348              :         // the delta keeps being published until a consumer exists to see it:
    1349              :         // a fresh consumer starts at NEW
    1350            0 :         let deadline = std::time::Instant::now()
    1351            0 :             + std::time::Duration::from_secs(20 * antares_api::state::slow_factor());
    1352            2 :         loop {
    1353            2 :             bus.publish_registry(&tenant, &delta("urn:reg:after"))
    1354            0 :                 .await
    1355            2 :                 .expect("publish");
    1356            2 :             if held("urn:reg:after")().await {
    1357            2 :                 break;
    1358            2 :             }
    1359            2 :             assert!(
    1360            2 :                 std::time::Instant::now() < deadline,
    1361            2 :                 "no delta reached the mirror after its consumer was deleted"
    1362            2 :             );
    1363            2 :             tokio::time::sleep(std::time::Duration::from_millis(500)).await;
    1364            2 :         }
    1365            2 :         watcher.abort();
    1366            2 :     }
    1367              : 
    1368              :     /// A gap in the registry deltas can swallow a deletion: the re-hydrate
    1369              :     /// that ends it must drop the registration as well as add the ones it
    1370              :     /// missed, or federation keeps forwarding to a deleted Context Source.
    1371              :     #[tokio::test]
    1372            2 :     async fn a_rehydrate_drops_a_registration_deleted_during_the_gap() {
    1373            2 :         let st = AppState::new("antares".into());
    1374            2 :         let tenant = antares_model::TenantId::new("rehydrategap").expect("tenant");
    1375            6 :         let reg = |id: &str| {
    1376            6 :             json!({"id": id, "type": "ContextSourceRegistration",
    1377            6 :                    "information": [{"entities": [{"type": "Vehicle"}]}],
    1378            6 :                    "endpoint": "http://127.0.0.1:9"})
    1379            6 :         };
    1380            2 :         st.store
    1381            2 :             .create(
    1382            2 :                 &tenant,
    1383            2 :                 Kind::Registration,
    1384            2 :                 "urn:reg:kept",
    1385            2 :                 reg("urn:reg:kept"),
    1386            2 :             )
    1387            2 :             .await
    1388            2 :             .expect("kept");
    1389            2 :         let mirror = DocMirror::default();
    1390            2 :         mirror.apply(tenant.as_str(), "urn:reg:kept", Some(reg("urn:reg:kept")));
    1391              :         // deleted from the store while no delta reached this pod
    1392            2 :         mirror.apply(tenant.as_str(), "urn:reg:gone", Some(reg("urn:reg:gone")));
    1393            2 :         rehydrate_registrations(&*st.store, &mirror)
    1394            2 :             .await
    1395            2 :             .expect("rehydrate");
    1396            2 :         let ids: Vec<serde_json::Value> = mirror
    1397            2 :             .docs(tenant.as_str())
    1398            2 :             .iter()
    1399            2 :             .map(|d| d["id"].clone())
    1400            2 :             .collect();
    1401            2 :         assert_eq!(ids, vec![json!("urn:reg:kept")]);
    1402            2 :     }
    1403              : 
    1404              :     /// The KV holds what the store holds. Each write's sync runs as its own
    1405              :     /// task with its own retries, so one that lands late (a retried put, or
    1406              :     /// a task scheduled after a newer write's) used to overwrite the newer
    1407              :     /// value with the document it captured — and the KV is what every
    1408              :     /// matcher pod replays, after its store hydrate, on every (re)start.
    1409              :     #[tokio::test(flavor = "multi_thread")]
    1410            2 :     async fn a_late_subscription_sync_never_overwrites_a_newer_write() {
    1411              :         use antares_store::CurrentStateDriverExt;
    1412            2 :         let Some(url) = nats_url() else { return };
    1413            0 :         let mut st = AppState::new("antares".into());
    1414            0 :         let _bus = wire_nats(&mut st, &url, Roles::parse("api").expect("roles"), None)
    1415            0 :             .await
    1416            0 :             .expect("wire");
    1417            0 :         let tenant = TenantId::default();
    1418            0 :         let id = format!("urn:ngsi-ld:Subscription:late{}", std::process::id());
    1419            0 :         let doc = |v: i64| {
    1420            0 :             json!({"id": id, "type": "Subscription", "v": v,
    1421            0 :             "entities": [{"type": "https://x/T"}]})
    1422            0 :         };
    1423            0 :         st.store
    1424            0 :             .create(&tenant, Kind::Subscription, &id, doc(1))
    1425            0 :             .await
    1426            0 :             .expect("create");
    1427            0 :         st.store
    1428            0 :             .mutate::<(), ()>(&tenant, Kind::Subscription, &id, |d| {
    1429            0 :                 *d = doc(2);
    1430            0 :                 Ok(())
    1431            0 :             })
    1432            0 :             .await
    1433            0 :             .expect("update");
    1434            0 :         let sync = st.sub_sync.clone().expect("api role installs the sync");
    1435            0 :         let kv = NatsBus::connect(&url)
    1436            0 :             .await
    1437            0 :             .expect("connect")
    1438            0 :             .subs_kv()
    1439            0 :             .await
    1440            0 :             .expect("kv");
    1441            0 :         let key = kv_key(tenant.as_str(), Kind::Subscription, &id);
    1442              :         // the subscription's `v` as the KV holds it
    1443              :         macro_rules! kv_v {
    1444              :             () => {{
    1445              :                 let (kv, key) = (kv.clone(), key.clone());
    1446            0 :                 async move {
    1447            0 :                     kv.get(key)
    1448            0 :                         .await
    1449            0 :                         .ok()
    1450            0 :                         .flatten()
    1451            0 :                         .and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
    1452            0 :                         .and_then(|v| v["doc"]["v"].as_i64())
    1453            0 :                 }
    1454              :             }};
    1455              :         }
    1456              :         // the newer write's sync lands first ...
    1457            0 :         sync(&tenant, Kind::Subscription, &id, Some(&doc(2)));
    1458            0 :         wait_until("the newer write in the KV", || {
    1459            0 :             let v = kv_v!();
    1460            0 :             async move { v.await == Some(2) }
    1461            0 :         })
    1462            0 :         .await;
    1463              :         // ... and the older write's sync after it
    1464            0 :         sync(&tenant, Kind::Subscription, &id, Some(&doc(1)));
    1465            0 :         tokio::time::sleep(std::time::Duration::from_millis(
    1466            0 :             1_000 * antares_api::state::slow_factor(),
    1467            0 :         ))
    1468            0 :         .await;
    1469            0 :         assert_eq!(
    1470            2 :             kv_v!().await,
    1471            2 :             Some(2),
    1472            2 :             "the KV went back to the document an older write captured"
    1473            2 :         );
    1474            2 :     }
    1475              : 
    1476              :     /// 32 zero bytes in base64: a test key that is plainly not a secret.
    1477              :     const TEST_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
    1478              : 
    1479              :     /// With ANTARES_SECRET_KEY the subscription KV mirror holds what the
    1480              :     /// store's row holds: the receiverInfo values and a URI with userinfo
    1481              :     /// sealed, never the plaintext. The matcher side opens them into its
    1482              :     /// mirror; a pod without the key, or with another, drops the entry
    1483              :     /// instead of mirroring ciphertext it would send as headers.
    1484              :     #[tokio::test(flavor = "multi_thread")]
    1485            2 :     async fn the_subscription_kv_holds_the_credentials_sealed() {
    1486            2 :         let Some(url) = nats_url() else { return };
    1487            0 :         let mut st = AppState::new("antares".into());
    1488            0 :         let _bus = wire_nats(
    1489            0 :             &mut st,
    1490            0 :             &url,
    1491            0 :             Roles::parse("api").expect("roles"),
    1492            0 :             Some(SecretKey::from_base64(TEST_KEY).expect("key")),
    1493            0 :         )
    1494            0 :         .await
    1495            0 :         .expect("wire");
    1496            0 :         let tenant = TenantId::default();
    1497            0 :         let id = format!("urn:ngsi-ld:Subscription:sealedkv{}", std::process::id());
    1498            0 :         let doc = json!({"id": id, "type": "Subscription",
    1499            0 :             "entities": [{"type": "https://x/T"}],
    1500            0 :             "notification": {"endpoint": {
    1501            0 :                 "uri": "http://hook:pa55word@localhost:1/n",
    1502            0 :                 "receiverInfo": [{"key": "Authorization", "value": "Bearer kv-token"}]}}});
    1503            0 :         st.store
    1504            0 :             .create(&tenant, Kind::Subscription, &id, doc.clone())
    1505            0 :             .await
    1506            0 :             .expect("create");
    1507            0 :         let kv = NatsBus::connect(&url)
    1508            0 :             .await
    1509            0 :             .expect("connect")
    1510            0 :             .subs_kv()
    1511            0 :             .await
    1512            0 :             .expect("kv");
    1513            0 :         let key = kv_key(tenant.as_str(), Kind::Subscription, &id);
    1514            0 :         let sync = st.sub_sync.clone().expect("api role installs the sync");
    1515            0 :         sync(&tenant, Kind::Subscription, &id, Some(&doc));
    1516            0 :         wait_until("the subscription in the KV", || {
    1517            0 :             let (kv, key) = (kv.clone(), key.clone());
    1518            0 :             async move { kv.get(key).await.ok().flatten().is_some() }
    1519            0 :         })
    1520            0 :         .await;
    1521            0 :         let raw = kv.get(key.clone()).await.expect("get").expect("entry");
    1522            0 :         let text = String::from_utf8_lossy(&raw).into_owned();
    1523            0 :         assert!(
    1524            0 :             !text.contains("kv-token") && !text.contains("pa55word"),
    1525              :             "plaintext credential in the KV: {text}"
    1526              :         );
    1527            0 :         assert!(text.contains(seal::PREFIX), "{text}");
    1528              : 
    1529            0 :         let entry: serde_json::Value = serde_json::from_slice(&raw).expect("json");
    1530            0 :         let mirrored = |key: Option<&SecretKey>| {
    1531            0 :             let m = antares_api::mirror::SubMirror::default();
    1532            0 :             let mut v = entry.clone();
    1533            0 :             open_sub_delta(key, &mut v);
    1534            0 :             apply_delta(&m, &v);
    1535            0 :             m.docs(tenant.as_str())
    1536            0 :                 .into_iter()
    1537            0 :                 .map(|d| (*d).clone())
    1538            0 :                 .collect::<Vec<_>>()
    1539            0 :         };
    1540            0 :         let own = SecretKey::from_base64(TEST_KEY).expect("key");
    1541            0 :         let opened = mirrored(Some(&own));
    1542            0 :         assert_eq!(opened.len(), 1);
    1543            0 :         assert_eq!(
    1544            0 :             opened[0]["notification"]["endpoint"],
    1545            0 :             doc["notification"]["endpoint"]
    1546              :         );
    1547            0 :         let other =
    1548            0 :             SecretKey::from_base64("AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=").expect("key");
    1549            0 :         assert!(mirrored(None).is_empty(), "mirrored without the key");
    1550            0 :         assert!(
    1551            0 :             mirrored(Some(&other)).is_empty(),
    1552              :             "mirrored under another key"
    1553              :         );
    1554            2 :         let _ = kv.purge(key).await;
    1555            2 :     }
    1556              : 
    1557              :     /// A Context Source Registration Subscription's KV entry only wakes the
    1558              :     /// sweep (`apply_delta` reads no document from it), so it ships none:
    1559              :     /// neither the read nor the credentials it would carry.
    1560              :     #[tokio::test(flavor = "multi_thread")]
    1561            2 :     async fn a_csource_subscription_kv_entry_carries_no_document() {
    1562            2 :         let Some(url) = nats_url() else { return };
    1563            0 :         let mut st = AppState::new("antares".into());
    1564            0 :         let _bus = wire_nats(&mut st, &url, Roles::parse("api").expect("roles"), None)
    1565            0 :             .await
    1566            0 :             .expect("wire");
    1567            0 :         let tenant = TenantId::new("csubnodoc").expect("tenant");
    1568            0 :         let id = format!("urn:ngsi-ld:Subscription:csubnodoc{}", std::process::id());
    1569            0 :         let doc = json!({"id": id, "type": "ContextSourceRegistrationSubscription",
    1570            0 :             "entities": [{"type": "https://x/T"}],
    1571            0 :             "notification": {"endpoint": {
    1572            0 :                 "uri": "http://localhost:1/n",
    1573            0 :                 "receiverInfo": [{"key": "Authorization", "value": "Bearer test-token"}]}}});
    1574            0 :         st.store
    1575            0 :             .create(&tenant, Kind::CSourceSubscription, &id, doc.clone())
    1576            0 :             .await
    1577            0 :             .expect("create");
    1578            0 :         let kv = NatsBus::connect(&url)
    1579            0 :             .await
    1580            0 :             .expect("connect")
    1581            0 :             .subs_kv()
    1582            0 :             .await
    1583            0 :             .expect("kv");
    1584            0 :         let key = kv_key(tenant.as_str(), Kind::CSourceSubscription, &id);
    1585            0 :         let sync = st.sub_sync.clone().expect("api role installs the sync");
    1586            0 :         sync(&tenant, Kind::CSourceSubscription, &id, Some(&doc));
    1587            0 :         wait_until("the entry in the KV", || {
    1588            0 :             let (kv, key) = (kv.clone(), key.clone());
    1589            0 :             async move { kv.get(key).await.ok().flatten().is_some() }
    1590            0 :         })
    1591            0 :         .await;
    1592            0 :         let raw = kv.get(key.clone()).await.expect("get").expect("entry");
    1593            0 :         let _ = kv.purge(key).await;
    1594            0 :         let entry: serde_json::Value = serde_json::from_slice(&raw).expect("json");
    1595            0 :         assert_eq!(entry["csub"], true);
    1596            0 :         assert!(entry["doc"].is_null(), "a document was shipped");
    1597            2 :     }
    1598              : 
    1599              :     /// An entry put before ANTARES_SECRET_KEY was set holds its credentials
    1600              :     /// in plaintext; the boot pass seals it from the store's row, and the
    1601              :     /// entry still opens into the same document.
    1602              :     #[tokio::test(flavor = "multi_thread")]
    1603            2 :     async fn the_boot_pass_seals_a_plaintext_subscription_kv_entry() {
    1604            2 :         let Some(url) = nats_url() else { return };
    1605            0 :         let st = AppState::new("antares".into());
    1606            0 :         let tenant = TenantId::new("sealbootpass").expect("tenant");
    1607            0 :         let id = format!("urn:ngsi-ld:Subscription:sealboot{}", std::process::id());
    1608            0 :         let doc = json!({"id": id, "type": "Subscription",
    1609            0 :             "entities": [{"type": "https://x/T"}],
    1610            0 :             "notification": {"endpoint": {
    1611            0 :                 "uri": "http://hook:pa55word@localhost:1/n",
    1612            0 :                 "receiverInfo": [{"key": "Authorization", "value": "Bearer test-token"}]}}});
    1613            0 :         st.store
    1614            0 :             .create(&tenant, Kind::Subscription, &id, doc.clone())
    1615            0 :             .await
    1616            0 :             .expect("create");
    1617            0 :         let kv = NatsBus::connect(&url)
    1618            0 :             .await
    1619            0 :             .expect("connect")
    1620            0 :             .subs_kv()
    1621            0 :             .await
    1622            0 :             .expect("kv");
    1623            0 :         let key = kv_key(tenant.as_str(), Kind::Subscription, &id);
    1624            0 :         let plain = json!({"tenant": tenant.as_str(), "id": id, "doc": doc, "csub": false});
    1625            0 :         kv.put(
    1626            0 :             key.clone(),
    1627            0 :             serde_json::to_vec(&plain).expect("json").into(),
    1628            0 :         )
    1629            0 :         .await
    1630            0 :         .expect("put");
    1631            0 :         let secret = SecretKey::from_base64(TEST_KEY).expect("key");
    1632            0 :         let n = seal_kv_entries(&kv, &*st.store, &secret, vec![key.clone()]).await;
    1633            0 :         let raw = kv.get(key.clone()).await.expect("get").expect("entry");
    1634            0 :         let _ = kv.purge(key).await;
    1635            0 :         assert_eq!(n, 1);
    1636            0 :         let text = String::from_utf8_lossy(&raw).into_owned();
    1637            0 :         assert!(
    1638            0 :             !text.contains("test-token") && !text.contains("pa55word"),
    1639              :             "plaintext credential left in the KV entry"
    1640              :         );
    1641            0 :         let mut entry: serde_json::Value = serde_json::from_slice(&raw).expect("json");
    1642            0 :         open_sub_delta(Some(&secret), &mut entry);
    1643            0 :         assert_eq!(
    1644            2 :             entry["doc"]["notification"]["endpoint"],
    1645            2 :             doc["notification"]["endpoint"]
    1646            2 :         );
    1647            2 :     }
    1648              : 
    1649              :     /// The registration mirror converges on what the store holds. Each
    1650              :     /// write's delta was its own task publishing the document captured at
    1651              :     /// write time, so one that landed late (a retried publish, or a task
    1652              :     /// scheduled after a newer write's) left every api pod's mirror holding
    1653              :     /// the older registration, and the federation path reads that mirror as
    1654              :     /// the truth until a restart re-hydrates it.
    1655              :     #[tokio::test(flavor = "multi_thread")]
    1656            2 :     async fn a_late_registration_sync_never_overwrites_a_newer_write() {
    1657              :         use antares_store::CurrentStateDriverExt;
    1658            2 :         let Some(url) = nats_url() else { return };
    1659            0 :         let mut st = AppState::new("antares".into());
    1660            0 :         let bus = wire_nats(&mut st, &url, Roles::parse("api").expect("roles"), None)
    1661            0 :             .await
    1662            0 :             .expect("wire");
    1663            0 :         let tenant = TenantId::new(&format!("reglate{}", std::process::id())).expect("tenant");
    1664            0 :         let id = format!(
    1665              :             "urn:ngsi-ld:ContextSourceRegistration:late{}",
    1666            0 :             std::process::id()
    1667              :         );
    1668            0 :         let doc = |v: i64| json!({"id": id, "type": "ContextSourceRegistration", "v": v});
    1669            0 :         st.store
    1670            0 :             .create(&tenant, Kind::Registration, &id, doc(1))
    1671            0 :             .await
    1672            0 :             .expect("create");
    1673            0 :         st.store
    1674            0 :             .mutate::<(), ()>(&tenant, Kind::Registration, &id, |d| {
    1675            0 :                 *d = doc(2);
    1676            0 :                 Ok(())
    1677            0 :             })
    1678            0 :             .await
    1679            0 :             .expect("update");
    1680              :         // a second pod's mirror, fed from the stream
    1681            0 :         let mirror = Arc::new(DocMirror::default());
    1682            0 :         let watcher = tokio::spawn(watch_registry(
    1683            0 :             bus.clone(),
    1684            0 :             bus.consume_registry_broadcast().await.expect("consumer"),
    1685            0 :             mirror.clone(),
    1686            0 :             st.store.clone(),
    1687            0 :             st.egress.clone(),
    1688              :         ));
    1689            0 :         let held = || {
    1690            0 :             let (m, t) = (mirror.clone(), tenant.as_str().to_owned());
    1691            0 :             async move {
    1692            0 :                 m.docs(&t)
    1693            0 :                     .iter()
    1694            0 :                     .find_map(|d| d.get("v").and_then(serde_json::Value::as_i64))
    1695            0 :             }
    1696            0 :         };
    1697            0 :         let sync = st.reg_sync.clone().expect("api role installs the sync");
    1698              :         // the newer write's delta lands first ...
    1699            0 :         sync(&tenant, &id, Some(&doc(2)));
    1700            0 :         wait_until("the newer write in the mirror", || {
    1701            0 :             let v = held();
    1702            0 :             async move { v.await == Some(2) }
    1703            0 :         })
    1704            0 :         .await;
    1705              :         // ... and the older write's delta after it
    1706            0 :         sync(&tenant, &id, Some(&doc(1)));
    1707            0 :         tokio::time::sleep(std::time::Duration::from_millis(
    1708            0 :             1_000 * antares_api::state::slow_factor(),
    1709            0 :         ))
    1710            0 :         .await;
    1711            0 :         assert_eq!(
    1712            0 :             held().await,
    1713              :             Some(2),
    1714              :             "the mirror went back to the registration an older write captured"
    1715              :         );
    1716            0 :         watcher.abort();
    1717            2 :     }
    1718              : }
        

Generated by: LCOV version 2.0-1