LCOV - code coverage report
Current view: top level - antares-bus/src - nats.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 27.5 % 287 79
Test Date: 2026-09-21 10:31:06 Functions: 4.2 % 168 7

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! The JetStream implementation of the bus.
       3              : //!
       4              : //! One `ANTARES_CHANGES` stream (Interest retention, subjects `changes.>`),
       5              : //! one `ANTARES_REGISTRY` stream for registration deltas, one KV bucket
       6              : //! (`antares_subscriptions`) for the compiled-subscription mirror.
       7              : //!
       8              : //! Consumer discipline, asserted not assumed (a lesson from Scorpio):
       9              : //! *balanced* work (matcher, temporal recorder) = a shared DURABLE pull
      10              : //! consumer — instances joining the same durable load-balance; *broadcast*
      11              : //! work (per-instance mirrors) = an EPHEMERAL pull consumer — every instance
      12              : //! sees every message. The distinction is explicit in the method you call,
      13              : //! and `assert_topology` verifies the server agrees at startup.
      14              : //!
      15              : //! Delivery is at-least-once engineered to idempotent: publish-side dedup via
      16              : //! `Nats-Msg-Id` inside the stream's duplicate window, explicit ack AFTER
      17              : //! processing (never Scorpio's PRE_PROCESSING commit-before-work), bounded
      18              : //! prefetch.
      19              : 
      20              : use crate::{subjects, ChangeEvent};
      21              : use async_nats::jetstream::{self, consumer, stream};
      22              : use futures_util::StreamExt;
      23              : 
      24              : /// Bus failures. String-typed on purpose: callers either retry (drain loop)
      25              : /// or die loudly (startup) — nobody branches on the variant.
      26              : #[derive(Debug)]
      27              : pub struct BusError(pub String);
      28              : 
      29              : impl std::fmt::Display for BusError {
      30            0 :     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
      31            0 :         write!(f, "bus error: {}", self.0)
      32            0 :     }
      33              : }
      34              : impl std::error::Error for BusError {}
      35              : 
      36            0 : fn err<E: std::fmt::Display>(e: E) -> BusError {
      37            0 :     BusError(e.to_string())
      38            0 : }
      39              : 
      40              : pub const CHANGES_STREAM: &str = "ANTARES_CHANGES";
      41              : pub const REGISTRY_STREAM: &str = "ANTARES_REGISTRY";
      42              : pub const SUBS_BUCKET: &str = "antares_subscriptions";
      43              : 
      44              : /// Bounded prefetch: how many unacked messages one consumer may
      45              : /// hold. Any unbounded queue is a 3am page.
      46              : pub const MAX_ACK_PENDING: i64 = 256;
      47              : 
      48              : pub struct NatsBus {
      49              :     js: jetstream::Context,
      50              :     client: async_nats::Client,
      51              :     /// `Event::Connected` occurrences (the initial connect included; the
      52              :     /// getter subtracts it — surfaced on /q/health as `reconnects`).
      53              :     reconnects: std::sync::Arc<std::sync::atomic::AtomicU64>,
      54              : }
      55              : 
      56              : impl NatsBus {
      57              :     /// Connect and ensure the streams + KV bucket exist (idempotent).
      58            0 :     pub async fn connect(url: &str) -> Result<Self, BusError> {
      59            0 :         let reconnects = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
      60            0 :         let counter = reconnects.clone();
      61            0 :         let client = async_nats::ConnectOptions::new()
      62            0 :             .event_callback(move |ev| {
      63            0 :                 let counter = counter.clone();
      64            0 :                 async move {
      65            0 :                     match ev {
      66              :                         // fires per successful (re)connect after the initial
      67              :                         // ConnectOptions::connect returned
      68              :                         async_nats::Event::Connected => {
      69            0 :                             counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
      70            0 :                             tracing::info!("bus reconnected to NATS");
      71              :                         }
      72              :                         async_nats::Event::Disconnected => {
      73            0 :                             tracing::warn!("bus lost the NATS connection — reconnecting");
      74              :                         }
      75            0 :                         other => tracing::debug!("bus event: {other}"),
      76              :                     }
      77            0 :                 }
      78            0 :             })
      79            0 :             .connect(url)
      80            0 :             .await
      81            0 :             .map_err(err)?;
      82              :         // The change stream carries every tenant's ChangeEvent bodies
      83              :         // and MUST stay internal. If the server requires no auth, anything that
      84              :         // reaches it can read or forge all-tenant events — warn loudly so an
      85              :         // unauthenticated JetStream cluster is not shipped by accident.
      86            0 :         if !client.server_info().auth_required {
      87            0 :             tracing::warn!(
      88              :                 "connected to a NATS server that requires NO authentication — the \
      89              :                  ANTARES_CHANGES stream exposes all tenants' change events; require \
      90              :                  nkey/creds/mTLS and network-isolate the JetStream cluster in production"
      91              :             );
      92            0 :         }
      93            0 :         let js = jetstream::new(client.clone());
      94            0 :         let bus = Self {
      95            0 :             js,
      96            0 :             client,
      97            0 :             reconnects,
      98            0 :         };
      99            0 :         bus.ensure_streams().await?;
     100            0 :         Ok(bus)
     101            0 :     }
     102              : 
     103              :     /// Live connection state for /q/health.
     104            0 :     pub fn connected(&self) -> bool {
     105            0 :         self.client.connection_state() == async_nats::connection::State::Connected
     106            0 :     }
     107              : 
     108              :     /// Successful reconnects since startup. `Event::Connected` fires on
     109              :     /// EVERY successful connect including the initial one (connector.rs
     110              :     /// emits it unconditionally), so the first event is subtracted.
     111            0 :     pub fn reconnects(&self) -> u64 {
     112            0 :         self.reconnects
     113            0 :             .load(std::sync::atomic::Ordering::Relaxed)
     114            0 :             .saturating_sub(1)
     115            0 :     }
     116              : 
     117            0 :     async fn ensure_streams(&self) -> Result<(), BusError> {
     118              :         // Production runs replicas=3 on a 3-node JetStream cluster. Stream
     119              :         // replication is a CLIENT-side stream setting, so the deployment
     120              :         // manifests set ANTARES_NATS_REPLICAS=3; single-node dev/CI keeps 1.
     121              :         // Garbage is FATAL like every other config value: a typo silently
     122              :         // running replicas=1 on a 3-node cluster is a durability downgrade
     123              :         // nobody chose.
     124            0 :         let replicas = replicas_from(std::env::var("ANTARES_NATS_REPLICAS").ok().as_deref())?;
     125            0 :         self.js
     126            0 :             .get_or_create_stream(stream::Config {
     127            0 :                 name: CHANGES_STREAM.into(),
     128            0 :                 subjects: vec!["changes.>".into()],
     129            0 :                 // Interest retention: each durable sees every message; a
     130            0 :                 // message dies once every interested consumer acked it.
     131            0 :                 // (WorkQueue would forbid multiple consumer groups.)
     132            0 :                 retention: stream::RetentionPolicy::Interest,
     133            0 :                 duplicate_window: std::time::Duration::from_secs(120),
     134            0 :                 num_replicas: replicas,
     135            0 :                 ..Default::default()
     136            0 :             })
     137            0 :             .await
     138            0 :             .map_err(err)?;
     139            0 :         self.js
     140            0 :             .get_or_create_stream(stream::Config {
     141            0 :                 name: REGISTRY_STREAM.into(),
     142            0 :                 subjects: vec!["registry.>".into()],
     143            0 :                 // Broadcast deltas for per-instance mirrors: ephemeral
     144            0 :                 // consumers carry no interest, so bound by age instead.
     145            0 :                 retention: stream::RetentionPolicy::Limits,
     146            0 :                 max_age: std::time::Duration::from_secs(600),
     147            0 :                 num_replicas: replicas,
     148            0 :                 ..Default::default()
     149            0 :             })
     150            0 :             .await
     151            0 :             .map_err(err)?;
     152            0 :         self.js
     153            0 :             .create_key_value(async_nats::jetstream::kv::Config {
     154            0 :                 bucket: SUBS_BUCKET.into(),
     155            0 :                 num_replicas: replicas,
     156            0 :                 ..Default::default()
     157            0 :             })
     158            0 :             .await
     159            0 :             .map_err(err)?;
     160            0 :         Ok(())
     161            0 :     }
     162              : 
     163              :     /// Publish one change event with `Nats-Msg-Id` dedup. The id is
     164              :     /// the outbox seq so a drain retry after a crash is absorbed by the
     165              :     /// stream's duplicate window, not delivered twice.
     166            0 :     pub async fn publish(&self, ev: &ChangeEvent) -> Result<(), BusError> {
     167            0 :         let ev = ev.clone().claim_check(crate::CLAIM_CHECK_BYTES);
     168            0 :         let subject = subjects::change_subject(
     169            0 :             &ev.tenant,
     170            0 :             ev.types.first().map(String::as_str).unwrap_or(""),
     171            0 :             ev.entity_id.as_str(),
     172              :         );
     173            0 :         let mut headers = async_nats::HeaderMap::new();
     174            0 :         headers.insert(
     175              :             "Nats-Msg-Id",
     176            0 :             format!("{}:{}", ev.tenant.as_str(), ev.seq).as_str(),
     177              :         );
     178            0 :         let bytes = serde_json::to_vec(&ev).map_err(err)?;
     179              :         // double-ack: await the JetStream publish ack, not just the TCP write
     180            0 :         self.js
     181            0 :             .publish_with_headers(subject, headers, bytes.into())
     182            0 :             .await
     183            0 :             .map_err(err)?
     184            0 :             .await
     185            0 :             .map_err(err)?;
     186            0 :         Ok(())
     187            0 :     }
     188              : 
     189              :     /// Publish a registration CUD delta: the full registration document
     190              :     /// (or a `{"deleted": id}` tombstone) on the tenant's registry subject.
     191              :     /// The tenant is re-validated here because it becomes a subject token.
     192            0 :     pub async fn publish_registry(
     193            0 :         &self,
     194            0 :         tenant: &str,
     195            0 :         delta: &serde_json::Value,
     196            0 :     ) -> Result<(), BusError> {
     197            0 :         let tenant = antares_model::TenantId::new_internal(tenant).map_err(err)?;
     198            0 :         let bytes = serde_json::to_vec(delta).map_err(err)?;
     199            0 :         self.js
     200            0 :             .publish(subjects::registry_subject(&tenant), bytes.into())
     201            0 :             .await
     202            0 :             .map_err(err)?
     203            0 :             .await
     204            0 :             .map_err(err)?;
     205            0 :         Ok(())
     206            0 :     }
     207              : 
     208              :     /// BALANCED consumption (matcher, temporal recorder): a shared durable —
     209              :     /// instances with the same `durable` name split the work. Explicit-ack,
     210              :     /// bounded prefetch; the caller acks AFTER processing.
     211            0 :     pub async fn consume_balanced(
     212            0 :         &self,
     213            0 :         durable: &str,
     214            0 :     ) -> Result<consumer::PullConsumer, BusError> {
     215            0 :         let s = self.js.get_stream(CHANGES_STREAM).await.map_err(err)?;
     216            0 :         s.get_or_create_consumer(
     217            0 :             durable,
     218            0 :             consumer::pull::Config {
     219            0 :                 durable_name: Some(durable.into()),
     220            0 :                 ack_policy: consumer::AckPolicy::Explicit,
     221            0 :                 max_ack_pending: MAX_ACK_PENDING,
     222            0 :                 ..Default::default()
     223            0 :             },
     224            0 :         )
     225            0 :         .await
     226            0 :         .map_err(err)
     227            0 :     }
     228              : 
     229              :     /// BROADCAST consumption (per-instance registry mirror): an ephemeral
     230              :     /// consumer — every instance sees every delta, and the consumer dies
     231              :     /// with the instance.
     232            0 :     pub async fn consume_registry_broadcast(&self) -> Result<consumer::PullConsumer, BusError> {
     233            0 :         let s = self.js.get_stream(REGISTRY_STREAM).await.map_err(err)?;
     234            0 :         s.create_consumer(consumer::pull::Config {
     235            0 :             // no durable name = ephemeral = broadcast
     236            0 :             durable_name: None,
     237            0 :             deliver_policy: consumer::DeliverPolicy::New,
     238            0 :             ack_policy: consumer::AckPolicy::Explicit,
     239            0 :             max_ack_pending: MAX_ACK_PENDING,
     240            0 :             ..Default::default()
     241            0 :         })
     242            0 :         .await
     243            0 :         .map_err(err)
     244            0 :     }
     245              : 
     246              :     /// Drop a consumer of the registry stream, the way a server restart or an
     247              :     /// inactivity gap drops an ephemeral one. The watcher's reopen path is
     248              :     /// only exercisable if that gap can be produced on demand.
     249            0 :     pub async fn delete_registry_consumer(&self, name: &str) -> Result<(), BusError> {
     250            0 :         let s = self.js.get_stream(REGISTRY_STREAM).await.map_err(err)?;
     251            0 :         s.delete_consumer(name).await.map_err(err)?;
     252            0 :         Ok(())
     253            0 :     }
     254              : 
     255              :     /// The KV bucket holding the compiled-subscription mirror.
     256            0 :     pub async fn subs_kv(&self) -> Result<async_nats::jetstream::kv::Store, BusError> {
     257            0 :         self.js.get_key_value(SUBS_BUCKET).await.map_err(err)
     258            0 :     }
     259              : 
     260              :     /// The Scorpio `$[quarkus.uuid}` lesson: assert at startup that every
     261              :     /// balanced concern really is a shared durable on the server — a typo'd
     262              :     /// durable name would silently turn work-sharing into a private queue
     263              :     /// (or broadcast into load-balancing). Fatal on mismatch.
     264            0 :     pub async fn assert_topology(&self, balanced_durables: &[&str]) -> Result<(), BusError> {
     265            0 :         let s = self.js.get_stream(CHANGES_STREAM).await.map_err(err)?;
     266            0 :         for durable in balanced_durables {
     267            0 :             let info = s.consumer_info(durable).await.map_err(|e| {
     268            0 :                 BusError(format!(
     269            0 :                     "topology: balanced durable '{durable}' missing on {CHANGES_STREAM}: {e}"
     270            0 :                 ))
     271            0 :             })?;
     272            0 :             if info.config.durable_name.as_deref() != Some(*durable) {
     273            0 :                 return Err(BusError(format!(
     274            0 :                     "topology: consumer '{durable}' is not durable — balanced work would \
     275            0 :                      not be shared across instances"
     276            0 :                 )));
     277            0 :             }
     278              :         }
     279            0 :         tracing::info!(
     280              :             durables = ?balanced_durables,
     281              :             "bus topology asserted: balanced durables present on {CHANGES_STREAM}"
     282              :         );
     283            0 :         Ok(())
     284            0 :     }
     285              : }
     286              : 
     287              : /// Decode one JetStream message into a `ChangeEvent`. `None` = alien bytes —
     288              : /// log-and-ack territory for the consumer (redelivering garbage forever is
     289              : /// the alternative). Defence in depth: the subject's tenant segment must agree with
     290              : /// the event body — consumers re-verify so a subject-mapping bug can never
     291              : /// route one tenant's change into another tenant's processing.
     292              : /// `ANTARES_NATS_REPLICAS`: absent = 1; present must be a positive integer.
     293           18 : fn replicas_from(v: Option<&str>) -> Result<usize, BusError> {
     294           18 :     match v {
     295            2 :         None => Ok(1),
     296           16 :         Some(raw) => raw
     297           16 :             .trim()
     298           16 :             .parse::<usize>()
     299           16 :             .ok()
     300           16 :             .filter(|n| *n >= 1)
     301           16 :             .ok_or_else(|| {
     302           12 :                 BusError(format!(
     303           12 :                     "ANTARES_NATS_REPLICAS must be a positive integer, got {raw:?}"
     304           12 :                 ))
     305           12 :             }),
     306              :     }
     307           18 : }
     308              : 
     309            0 : pub fn decode(msg: &async_nats::jetstream::Message) -> Option<ChangeEvent> {
     310            0 :     let ev: ChangeEvent = serde_json::from_slice(&msg.payload).ok()?;
     311            0 :     if !subject_tenant_agrees(msg.subject.as_str(), &ev) {
     312            0 :         tracing::error!(
     313            0 :             subject = %msg.subject,
     314            0 :             tenant = %ev.tenant.as_str(),
     315              :             "dropping change event: subject tenant segment disagrees with body"
     316              :         );
     317            0 :         return None;
     318            0 :     }
     319            0 :     Some(ev)
     320            0 : }
     321              : 
     322              : /// The tenant-agreement check, unit-testable: `changes.{tenant}.…` must carry the
     323              : /// event's own tenant. Non-`changes` subjects pass (registry deltas carry
     324              : /// tenant in the body only).
     325           18 : pub fn subject_tenant_agrees(subject: &str, ev: &ChangeEvent) -> bool {
     326           18 :     let mut parts = subject.split('.');
     327           18 :     if parts.next() != Some("changes") {
     328            2 :         return true;
     329           16 :     }
     330           16 :     parts.next() == Some(ev.tenant.as_str())
     331           18 : }
     332              : 
     333              : /// Drive a pull consumer as a message stream. Thin wrapper so wiring code
     334              : /// does not import futures/consumer types everywhere.
     335            0 : pub async fn messages(
     336            0 :     consumer: &consumer::PullConsumer,
     337            0 : ) -> Result<
     338            0 :     impl futures_util::Stream<
     339            0 :             Item = Result<
     340            0 :                 async_nats::jetstream::Message,
     341            0 :                 async_nats::error::Error<consumer::pull::MessagesErrorKind>,
     342            0 :             >,
     343            0 :         > + '_,
     344            0 :     BusError,
     345            0 : > {
     346            0 :     consumer.messages().await.map_err(err)
     347            0 : }
     348              : 
     349              : /// One decoded registry delta from the broadcast consumer, acked in place.
     350            0 : pub async fn next_delta(
     351            0 :     stream: &mut (impl futures_util::Stream<
     352            0 :         Item = Result<
     353            0 :             async_nats::jetstream::Message,
     354            0 :             async_nats::error::Error<consumer::pull::MessagesErrorKind>,
     355            0 :         >,
     356            0 :     > + Unpin),
     357            0 : ) -> Option<serde_json::Value> {
     358              :     loop {
     359            0 :         let msg = stream.next().await?.ok()?;
     360            0 :         let parsed = serde_json::from_slice(&msg.payload).ok();
     361            0 :         let _ = msg.ack().await;
     362            0 :         if parsed.is_some() {
     363            0 :             return parsed;
     364            0 :         }
     365              :     }
     366            0 : }
     367              : 
     368              : #[cfg(test)]
     369              : mod tests {
     370              :     use super::*;
     371              :     use crate::ChangeOp;
     372              :     use antares_model::{EntityId, TenantId};
     373              : 
     374              :     /// Config fatality: a typo'd replica count must never silently run a
     375              :     /// 3-node deployment at replicas=1 — that is a durability downgrade
     376              :     /// nobody chose. Absent stays 1; whitespace is tolerated; garbage and
     377              :     /// zero name the key in the error.
     378              :     #[test]
     379            2 :     fn replica_count_garbage_is_fatal_not_a_silent_default() {
     380            2 :         assert_eq!(replicas_from(None).map_err(|e| e.0), Ok(1));
     381            2 :         assert_eq!(replicas_from(Some("3")).map_err(|e| e.0), Ok(3));
     382            2 :         assert_eq!(replicas_from(Some(" 3 ")).map_err(|e| e.0), Ok(3));
     383           12 :         for bad in ["three", "", "0", "-1", "1.5", "3 nodes"] {
     384           12 :             let err = replicas_from(Some(bad))
     385           12 :                 .map(|_| ())
     386           12 :                 .expect_err("garbage must be refused")
     387              :                 .0;
     388           12 :             assert!(
     389           12 :                 err.contains("ANTARES_NATS_REPLICAS") && err.contains(bad.trim()),
     390              :                 "the error must name the key and the value: {err}"
     391              :             );
     392              :         }
     393            2 :     }
     394              : 
     395              :     #[test]
     396            2 :     fn subject_tenant_reverification_drops_mismatches() {
     397            2 :         let ev = ChangeEvent {
     398            2 :             tenant: TenantId::new("acme").expect("tenant"),
     399            2 :             entity_id: EntityId::new("urn:x:1").expect("id"),
     400            2 :             types: vec!["T".into()],
     401            2 :             op: ChangeOp::Create,
     402            2 :             changed_attrs: vec![],
     403            2 :             payload: None,
     404            2 :             prev_payload: None,
     405            2 :             version: 1,
     406            2 :             incarnation: String::new(),
     407            2 :             seq: 1,
     408            2 :             payload_ref: None,
     409            2 :             prev_payload_ref: None,
     410            2 :         };
     411            2 :         assert!(subject_tenant_agrees("changes.acme.aa.bb", &ev));
     412            2 :         assert!(
     413            2 :             !subject_tenant_agrees("changes.other.aa.bb", &ev),
     414              :             "a mis-mapped subject must be dropped, not processed"
     415              :         );
     416            2 :         assert!(
     417            2 :             subject_tenant_agrees("registry.other", &ev),
     418              :             "non-changes subjects carry tenant in the body only"
     419              :         );
     420            2 :     }
     421              : 
     422              :     /// The check compares whole tokens: a subject whose tenant segment only
     423              :     /// starts with, contains or is missing the event's tenant must not pass.
     424              :     #[test]
     425            2 :     fn tenant_agreement_is_not_fooled_by_partial_tokens() {
     426            2 :         let ev = ChangeEvent {
     427            2 :             tenant: TenantId::new("acme").expect("tenant"),
     428            2 :             entity_id: EntityId::new("urn:x:1").expect("id"),
     429            2 :             types: vec![],
     430            2 :             op: ChangeOp::Delete,
     431            2 :             changed_attrs: vec![],
     432            2 :             payload: None,
     433            2 :             prev_payload: None,
     434            2 :             version: 1,
     435            2 :             incarnation: String::new(),
     436            2 :             seq: 1,
     437            2 :             payload_ref: None,
     438            2 :             prev_payload_ref: None,
     439            2 :         };
     440           12 :         for subject in [
     441            2 :             "changes.acmeX.aa.bb", // longer token
     442            2 :             "changes.acm.aa.bb",   // prefix of the tenant
     443            2 :             "changes..aa.bb",      // empty tenant segment
     444            2 :             "changes.aa.acme.bb",  // tenant present, wrong position
     445            2 :             "changes",             // no tenant segment at all
     446            2 :             "changes.",
     447            2 :         ] {
     448           12 :             assert!(
     449           12 :                 !subject_tenant_agrees(subject, &ev),
     450              :                 "must not accept {subject:?}"
     451              :             );
     452              :         }
     453            2 :     }
     454              : }
        

Generated by: LCOV version 2.0-1