LCOV - code coverage report
Current view: top level - antares-broker/src - wiring.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 58.6 % 563 330
Test Date: 2026-09-21 10:31:06 Functions: 33.7 % 95 32

            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::Kind;
      31              : use futures_util::StreamExt;
      32              : use std::sync::Arc;
      33              : 
      34              : #[derive(Clone, Copy, Debug)]
      35              : pub struct Roles {
      36              :     pub api: bool,
      37              :     pub matcher: bool,
      38              :     pub notifier: bool,
      39              :     pub temporal: bool,
      40              :     pub registry: bool,
      41              : }
      42              : 
      43              : impl Roles {
      44           46 :     pub fn parse(spec: &str) -> Result<Self, String> {
      45           46 :         if spec == "all" {
      46           16 :             return Ok(Self {
      47           16 :                 api: true,
      48           16 :                 matcher: true,
      49           16 :                 notifier: true,
      50           16 :                 temporal: true,
      51           16 :                 registry: true,
      52           16 :             });
      53           30 :         }
      54           30 :         let mut r = Self {
      55           30 :             api: false,
      56           30 :             matcher: false,
      57           30 :             notifier: false,
      58           30 :             temporal: false,
      59           30 :             registry: false,
      60           30 :         };
      61           44 :         for part in spec.split(',') {
      62           44 :             match part.trim() {
      63           44 :                 "api" => r.api = true,
      64           36 :                 "matcher" => r.matcher = true,
      65           28 :                 "notifier" => r.notifier = true,
      66           22 :                 "temporal" => r.temporal = true,
      67           18 :                 "registry" => r.registry = true,
      68           16 :                 other => {
      69           16 :                     return Err(format!(
      70           16 :                         "unknown role {other:?} (api|matcher|notifier|temporal|registry|all)"
      71           16 :                     ))
      72              :                 }
      73              :             }
      74              :         }
      75           14 :         Ok(r)
      76           46 :     }
      77              : 
      78           26 :     pub fn all(&self) -> bool {
      79           26 :         self.api && self.matcher && self.notifier && self.temporal && self.registry
      80           26 :     }
      81              : }
      82              : 
      83              : /// ANTARES_OUTBOX_DRAIN: `on` (the default) or `off`, and nothing else. A
      84              : /// typo'd `of` read as "on" under the old permissive parse, quietly defeating
      85              : /// the dedicated-drainer split and the crash drill it exists for.
      86           69 : pub fn outbox_drain_enabled() -> Result<bool, String> {
      87           69 :     match std::env::var("ANTARES_OUTBOX_DRAIN") {
      88           51 :         Err(std::env::VarError::NotPresent) => Ok(true),
      89            0 :         Err(e) => Err(format!("ANTARES_OUTBOX_DRAIN is unreadable: {e}")),
      90           18 :         Ok(v) => match v.as_str() {
      91           18 :             "on" => Ok(true),
      92           16 :             "off" => Ok(false),
      93           14 :             other => Err(format!(
      94           14 :                 "ANTARES_OUTBOX_DRAIN must be on|off, got {other:?}"
      95           14 :             )),
      96              :         },
      97              :     }
      98           69 : }
      99              : 
     100              : /// KV key for one mirrored subscription: tenant verbatim (token-safe by
     101              : /// construction), id hashed (URNs carry `:` — illegal in KV keys). The VALUE
     102              : /// carries the real tenant/id, so the key only needs uniqueness. Kind-scoped:
     103              : /// a Subscription and a Context Source Registration Subscription may carry the
     104              : /// same client-chosen id in one tenant (5.5.10), and sharing a key would let
     105              : /// one overwrite the other's mirror entry.
     106           16 : fn kv_key(tenant: &str, kind: Kind, id: &str) -> String {
     107           16 :     format!(
     108              :         "{tenant}.{}{:016x}",
     109           16 :         if kind == Kind::CSourceSubscription {
     110            2 :             "c"
     111              :         } else {
     112           14 :             ""
     113              :         },
     114           16 :         antares_bus::subjects::fnv1a64(id.as_bytes())
     115              :     )
     116           16 : }
     117              : 
     118              : /// Wire everything bus=nats needs onto the state. Async: connects, hydrates
     119              : /// mirrors, creates consumers, asserts topology — all before the broker
     120              : /// starts accepting traffic, so a mis-shapen topology is a startup failure,
     121              : /// never a silent runtime drift.
     122            0 : pub async fn wire_nats(
     123            0 :     state: &mut AppState,
     124            0 :     url: &str,
     125            0 :     roles: Roles,
     126            0 : ) -> Result<(), Box<dyn std::error::Error>> {
     127            0 :     let bus = Arc::new(NatsBus::connect(url).await?);
     128              :     // /q/health `bus` member: live connection state + reconnect count.
     129              :     // Installed HERE so bus=local never carries the member at all.
     130              :     {
     131            0 :         let b = bus.clone();
     132            0 :         state.bus_stats = Some(Arc::new(move || {
     133            0 :             serde_json::json!({
     134            0 :                 "mode": "nats",
     135            0 :                 "connected": b.connected(),
     136            0 :                 "reconnects": b.reconnects(),
     137              :             })
     138            0 :         }));
     139              :     }
     140              :     // Multi-process mode: interval firings need the single-winner claim
     141              :     // — keyed off this flag, not off mirror presence (local mode wires a
     142              :     // mirror too).
     143            0 :     state.nats = true;
     144              :     // Entity writes now enqueue their events in the write transaction.
     145            0 :     state.store.set_outbox(true);
     146              :     // Auto-recording stays SYNCHRONOUS in the write path in every bus mode:
     147              :     // every write goes through an api-role pod that has the
     148              :     // shared store, so recording in-request gives read-your-writes — the
     149              :     // ETSI suite asserts history immediately after a write — and kills the
     150              :     // late-replay resurrection race (a consumer re-applying a pre-delete
     151              :     // event AFTER a direct temporal delete). The recorder consumer this
     152              :     // replaced double-applied by design; it bought nothing but the races.
     153              : 
     154              :     // The drain nudge: a same-process write pokes its own drain, so publish
     155              :     // latency is ~1 ms, not the idle-poll interval. Cross-pod writes are
     156              :     // still covered by each pod's own nudge; the poll below stays as the
     157              :     // crash-recovery fallback.
     158            0 :     let nudge = Arc::new(tokio::sync::Notify::new());
     159              :     {
     160            0 :         let n = nudge.clone();
     161              :         // Auto-recording rides the same synchronous hook here as in bus=local
     162              :         // (one choke point for every write, no handler can forget), then nudges
     163              :         // the outbox drain.
     164            0 :         let st_rec = state.clone();
     165            0 :         state.store.set_change_hook(Arc::new(
     166              :             move |tenant: &TenantId,
     167              :                   before: Option<serde_json::Value>,
     168              :                   after: Option<serde_json::Value>|
     169            0 :                   -> antares_store::HookFuture<'_> {
     170            0 :                 let st_rec = st_rec.clone();
     171            0 :                 let n = n.clone();
     172            0 :                 Box::pin(async move {
     173            0 :                     antares_api::notify::record_temporal_change(
     174            0 :                         &st_rec,
     175            0 :                         tenant,
     176            0 :                         before.as_ref(),
     177            0 :                         after.as_ref(),
     178            0 :                     )
     179            0 :                     .await;
     180            0 :                     n.notify_one();
     181            0 :                 })
     182            0 :             },
     183              :         ));
     184              :     }
     185              : 
     186            0 :     let mut durables: Vec<&'static str> = Vec::new();
     187              : 
     188            0 :     if roles.api {
     189              :         // Subscription write side: subscription CUD → KV (tombstone = null doc).
     190            0 :         let kv = bus.subs_kv().await?;
     191            0 :         let kv_for_hook = kv.clone();
     192            0 :         state.sub_sync = Some(Arc::new(
     193            0 :             move |tenant: &TenantId, kind: Kind, id: &str, doc| {
     194            0 :                 let kv = kv_for_hook.clone();
     195            0 :                 let key = kv_key(tenant.as_str(), kind, id);
     196            0 :                 let value = serde_json::json!({
     197            0 :                     "tenant": tenant.as_str(), "id": id, "doc": doc,
     198            0 :                     "csub": kind == Kind::CSourceSubscription,
     199              :                 });
     200            0 :                 tokio::spawn(async move {
     201            0 :                     let bytes = serde_json::to_vec(&value).unwrap_or_default();
     202              :                     // The store row is already committed. A lost put leaves this
     203              :                     // subscription invisible to every matcher pod — silently, and
     204              :                     // until the next restart, because mirrors hydrate from the
     205              :                     // store only at process start. So retry rather than warn once.
     206              :                     // Named ceiling: after the last attempt the divergence stands
     207              :                     // until a restart; closing that needs periodic reconciliation.
     208            0 :                     for attempt in 0..MIRROR_SYNC_ATTEMPTS {
     209            0 :                         match kv.put(key.clone(), bytes.clone().into()).await {
     210            0 :                             Ok(_) => return,
     211            0 :                             Err(e) => {
     212            0 :                                 tracing::warn!("sub KV sync attempt {} failed: {e}", attempt + 1);
     213            0 :                                 tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     214              :                             }
     215              :                         }
     216              :                     }
     217            0 :                     tracing::error!(
     218              :                         "sub KV sync gave up for {key} — this subscription is not mirrored \
     219              :                      until the next restart"
     220              :                     );
     221            0 :                 });
     222            0 :             },
     223              :         ));
     224              : 
     225              :         // Registration write side: registration CUD → ANTARES_REGISTRY delta.
     226            0 :         let bus_for_reg = bus.clone();
     227            0 :         state.reg_sync = Some(Arc::new(move |tenant: &TenantId, id: &str, doc| {
     228            0 :             let bus = bus_for_reg.clone();
     229            0 :             let delta = serde_json::json!({
     230            0 :                 "tenant": tenant.as_str(), "id": id, "doc": doc,
     231              :             });
     232            0 :             let tenant = tenant.as_str().to_owned();
     233            0 :             let id = id.to_owned();
     234            0 :             tokio::spawn(async move {
     235              :                 // Same contract as the subscription mirror: the row is
     236              :                 // committed, so a lost delta makes the registration invisible
     237              :                 // to every federation path until a restart re-hydrates.
     238            0 :                 for attempt in 0..MIRROR_SYNC_ATTEMPTS {
     239            0 :                     match bus.publish_registry(&tenant, &delta).await {
     240            0 :                         Ok(()) => return,
     241            0 :                         Err(e) => {
     242            0 :                             tracing::warn!(
     243              :                                 "registry delta publish attempt {} failed: {e}",
     244            0 :                                 attempt + 1
     245              :                             );
     246            0 :                             tokio::time::sleep(mirror_sync_backoff(attempt)).await;
     247              :                         }
     248              :                     }
     249              :                 }
     250            0 :                 tracing::error!(
     251              :                     "registry delta publish gave up for {id} — this registration is not \
     252              :                      mirrored until the next restart"
     253              :                 );
     254            0 :             });
     255            0 :         }));
     256              : 
     257              :         // 5.2.34 write side: a cooldown stamp is broadcast to the other api
     258              :         // pods on the registry stream (seconds-scale state, deliberately not
     259              :         // persisted) — per-process stamps re-dial a failed source from every
     260              :         // pod behind the LB.
     261            0 :         let bus_for_cool = bus.clone();
     262            0 :         state.reg_fail_sync = Some(Arc::new(move |reg_id: &str, ok: bool| {
     263            0 :             let bus = bus_for_cool.clone();
     264            0 :             let delta = serde_json::json!({"cooldownReg": reg_id, "ok": ok});
     265            0 :             tokio::spawn(async move {
     266            0 :                 if let Err(e) = bus.publish_registry("cooldown", &delta).await {
     267            0 :                     tracing::warn!("cooldown stamp publish failed: {e}");
     268            0 :                 }
     269            0 :             });
     270            0 :         }));
     271              : 
     272              :         // Registration read side: the ONE compiled registration mirror this instance's
     273              :         // federation path reads. Consumer created BEFORE the hydrate so no
     274              :         // delta can fall between them; last-writer-wins per key converges.
     275            0 :         let reg_mirror = Arc::new(antares_api::mirror::DocMirror::default());
     276            0 :         let reg_consumer = bus.consume_registry_broadcast().await?;
     277              :         // Installed only if it is whole. A mirror that is present and SHORT
     278              :         // is read as the truth — `reg_docs` asks it and never the store — so
     279              :         // half a hydrate silently drops Context Sources for the life of the
     280              :         // process. Left uninstalled, federation matching falls back to the
     281              :         // store's own indexed narrowing: correct, and merely slower.
     282            0 :         match antares_api::notify::seed_mirror(
     283            0 :             &*state.store,
     284            0 :             reg_mirror.as_ref(),
     285            0 :             Kind::Registration,
     286              :         )
     287            0 :         .await
     288              :         {
     289            0 :             Ok(()) => state.reg_mirror = Some(reg_mirror.clone()),
     290            0 :             Err(e) => tracing::error!(
     291              :                 "registration mirror hydrate failed ({e}); \
     292              :                  federation matching falls back to a store read per request"
     293              :             ),
     294              :         }
     295            0 :         let egress_for_cool = state.egress.clone();
     296            0 :         let store_for_reg = state.store.clone();
     297            0 :         tokio::spawn(async move {
     298              :             // The consumer is ephemeral, so a NATS restart or an inactivity
     299              :             // gap deletes it server-side and the next pull errors. Ending the
     300              :             // task there froze this pod's registration mirror — and with it
     301              :             // all federation matching — for the process lifetime, while
     302              :             // /q/health still reported the bus connected. Re-open instead,
     303              :             // and re-hydrate from the store because a fresh consumer starts
     304              :             // at NEW and never replays what the gap dropped.
     305              :             loop {
     306            0 :                 let mut msgs = match reg_consumer.messages().await {
     307            0 :                     Ok(m) => m,
     308            0 :                     Err(e) => {
     309            0 :                         tracing::warn!("registry broadcast consumer stream failed: {e}");
     310            0 :                         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     311            0 :                         continue;
     312              :                     }
     313              :                 };
     314            0 :                 while let Some(delta) = nats::next_delta(&mut msgs).await {
     315              :                     // 5.2.34 read side: a cooldown stamp updates this pod's
     316              :                     // map (a marker delta has no tenant/id — apply_delta
     317              :                     // ignores it on pods that predate the member).
     318            0 :                     if let Some(rid) = delta.get("cooldownReg").and_then(serde_json::Value::as_str)
     319              :                     {
     320            0 :                         egress_for_cool.reg_record(rid, delta["ok"].as_bool().unwrap_or(false));
     321            0 :                         continue;
     322            0 :                     }
     323            0 :                     apply_delta(reg_mirror.as_ref(), &delta);
     324              :                 }
     325            0 :                 tracing::warn!("registry broadcast consumer stream ended — reopening");
     326            0 :                 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     327              :                 // Already installed, so this one cannot be withheld. A failed
     328              :                 // re-hydrate leaves the mirror holding what it had before the
     329              :                 // gap, which the federation path will serve as current: say
     330              :                 // so at error level rather than let it pass as a warning.
     331            0 :                 if let Err(e) = antares_api::notify::seed_mirror(
     332            0 :                     &*store_for_reg,
     333            0 :                     reg_mirror.as_ref(),
     334            0 :                     Kind::Registration,
     335              :                 )
     336            0 :                 .await
     337              :                 {
     338            0 :                     tracing::error!(
     339              :                         "registration mirror re-hydrate failed ({e}); \
     340              :                          this pod is matching against registrations from before the gap"
     341              :                     );
     342            0 :                 }
     343              :             }
     344              :         });
     345              : 
     346              :         // The outbox drain. Runs on every api pod; concurrent drains are
     347              :         // absorbed by Nats-Msg-Id dedup within the duplicate window.
     348              :         // ANTARES_OUTBOX_DRAIN=off leaves the rows for another pod's drain —
     349              :         // the crash-drill lever and the dedicated-drainer split.
     350            0 :         let drain_on = outbox_drain_enabled()?;
     351            0 :         if !drain_on {
     352            0 :             tracing::warn!("outbox drain OFF on this pod (ANTARES_OUTBOX_DRAIN=off)");
     353            0 :         }
     354            0 :         if drain_on {
     355            0 :             let store = state.store.clone();
     356            0 :             let bus_for_drain = bus.clone();
     357            0 :             tokio::spawn(async move {
     358              :                 loop {
     359            0 :                     let rows = match store.outbox_peek(64).await {
     360            0 :                         Ok(r) => r,
     361            0 :                         Err(e) => {
     362            0 :                             tracing::warn!("outbox peek failed: {e}");
     363            0 :                             tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     364            0 :                             continue;
     365              :                         }
     366              :                     };
     367            0 :                     if rows.is_empty() {
     368              :                         // woken by the same-process write hook, or the fallback
     369              :                         // poll for rows another pod failed to publish
     370            0 :                         let _ = tokio::time::timeout(
     371            0 :                             std::time::Duration::from_millis(250),
     372            0 :                             nudge.notified(),
     373              :                         )
     374            0 :                         .await;
     375            0 :                         continue;
     376            0 :                     }
     377              :                     // Ack the EXACT published seqs — a blanket
     378              :                     // up-to-max delete loses a lower-seq row whose
     379              :                     // transaction commits between peek and ack.
     380            0 :                     let mut acked: Vec<i64> = Vec::new();
     381              :                     // Rows whose bodies were too big for the bus: the message
     382              :                     // carries a reference, and this row is the only copy of
     383              :                     // what it references. Kept, not deleted, and taken out of
     384              :                     // the next page by the same stamp.
     385            0 :                     let mut retained: Vec<(TenantId, i64)> = Vec::new();
     386            0 :                     for (seq, _tenant, event) in rows {
     387            0 :                         match serde_json::from_value::<ChangeEvent>(event) {
     388            0 :                             Ok(mut ev) => {
     389            0 :                                 ev.seq = seq;
     390            0 :                                 let checked = ev.claim_checked_at(antares_bus::CLAIM_CHECK_BYTES);
     391            0 :                                 match bus_for_drain.publish(&ev).await {
     392            0 :                                     Ok(()) if checked => retained.push((ev.tenant, seq)),
     393            0 :                                     Ok(()) => acked.push(seq),
     394            0 :                                     Err(e) => {
     395            0 :                                         tracing::warn!("outbox publish of seq {seq} failed: {e}");
     396            0 :                                         break; // retry from here next round
     397              :                                     }
     398              :                                 }
     399              :                             }
     400            0 :                             Err(e) => {
     401              :                                 // an undecodable row would wedge the drain forever
     402            0 :                                 tracing::error!("outbox row {seq} undecodable ({e}) — skipped");
     403            0 :                                 acked.push(seq);
     404              :                             }
     405              :                         }
     406              :                     }
     407              :                     // Retain BEFORE ack: both statements are separate
     408              :                     // transactions, and a crash between them must leave a
     409              :                     // claim-check row alive rather than published and gone.
     410              :                     // One statement per row, under that row's tenant — the
     411              :                     // outbox UPDATE takes no service escape (0005), and an
     412              :                     // event over the bus ceiling is rare enough that grouping
     413              :                     // the page by tenant would cost more code than statements.
     414            0 :                     for (tenant, seq) in &retained {
     415            0 :                         if let Err(e) = store.outbox_retain(tenant, &[*seq]).await {
     416            0 :                             tracing::warn!("outbox retain of seq {seq} failed: {e}");
     417            0 :                         }
     418              :                     }
     419            0 :                     if !acked.is_empty() {
     420            0 :                         if let Err(e) = store.outbox_ack(&acked).await {
     421            0 :                             tracing::warn!("outbox ack {acked:?} failed: {e}");
     422            0 :                         }
     423            0 :                     }
     424              :                 }
     425              :             });
     426            0 :         }
     427            0 :     }
     428              : 
     429            0 :     if roles.matcher || roles.notifier {
     430              :         // Subscription read side: consumer-before-hydrate, same convergence argument.
     431            0 :         let sub_mirror = Arc::new(antares_api::mirror::SubMirror::default());
     432            0 :         let kv = bus.subs_kv().await?;
     433            0 :         let watch = kv.watch_all().await?;
     434              :         // Same rule as the registration mirror, and the same one `bus=local`
     435              :         // applies in `antares_api::wire`: a subscription absent from an installed
     436              :         // mirror never fires again, because the matcher reads candidates
     437              :         // from the mirror alone.
     438            0 :         match antares_api::notify::seed_mirror(
     439            0 :             &*state.store,
     440            0 :             sub_mirror.as_ref(),
     441            0 :             Kind::Subscription,
     442              :         )
     443            0 :         .await
     444              :         {
     445            0 :             Ok(()) => state.sub_mirror = Some(sub_mirror.clone()),
     446            0 :             Err(e) => tracing::error!(
     447              :                 "subscription mirror hydrate failed ({e}); \
     448              :                  matching falls back to a store scan per change"
     449              :             ),
     450              :         }
     451            0 :         tokio::spawn(async move {
     452              :             // Same restart contract as the registry consumer: the watch ends
     453              :             // on a NATS restart, and a task that returns there stops seeing
     454              :             // every subscription change — i.e. this pod silently stops
     455              :             // notifying — for the process lifetime. `watch_all` replays the
     456              :             // bucket's current values, so re-opening also re-converges the
     457              :             // mirror over the gap.
     458            0 :             let mut watch = watch;
     459              :             loop {
     460            0 :                 while let Some(entry) = watch.next().await {
     461            0 :                     let Ok(entry) = entry else { continue };
     462            0 :                     if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&entry.value) {
     463            0 :                         apply_delta(sub_mirror.as_ref(), &v);
     464            0 :                     }
     465              :                 }
     466            0 :                 tracing::warn!("subscription KV watch ended — reopening");
     467            0 :                 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     468            0 :                 match kv.watch_all().await {
     469            0 :                     Ok(w) => watch = w,
     470            0 :                     Err(e) => tracing::warn!("subscription KV watch reopen failed: {e}"),
     471              :                 }
     472              :             }
     473              :         });
     474              : 
     475              :         // The balanced matcher durable: decode → process_change → ack AFTER.
     476            0 :         durables.push("matcher");
     477            0 :         let consumer = bus.consume_balanced("matcher").await?;
     478            0 :         let st = state.clone();
     479            0 :         tokio::spawn(async move {
     480              :             loop {
     481            0 :                 let mut msgs = match consumer.messages().await {
     482            0 :                     Ok(m) => m,
     483            0 :                     Err(e) => {
     484            0 :                         tracing::warn!("matcher consumer stream failed: {e}");
     485            0 :                         tokio::time::sleep(std::time::Duration::from_secs(1)).await;
     486            0 :                         continue;
     487              :                     }
     488              :                 };
     489            0 :                 while let Some(Ok(msg)) = msgs.next().await {
     490              :                     // Change lag = stream-publish → matcher-processing
     491              :                     // age, from the JetStream metadata timestamp.
     492            0 :                     if let Ok(info) = msg.info() {
     493              :                         // OffsetDateTime → SystemTime (impl in `time`/std),
     494              :                         // so no direct `time` dependency here.
     495            0 :                         let published: std::time::SystemTime = info.published.into();
     496            0 :                         if let Ok(age) = published.elapsed() {
     497            0 :                             metrics::histogram!("antares_change_lag_seconds")
     498            0 :                                 .record(age.as_secs_f64());
     499            0 :                         }
     500            0 :                     }
     501            0 :                     if let Some(ev) = nats::decode(&msg) {
     502            0 :                         let (before, after) = resolve_payloads(&st, &ev).await;
     503            0 :                         antares_api::notify::process_change(&st, ev.tenant.as_str(), before, after)
     504            0 :                             .await;
     505            0 :                     }
     506            0 :                     let _ = msg.ack().await;
     507              :                 }
     508              :             }
     509              :         });
     510              : 
     511              :         // Interval subscriptions: every matcher pod ticks; the row-lock claim
     512              :         // in interval_tick makes each firing single-winner.
     513            0 :         let st = state.clone();
     514            0 :         tokio::spawn(async move {
     515            0 :             let mut tick = tokio::time::interval(std::time::Duration::from_millis(500));
     516              :             loop {
     517            0 :                 tick.tick().await;
     518            0 :                 antares_api::notify::interval_tick(&st).await;
     519              :             }
     520              :         });
     521            0 :     }
     522              : 
     523              :     // The temporal role carries no bus consumer: auto-recording is
     524              :     // synchronous in the write path (see above), and plain-mode partition
     525              :     // maintenance runs from main.rs regardless of bus mode.
     526              : 
     527              :     // The server must agree these are shared durables.
     528            0 :     bus.assert_topology(&durables).await?;
     529            0 :     tracing::info!(?roles, "bus=nats wired");
     530            0 :     Ok(())
     531            0 : }
     532              : 
     533              : /// Hydrate a mirror from the system of record (Postgres) at startup.
     534              : /// Attempts a mirror write gets before the divergence is logged as an error.
     535              : const MIRROR_SYNC_ATTEMPTS: u32 = 5;
     536              : 
     537              : /// Exponential backoff between mirror-sync attempts: 0.2 s doubling to 3.2 s,
     538              : /// so the whole ladder outlives a bus reconnect without holding a task for
     539              : /// minutes.
     540            0 : fn mirror_sync_backoff(attempt: u32) -> std::time::Duration {
     541            0 :     std::time::Duration::from_millis(200u64 << attempt.min(4))
     542            0 : }
     543              : 
     544              : /// Apply one `{tenant, id, doc|null, csub}` delta to a mirror. A Context
     545              : /// Source Registration Subscription carries no document into the mirror: it
     546              : /// is matched against registrations rather than entities, so the delta only
     547              : /// wakes the interval sweep (5.11.7).
     548           34 : fn apply_delta(mirror: &dyn antares_api::mirror::Mirror, delta: &serde_json::Value) {
     549           34 :     if delta.get("csub").and_then(serde_json::Value::as_bool) == Some(true) {
     550            2 :         mirror.csub_written();
     551            2 :         return;
     552           32 :     }
     553           12 :     let (Some(tenant), Some(id)) = (
     554           32 :         delta.get("tenant").and_then(serde_json::Value::as_str),
     555           32 :         delta.get("id").and_then(serde_json::Value::as_str),
     556              :     ) else {
     557           20 :         return;
     558              :     };
     559              :     // Hydration validates the tenant before it touches the mirror; the delta
     560              :     // path must agree. A name outside the grammar can only add entries that no
     561              :     // lookup will ever hit, so an unvalidated one is unbounded growth keyed by
     562              :     // whatever reached the bus. The grammar is the whole check here: what
     563              :     // arrives is a tenant a peer broker wrote, and a write inside a Snapshot
     564              :     // (5.5.15) carries the synthetic tenant a client may not name — refusing
     565              :     // that one would drop the mirror delta the snapshot-scoped subscription
     566              :     // matches on.
     567           12 :     if TenantId::new_internal(tenant).is_err() {
     568            8 :         return;
     569            4 :     }
     570            4 :     let doc = delta.get("doc").filter(|d| !d.is_null()).cloned();
     571            4 :     mirror.apply(tenant, id, doc);
     572           34 : }
     573              : 
     574              : /// Resolve claim-check references: bodies the bus could not carry come back
     575              : /// from the outbox row the drain kept, read by the event's own `seq`.
     576              : ///
     577              : /// The store's current row is NOT that source. It answers with the entity as
     578              : /// it stands now, which is the after-image: resolving `prev_payload_ref` from
     579              : /// it hands the matcher two copies of the same document, `diff` finds nothing
     580              : /// changed and the change reaches no subscriber. It stays the fallback for
     581              : /// `payload_ref` alone, where being newer than the referenced version is the
     582              : /// ordinary at-least-once reality the matcher already tolerates.
     583           12 : async fn resolve_payloads(
     584           12 :     st: &AppState,
     585           12 :     ev: &ChangeEvent,
     586           12 : ) -> (Option<serde_json::Value>, Option<serde_json::Value>) {
     587           12 :     if ev.payload_ref.is_none() && ev.prev_payload_ref.is_none() {
     588            4 :         return (ev.prev_payload.clone(), ev.payload.clone());
     589            8 :     }
     590              :     // 0 is the local bus, which never claim-checks: it hands the payloads to
     591              :     // the matcher in process.
     592            8 :     let kept = match ev.seq {
     593            6 :         0 => None,
     594            2 :         seq => st
     595            2 :             .store
     596            2 :             .outbox_event(seq, &ev.tenant)
     597            2 :             .await
     598            2 :             .ok()
     599            2 :             .flatten()
     600            2 :             .and_then(|v| serde_json::from_value::<ChangeEvent>(v).ok()),
     601              :     };
     602            8 :     if let Some(kept) = kept {
     603            2 :         return (kept.prev_payload, kept.payload);
     604            6 :     }
     605              :     // Past the retention window, or a deployment whose store keeps no outbox.
     606              :     // The after-image is still recoverable; the before-image is not, and a
     607              :     // guess in its place is a notification that reports a change that did not
     608              :     // happen.
     609            6 :     let after = match ev.payload.clone() {
     610            0 :         Some(v) => Some(v),
     611            6 :         None => match ev.payload_ref.as_ref() {
     612            6 :             Some(r) => st
     613            6 :                 .store
     614            6 :                 .get(&ev.tenant, Kind::Entity, r.entity_id.as_str())
     615            6 :                 .await
     616            6 :                 .ok()
     617            6 :                 .flatten(),
     618            0 :             None => None,
     619              :         },
     620              :     };
     621            6 :     if ev.prev_payload_ref.is_some() {
     622            0 :         metrics::counter!("antares_claim_check_unresolved_total").increment(1);
     623            0 :         tracing::warn!(
     624              :             "claim-check row for seq {} is gone: the change to {} notifies nobody",
     625              :             ev.seq,
     626            0 :             ev.entity_id.as_str()
     627              :         );
     628            6 :     }
     629            6 :     (ev.prev_payload.clone(), after)
     630           12 : }
     631              : 
     632              : #[cfg(test)]
     633              : mod tests {
     634              :     use super::*;
     635              :     use antares_api::mirror::DocMirror;
     636              :     use antares_bus::{ChangeOp, PayloadRef};
     637              :     use antares_model::EntityId;
     638              :     use serde_json::json;
     639              : 
     640              :     #[test]
     641            2 :     fn roles_parse_accepts_the_role_set_and_refuses_anything_else() {
     642            2 :         assert!(Roles::parse("all").expect("all").all());
     643            2 :         let r = Roles::parse("api").expect("api");
     644            2 :         assert!(r.api && !r.matcher && !r.notifier && !r.temporal && !r.registry);
     645            2 :         assert!(
     646            2 :             !r.all(),
     647              :             "a single role must never claim to be the full set"
     648              :         );
     649            2 :         let r = Roles::parse(" matcher , notifier ").expect("padded list");
     650            2 :         assert!(r.matcher && r.notifier && !r.api);
     651              :         // The enumerated full set is the same thing as "all" — a role split
     652              :         // that happens to name every role must still pass the bus=local gate.
     653            2 :         assert!(Roles::parse("api,matcher,notifier,temporal,registry")
     654            2 :             .expect("full list")
     655            2 :             .all());
     656              : 
     657           14 :         for bad in ["", "api,", "ALL", "apis", "api;matcher", "worker", " "] {
     658           14 :             let err = Roles::parse(bad).expect_err(&format!("ANTARES_ROLES={bad:?} must be fatal"));
     659           14 :             assert!(err.starts_with("unknown role"), "{bad:?}: {err}");
     660              :         }
     661            2 :     }
     662              : 
     663              :     /// The KV key must be legal for a NATS KV bucket (`:` from a URN is not),
     664              :     /// stable across calls, and collision-free per id, per tenant and per
     665              :     /// kind — 5.5.10 leaves the id to the client, so one URN can name both a
     666              :     /// Subscription and a Context Source Registration Subscription.
     667              :     #[test]
     668            2 :     fn kv_key_is_bucket_legal_and_stable() {
     669            2 :         let sub = Kind::Subscription;
     670            2 :         let k = kv_key("default", sub, "urn:ngsi-ld:Subscription:1");
     671            2 :         assert_eq!(
     672              :             k,
     673            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
     674              :             "stable"
     675              :         );
     676            2 :         assert!(
     677            2 :             k.bytes()
     678           48 :                 .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-'),
     679              :             "illegal KV key character in {k:?}"
     680              :         );
     681            2 :         assert!(k.starts_with("default."), "tenant scoping lost: {k}");
     682            2 :         assert_ne!(
     683            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
     684            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:2")
     685              :         );
     686            2 :         assert_ne!(
     687            2 :             kv_key("t1", sub, "urn:ngsi-ld:Subscription:1"),
     688            2 :             kv_key("t2", sub, "urn:ngsi-ld:Subscription:1")
     689              :         );
     690            2 :         assert_ne!(
     691            2 :             kv_key("default", sub, "urn:ngsi-ld:Subscription:1"),
     692            2 :             kv_key(
     693            2 :                 "default",
     694            2 :                 Kind::CSourceSubscription,
     695            2 :                 "urn:ngsi-ld:Subscription:1"
     696              :             ),
     697              :             "one id naming both kinds must not share a mirror entry"
     698              :         );
     699            2 :     }
     700              : 
     701              :     /// A Context Source Registration Subscription delta carries no document:
     702              :     /// it wakes the interval sweep and touches nothing the matcher reads.
     703              :     #[test]
     704            2 :     fn a_csub_delta_only_wakes_the_sweep() {
     705            2 :         let m = antares_api::mirror::SubMirror::default();
     706            2 :         apply_delta(
     707            2 :             &m,
     708            2 :             &serde_json::json!({
     709            2 :                 "tenant": "default", "id": "urn:ngsi-ld:CSourceSubscription:1",
     710            2 :                 "doc": {"id": "urn:ngsi-ld:CSourceSubscription:1", "timeInterval": 5},
     711            2 :                 "csub": true,
     712            2 :             }),
     713              :         );
     714            2 :         assert!(
     715            2 :             m.docs("default").is_empty(),
     716              :             "a csource subscription must not enter the candidate index"
     717              :         );
     718            2 :     }
     719              : 
     720              :     /// The KV/registry mirrors are fed from the bus. A malformed or hostile
     721              :     /// delta must be dropped — never panic a consumer task, and never grow
     722              :     /// the mirror under a key no request can ever address (the tenant is
     723              :     /// validated on every request, so an unvalidated one is pure ballast).
     724              :     #[test]
     725            2 :     fn apply_delta_ignores_malformed_and_hostile_deltas() {
     726            2 :         let m = DocMirror::default();
     727            2 :         apply_delta(
     728            2 :             &m,
     729            2 :             &json!({"tenant": "default", "id": "urn:x:1", "doc": {"id": "urn:x:1"}}),
     730              :         );
     731            2 :         assert_eq!(m.docs("default").len(), 1, "a good delta must apply");
     732              : 
     733              :         // tombstone
     734            2 :         apply_delta(
     735            2 :             &m,
     736            2 :             &json!({"tenant": "default", "id": "urn:x:1", "doc": null}),
     737              :         );
     738            2 :         assert!(m.docs("default").is_empty(), "null doc must delete");
     739            2 :         assert!(m.tenants().is_empty(), "an emptied tenant must not linger");
     740              : 
     741              :         // Shapes that must be ignored without panicking.
     742           20 :         for junk in [
     743            2 :             json!({}),
     744            2 :             json!(null),
     745            2 :             json!(42),
     746            2 :             json!("scalar"),
     747            2 :             json!([1, 2, 3]),
     748            2 :             json!({"tenant": "default"}),
     749            2 :             json!({"id": "urn:x:1"}),
     750            2 :             json!({"tenant": 7, "id": "urn:x:1", "doc": {}}),
     751            2 :             json!({"tenant": "default", "id": null, "doc": {}}),
     752            2 :             json!({"cooldownReg": "urn:reg:1", "ok": false}),
     753           20 :         ] {
     754           20 :             apply_delta(&m, &junk);
     755           20 :         }
     756            2 :         assert!(m.tenants().is_empty(), "junk deltas grew the mirror");
     757              : 
     758              :         // Hostile tenants: not addressable by any request (the header is
     759              :         // validated to [A-Za-z0-9_-]{1,64}), so they may not take memory.
     760            8 :         for hostile in [
     761            2 :             "a".repeat(4096),
     762            2 :             "../../etc".into(),
     763            2 :             "a b".into(),
     764            2 :             "".into(),
     765            8 :         ] {
     766            8 :             apply_delta(
     767            8 :                 &m,
     768            8 :                 &json!({"tenant": hostile, "id": "urn:x:1", "doc": {"id": "urn:x:1"}}),
     769            8 :             );
     770            8 :         }
     771            2 :         assert!(
     772            2 :             m.tenants().is_empty(),
     773              :             "an unaddressable tenant grew the mirror without bound: {:?}",
     774            0 :             m.tenants()
     775              :         );
     776            2 :     }
     777              : 
     778            6 :     async fn state_with(entity: Option<serde_json::Value>) -> AppState {
     779            6 :         let st = AppState::new("antares".into());
     780            6 :         if let Some(doc) = entity {
     781            4 :             let id = doc["id"].as_str().expect("id").to_owned();
     782            4 :             st.store
     783            4 :                 .create(&TenantId::default(), Kind::Entity, &id, doc)
     784            4 :                 .await
     785            4 :                 .expect("seed");
     786            2 :         }
     787            6 :         st
     788            6 :     }
     789              : 
     790           10 :     fn event(payload: Option<serde_json::Value>, r#ref: Option<PayloadRef>) -> ChangeEvent {
     791           10 :         ChangeEvent {
     792           10 :             tenant: TenantId::default(),
     793           10 :             entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
     794           10 :             types: vec!["T".into()],
     795           10 :             op: ChangeOp::Update,
     796           10 :             changed_attrs: vec![],
     797           10 :             payload,
     798           10 :             prev_payload: None,
     799           10 :             version: 1,
     800           10 :             incarnation: String::new(),
     801           10 :             seq: 0,
     802           10 :             payload_ref: r#ref,
     803           10 :             prev_payload_ref: None,
     804           10 :         }
     805           10 :     }
     806              : 
     807              :     /// Claim-check resolution: inline wins, a reference is fetched, and a
     808              :     /// reference to a row that is gone resolves to None instead of panicking
     809              :     /// the matcher task.
     810              :     #[tokio::test]
     811            2 :     async fn resolve_payloads_prefers_inline_and_tolerates_a_dangling_reference() {
     812            2 :         let doc = json!({"id": "urn:ngsi-ld:T:1", "type": "T"});
     813            2 :         let st = state_with(Some(doc.clone())).await;
     814              : 
     815            2 :         let (before, after) =
     816            2 :             resolve_payloads(&st, &event(Some(json!({"inline": true})), None)).await;
     817            2 :         assert_eq!(after, Some(json!({"inline": true})), "inline payload wins");
     818            2 :         assert_eq!(before, None);
     819              : 
     820            2 :         let r = PayloadRef {
     821            2 :             entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
     822            2 :             version: 1,
     823            2 :         };
     824            2 :         let (_, after) = resolve_payloads(&st, &event(None, Some(r.clone()))).await;
     825            2 :         assert_eq!(
     826            2 :             after.as_ref().and_then(|a| a["id"].as_str()),
     827              :             Some("urn:ngsi-ld:T:1"),
     828              :             "a claim-check reference must be fetched from the store"
     829              :         );
     830              : 
     831              :         // The row was deleted between publish and consumption.
     832            2 :         let gone = state_with(None).await;
     833            2 :         let (before, after) = resolve_payloads(&gone, &event(None, Some(r))).await;
     834            2 :         assert_eq!(after, None, "a dangling reference must resolve to None");
     835            2 :         assert_eq!(before, None);
     836              : 
     837            2 :         let (before, after) = resolve_payloads(&st, &event(None, None)).await;
     838            2 :         assert!(before.is_none() && after.is_none(), "no payload, no fetch");
     839            2 :     }
     840              : 
     841              :     /// A claim-check fetch is scoped to the EVENT's tenant: a reference must
     842              :     /// never resolve against another tenant's row of the same id.
     843              :     #[tokio::test]
     844            2 :     async fn resolve_payloads_never_crosses_a_tenant_boundary() {
     845            2 :         let st = state_with(Some(json!({"id": "urn:ngsi-ld:T:1", "type": "T"}))).await;
     846            2 :         let mut ev = event(
     847            2 :             None,
     848            2 :             Some(PayloadRef {
     849            2 :                 entity_id: EntityId::new("urn:ngsi-ld:T:1").expect("id"),
     850            2 :                 version: 1,
     851            2 :             }),
     852              :         );
     853            2 :         ev.tenant = TenantId::new("other").expect("tenant");
     854            2 :         let (_, after) = resolve_payloads(&st, &ev).await;
     855            2 :         assert_eq!(
     856            2 :             after, None,
     857            2 :             "a reference resolved another tenant's entity: {after:?}"
     858            2 :         );
     859            2 :     }
     860              : 
     861              :     /// A change whose bodies were both too big for the bus reaches the
     862              :     /// matcher with the before-image the write actually replaced. Resolving
     863              :     /// the reference against the store's current row instead hands back the
     864              :     /// after-image twice, `diff` finds nothing and the change notifies
     865              :     /// nobody. Skips without ANTARES_TEST_DATABASE_URL — the outbox is a
     866              :     /// Postgres table, and the memory arm has none.
     867              :     #[tokio::test(flavor = "multi_thread")]
     868            2 :     async fn a_retained_row_gives_the_matcher_the_before_image_the_write_replaced() {
     869            2 :         let Ok(url) = std::env::var("ANTARES_TEST_DATABASE_URL") else {
     870            0 :             eprintln!("SKIP: ANTARES_TEST_DATABASE_URL not set");
     871            0 :             return;
     872              :         };
     873            2 :         let pool = antares_sql::store::pg::connect(&url, 5)
     874            2 :             .await
     875            2 :             .expect("connect");
     876            2 :         let tenant = TenantId::new("claimcheck").expect("tenant");
     877            2 :         antares_sql::store::pg::ensure_tenant(&pool, &tenant)
     878            2 :             .await
     879            2 :             .expect("tenant row");
     880              : 
     881            2 :         let id = "urn:ngsi-ld:T:oversized";
     882            4 :         let wide = |v: &str| {
     883            4 :             json!({"id": id, "type": ["T"],
     884            4 :                    "https://uri.etsi.org/ngsi-ld/default-context/note":
     885            4 :                        [{"type": "Property", "value": format!("{v}{}", "x".repeat(300 * 1024))}]})
     886            4 :         };
     887            2 :         let before_doc = wide("before-");
     888            2 :         let after_doc = wide("after-");
     889              : 
     890            2 :         let mut ev = ChangeEvent {
     891            2 :             tenant: tenant.clone(),
     892            2 :             entity_id: EntityId::new(id).expect("id"),
     893            2 :             types: vec!["T".into()],
     894            2 :             op: ChangeOp::Update,
     895            2 :             changed_attrs: vec![],
     896            2 :             payload: Some(after_doc.clone()),
     897            2 :             prev_payload: Some(before_doc.clone()),
     898            2 :             version: 2,
     899            2 :             incarnation: String::new(),
     900            2 :             seq: 0,
     901            2 :             payload_ref: None,
     902            2 :             prev_payload_ref: None,
     903            2 :         };
     904            2 :         let mut tx = pool.begin().await.expect("tx");
     905            2 :         antares_sql::store::pg::set_tenant(&mut tx, &tenant)
     906            2 :             .await
     907            2 :             .expect("set tenant");
     908            2 :         let seq = antares_sql::store::pg::outbox::enqueue(
     909            2 :             &mut tx,
     910            2 :             &tenant,
     911            2 :             &serde_json::to_value(&ev).expect("event json"),
     912            2 :         )
     913            2 :         .await
     914            2 :         .expect("enqueue");
     915            2 :         tx.commit().await.expect("commit");
     916            2 :         antares_sql::store::pg::outbox::retain(&pool, &tenant, &[seq])
     917            2 :             .await
     918            2 :             .expect("retain");
     919              : 
     920            2 :         let st = AppState::with_store(
     921            2 :             "antares".into(),
     922            2 :             std::sync::Arc::new(antares_sql::store::any::AnyStore::Pg(
     923            2 :                 antares_sql::store::any::PgBackend::new(pool.clone()),
     924            2 :             )),
     925            2 :             "postgres",
     926              :         );
     927              :         // The current row is the AFTER image — the document the old
     928              :         // resolution handed back for both halves.
     929            2 :         let _ = st.store.delete(&tenant, Kind::Entity, id).await;
     930            2 :         st.store
     931            2 :             .create(&tenant, Kind::Entity, id, after_doc.clone())
     932            2 :             .await
     933            2 :             .expect("seed current row");
     934              : 
     935            2 :         ev.seq = seq;
     936            2 :         let wire = ev.claim_check(antares_bus::CLAIM_CHECK_BYTES);
     937            2 :         assert!(
     938            2 :             wire.prev_payload_ref.is_some() && wire.payload_ref.is_some(),
     939              :             "the fixture must be over the claim-check ceiling"
     940              :         );
     941            2 :         let (before, after) = resolve_payloads(&st, &wire).await;
     942            2 :         assert_eq!(before.as_ref(), Some(&before_doc), "before-image lost");
     943            2 :         assert_eq!(after.as_ref(), Some(&after_doc));
     944            2 :         assert_ne!(
     945              :             before, after,
     946              :             "both halves resolved to the current row: the change notifies nobody"
     947              :         );
     948              : 
     949            2 :         let _ = st.store.delete(&tenant, Kind::Entity, id).await;
     950            2 :         let _ = antares_sql::store::pg::outbox::reap_published(&pool, 0).await;
     951            2 :     }
     952              : 
     953              :     /// The outbox-drain switch is a config value like any other: the two
     954              :     /// documented spellings decide, anything else is fatal instead of
     955              :     /// silently leaving the drain on (a typo'd `ANTARES_OUTBOX_DRAIN=of`
     956              :     /// would otherwise read as "on" and quietly defeat the crash drill).
     957              :     #[test]
     958            2 :     fn outbox_drain_switch_is_total() {
     959            2 :         std::env::remove_var("ANTARES_OUTBOX_DRAIN");
     960            2 :         assert!(outbox_drain_enabled().expect("default"), "default is on");
     961            2 :         std::env::set_var("ANTARES_OUTBOX_DRAIN", "off");
     962            2 :         assert!(!outbox_drain_enabled().expect("off"));
     963            2 :         std::env::set_var("ANTARES_OUTBOX_DRAIN", "on");
     964            2 :         assert!(outbox_drain_enabled().expect("on"));
     965           12 :         for bad in ["", "of", "false", "0", "OFF", "no"] {
     966           12 :             std::env::set_var("ANTARES_OUTBOX_DRAIN", bad);
     967           12 :             let err = outbox_drain_enabled()
     968           12 :                 .expect_err(&format!("ANTARES_OUTBOX_DRAIN={bad:?} must be fatal"));
     969           12 :             assert!(err.contains("ANTARES_OUTBOX_DRAIN"), "{err}");
     970              :         }
     971            2 :         std::env::remove_var("ANTARES_OUTBOX_DRAIN");
     972            2 :     }
     973              : }
        

Generated by: LCOV version 2.0-1