Skip to main content

av_bridge/
nats_bus.rs

1//! NATS JetStream connector (brief Module F: "lighter-weight edge/embedded
2//! alternative"). Feature `nats`. One JetStream stream per topic
3//! (`av_<topic>` with dots mapped to underscores), one subject per partition
4//! (`<topic>.p<N>`), giving the same partitioned-ordered-replay contract as
5//! the embedded broker.
6//!
7//! Contract tests gate on `AV_NATS_URL` and skip loudly when unset.
8
9use crate::bus::{partition_for, BusError, EventBus, PublishAck, StoredEvent};
10use crate::manifest::BridgeManifest;
11use std::collections::HashMap;
12
13/// NATS JetStream bus.
14pub struct NatsBus {
15    cold_archive: Option<crate::cold_store::ColdArchive>,
16    js: async_nats::jetstream::Context,
17    executor: crate::bus::ConnectorExecutor,
18    topics: HashMap<String, u32>,
19    validators: HashMap<String, jsonschema::Validator>,
20}
21
22fn stream_name(topic: &str) -> String {
23    format!("av_{}", topic.replace('.', "_"))
24}
25
26/// Pair up broker credentials, refusing half-configured auth loudly. A
27/// typo'd or unexported password must not silently downgrade the
28/// connection to anonymous (D13: a dropped credential is a silent error;
29/// same contract as the Kafka connector's `KafkaSecurity`).
30fn nats_credentials(
31    user: Option<String>,
32    password: Option<String>,
33) -> Result<Option<(String, String)>, BusError> {
34    match (user, password) {
35        (Some(user), Some(password)) => Ok(Some((user, password))),
36        (None, None) => Ok(None),
37        _ => Err(BusError::Backend(
38            "AV_NATS_USER and AV_NATS_PASSWORD must be set together".to_owned(),
39        )),
40    }
41}
42
43impl NatsBus {
44    /// Connect and provision streams per the manifest.
45    pub fn provision(url: &str, manifest: &BridgeManifest) -> Result<Self, BusError> {
46        manifest
47            .validate()
48            .map_err(|e| BusError::Backend(e.to_string()))?;
49        let validators = crate::manifest::compile_topic_validators(manifest)?;
50        let cold_archive = crate::cold_store::ColdArchive::from_manifest(manifest)?;
51        let executor = crate::bus::ConnectorExecutor::new("agentvisor-ai-nats")?;
52        // rustls 0.23 resolves its process-level CryptoProvider lazily and
53        // panics on first TLS use when more than one provider feature is
54        // compiled in (all-feature workspace builds carry both `ring` and
55        // `aws-lc-rs`) and none has been installed. Pin `ring` here; if the
56        // embedding application already installed one, keep theirs.
57        let _ = rustls_tls::crypto::ring::default_provider().install_default();
58        let url = url.to_owned();
59        // TLS/auth material comes from the environment so `tls://` endpoints
60        // work against private-CA deployments without new constructor
61        // surface: `AV_NATS_CA_FILE` pins a root CA (self-hosted enclaves
62        // rarely use WebPKI certs), `AV_NATS_USER`/`AV_NATS_PASSWORD` supply
63        // broker auth. Values are paths/identifiers, never logged.
64        let ca_file = std::env::var_os("AV_NATS_CA_FILE").map(std::path::PathBuf::from);
65        let credentials = nats_credentials(
66            std::env::var("AV_NATS_USER").ok(),
67            std::env::var("AV_NATS_PASSWORD").ok(),
68        )?;
69        let client = executor
70            .run(move || async move {
71                let mut options = async_nats::ConnectOptions::new();
72                // A pinned CA or supplied credentials both state intent:
73                // this connection must be TLS. Forcing the requirement means
74                // a `nats://` (instead of `tls://`) endpoint typo cannot
75                // silently downgrade to plaintext, and an active MITM
76                // cannot strip `tls_required` from INFO to capture the
77                // CONNECT password in cleartext. A CA file is deliberately
78                // not required for credentials — WebPKI-certified `tls://`
79                // endpoints are legitimate.
80                let secured = ca_file.is_some() || credentials.is_some();
81                if let Some(ca) = ca_file {
82                    options = options.add_root_certificates(ca);
83                }
84                if secured {
85                    options = options.require_tls(true);
86                }
87                if let Some((user, password)) = credentials {
88                    options = options.user_and_password(user, password);
89                }
90                options.connect(url).await
91            })?
92            .map_err(|e| BusError::Backend(e.to_string()))?;
93        // `async_nats::jetstream::new` must be called from within a Tokio
94        // runtime — the constructor eagerly calls `Handle::current()` and
95        // panics otherwise (async-nats 0.50, jetstream/context.rs:129).
96        // Route it through the executor so the sync `provision` entrypoint
97        // stays runtime-agnostic for callers.
98        let js = executor.run({
99            let client = client.clone();
100            move || async move { async_nats::jetstream::new(client) }
101        })?;
102        let mut topics = HashMap::new();
103        for t in &manifest.topics {
104            let subjects: Vec<String> = (0..t.partitions).map(|p| format!("{}.p{p}", t.name)).collect();
105            let context = js.clone();
106            // Match the KafkaBus retention arithmetic
107            // discipline. Today `hot_hours: u32` × 3600 fits in
108            // u64, but a future field-widening (e.g., u64 for
109            // very-long-retention research clusters) would
110            // silently wrap here and surface as `Overflow` on the
111            // Kafka path — the same cross-backend divergence
112            // already closed for counters. Use checked_mul
113            // now so a future widening surfaces the error
114            // consistently.
115            let retention_secs = u64::from(t.retention.hot_hours)
116                .checked_mul(3600)
117                .ok_or_else(|| BusError::Backend("NATS retention overflow".to_owned()))?;
118            let retention = std::time::Duration::from_secs(retention_secs);
119            let config = async_nats::jetstream::stream::Config {
120                name: stream_name(&t.name),
121                subjects: subjects.clone(),
122                max_age: retention,
123                duplicate_window: retention,
124                num_replicas: usize::try_from(manifest.replication_factor)
125                    .map_err(|_| BusError::Backend("NATS replication factor exceeds usize".to_owned()))?,
126                ..Default::default()
127            };
128            executor
129                .run(move || async move {
130                    context
131                        .get_or_create_stream(config.clone())
132                        .await
133                        .map_err(|error| error.to_string())?;
134                    context
135                        .update_stream(config.clone())
136                        .await
137                        .map_err(|error| error.to_string())?;
138                    let mut stream = context
139                        .get_stream(&config.name)
140                        .await
141                        .map_err(|error| error.to_string())?;
142                    let actual = stream
143                        .info()
144                        .await
145                        .map_err(|error| error.to_string())?
146                        .config
147                        .clone();
148                    if actual.subjects != subjects
149                        || actual.max_age != retention
150                        || actual.duplicate_window != retention
151                        || actual.num_replicas != config.num_replicas
152                    {
153                        return Err("JetStream stream does not match Bridge manifest".to_owned());
154                    }
155                    Ok::<_, String>(())
156                })?
157                .map_err(BusError::Backend)?;
158            topics.insert(t.name.clone(), t.partitions);
159        }
160        Ok(Self {
161            cold_archive,
162            js,
163            executor,
164            topics,
165            validators,
166        })
167    }
168
169    fn publish_with_uid(
170        &self,
171        topic: &str,
172        key: &str,
173        value: &serde_json::Value,
174        event_uid: Option<&str>,
175    ) -> Result<PublishAck, BusError> {
176        crate::manifest::validate_topic_event(&self.validators, topic, value)?;
177        let partitions = *self
178            .topics
179            .get(topic)
180            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
181        let partition = partition_for(key, partitions);
182        let event_uid = event_uid.map_or_else(av_core::new_event_uid, str::to_owned);
183        let stored_at = av_core::time::now_ms();
184        let record = StoredEvent {
185            partition,
186            offset: 0,
187            key: key.to_owned(),
188            value: value.clone(),
189            stored_at,
190        };
191        if let Some(archive) = &self.cold_archive {
192            let already_staged = archive.stage(topic, &record, &event_uid)?;
193            if already_staged {
194                // A prior attempt for this UID staged the intent and may
195                // have produced to the broker before its commit step
196                // failed (ENOSPC persisting the resolved offset, MAC
197                // refusal, crash). JetStream's Nats-Msg-Id dedup only
198                // covers the stream's duplicate_window, so a retry
199                // outside that window would append a second record for
200                // the same logical event — a duplicate in the audit
201                // stream. Consult the stream first, exactly like the
202                // maintenance retry path does.
203                if let Some(existing) = self.find_event_by_uid(topic, key, &event_uid)? {
204                    // The event is durably on the broker; resolving the
205                    // cold intent is best-effort here. On failure the
206                    // durable intent stays queued for the maintenance
207                    // pass — do not fail a publish that succeeded.
208                    if let Err(error) = archive.commit(topic, &event_uid, existing.offset) {
209                        tracing::warn!(
210                            %error,
211                            topic,
212                            event_uid = %event_uid,
213                            "cold intent commit failed after idempotent dedup hit; left for maintenance retry"
214                        );
215                    }
216                    return Ok(existing);
217                }
218            }
219        }
220        let ack = self.publish_broker_only(topic, key, value, stored_at, &event_uid)?;
221        if let Some(archive) = &self.cold_archive {
222            archive.commit(topic, &event_uid, ack.offset)?;
223        }
224        Ok(ack)
225    }
226
227    fn publish_broker_only(
228        &self,
229        topic: &str,
230        key: &str,
231        value: &serde_json::Value,
232        stored_at: u64,
233        event_uid: &str,
234    ) -> Result<PublishAck, BusError> {
235        crate::manifest::validate_topic_event(&self.validators, topic, value)?;
236        let partitions = *self
237            .topics
238            .get(topic)
239            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
240        let partition = partition_for(key, partitions);
241        let record = StoredEvent {
242            partition,
243            offset: 0,
244            key: key.to_owned(),
245            value: value.clone(),
246            stored_at,
247        };
248        let payload = serde_json::to_vec(&record)?;
249        let subject = format!("{topic}.p{partition}");
250        let context = self.js.clone();
251        let event_uid = event_uid.to_owned();
252        let ack = self
253            .executor
254            .run(move || async move {
255                let mut headers = async_nats::HeaderMap::new();
256                headers.insert("Nats-Msg-Id", event_uid);
257                context
258                    .publish_with_headers(subject, headers, payload.into())
259                    .await?
260                    .await
261            })?
262            .map_err(|e| BusError::Backend(e.to_string()))?;
263        Ok(PublishAck {
264            topic: topic.to_owned(),
265            partition,
266            offset: ack.sequence,
267        })
268    }
269}
270
271impl EventBus for NatsBus {
272    fn set_control_key(&self, key: [u8; 32]) -> Result<(), BusError> {
273        if let Some(archive) = &self.cold_archive {
274            archive.set_control_key(key)?;
275        }
276        Ok(())
277    }
278
279    fn publish(&self, topic: &str, key: &str, value: &serde_json::Value) -> Result<PublishAck, BusError> {
280        let event_uid = value
281            .get("metadata")
282            .and_then(|metadata| metadata.get("uid"))
283            .and_then(serde_json::Value::as_str);
284        self.publish_with_uid(topic, key, value, event_uid)
285    }
286
287    fn publish_idempotent(
288        &self,
289        topic: &str,
290        key: &str,
291        value: &serde_json::Value,
292        event_uid: &str,
293    ) -> Result<PublishAck, BusError> {
294        self.publish_with_uid(topic, key, value, Some(event_uid))
295    }
296
297    /// JetStream-aware UID lookup. The default trait implementation
298    /// treats an empty `fetch` as proof of absence, but a NATS batch
299    /// with a 500 ms `expires` can legitimately deliver zero messages
300    /// before expiry on a loaded server even when matching messages
301    /// exist — and a false "absent" makes crash-recovery maintenance
302    /// re-produce an already-committed event (a duplicate in the audit
303    /// stream once outside the JetStream dedup window). Use the
304    /// consumer's server-computed `num_pending` instead: zero pending
305    /// on a freshly-created filtered consumer proves absence; an empty
306    /// batch with pending remaining is a timing artifact and retries
307    /// bounded, then fails the pass (callers retry later — the cold
308    /// intent is durable).
309    fn find_event_by_uid(
310        &self,
311        topic: &str,
312        key: &str,
313        event_uid: &str,
314    ) -> Result<Option<PublishAck>, BusError> {
315        let partitions = *self
316            .topics
317            .get(topic)
318            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))?;
319        let partition = partition_for(key, partitions);
320        let stream_name = stream_name(topic);
321        let subject = format!("{topic}.p{partition}");
322        // One page per `executor.run` call: the executor imposes a hard
323        // 10 s timeout per operation, so running the whole from-sequence-1
324        // scan inside a single call would make lookups on large streams
325        // fail with a timeout on every attempt, forever (each retry
326        // restarts from sequence 1 under the same cap). Paging gives each
327        // bounded step its own budget — the same shape as the default
328        // trait implementation's per-`fetch` paging.
329        enum Page {
330            Found(u64),
331            Absent,
332            Advanced(u64),
333            Empty,
334        }
335        let mut offset = 1u64;
336        let mut empty_batches = 0u32;
337        loop {
338            let context = self.js.clone();
339            let page_stream = stream_name.clone();
340            let page_subject = subject.clone();
341            let page_uid = event_uid.to_owned();
342            let page = self
343                .executor
344                .run(move || async move {
345                    let stream = context.get_stream(&page_stream).await?;
346                    let consumer = stream
347                        .create_consumer(async_nats::jetstream::consumer::pull::Config {
348                            deliver_policy: async_nats::jetstream::consumer::DeliverPolicy::ByStartSequence {
349                                start_sequence: offset,
350                            },
351                            filter_subject: page_subject,
352                            // See `fetch`: ephemeral consumers without an
353                            // inactive_threshold linger server-side for the
354                            // lifetime of the connection.
355                            inactive_threshold: std::time::Duration::from_secs(30),
356                            ..Default::default()
357                        })
358                        .await?;
359                    if consumer.cached_info().num_pending == 0 {
360                        // Server-attested: no messages at or after `offset`
361                        // match the filter — genuinely absent.
362                        return Ok::<_, async_nats::Error>(Page::Absent);
363                    }
364                    let mut batch = consumer
365                        .batch()
366                        .max_messages(1_024)
367                        .expires(std::time::Duration::from_millis(500))
368                        .messages()
369                        .await?;
370                    let mut next_offset = offset;
371                    let mut delivered = false;
372                    use futures::StreamExt as _;
373                    while let Some(next) = batch.next().await {
374                        let msg = next?;
375                        delivered = true;
376                        let info = msg.info().map_err(|error| {
377                            async_nats::Error::from(std::io::Error::other(format!(
378                                "uid lookup info: {error}"
379                            )))
380                        })?;
381                        // `publish_broker_only` stamps the (possibly freshly
382                        // generated) uid only as the `Nats-Msg-Id` header;
383                        // for events without `metadata.uid` the payload scan
384                        // below can never find the record this bus produced,
385                        // so crash recovery re-published a duplicate once
386                        // outside the JetStream dedup window. Consult the
387                        // header first — same parity fix as KafkaBus's
388                        // `agentvisor-event-uid` header check.
389                        if msg
390                            .headers
391                            .as_ref()
392                            .and_then(|headers| headers.get("Nats-Msg-Id"))
393                            .is_some_and(|value| value.as_str() == page_uid.as_str())
394                        {
395                            return Ok(Page::Found(info.stream_sequence));
396                        }
397                        let stored: StoredEvent = serde_json::from_slice(&msg.payload).map_err(|error| {
398                            async_nats::Error::from(std::io::Error::other(format!(
399                                "uid lookup decode at sequence {}: {error}",
400                                info.stream_sequence
401                            )))
402                        })?;
403                        if stored
404                            .value
405                            .get("metadata")
406                            .and_then(|metadata| metadata.get("uid"))
407                            .and_then(serde_json::Value::as_str)
408                            == Some(page_uid.as_str())
409                        {
410                            return Ok(Page::Found(info.stream_sequence));
411                        }
412                        next_offset = info.stream_sequence.checked_add(1).ok_or_else(|| {
413                            async_nats::Error::from(std::io::Error::other("uid lookup sequence overflow"))
414                        })?;
415                    }
416                    Ok(if delivered {
417                        Page::Advanced(next_offset)
418                    } else {
419                        Page::Empty
420                    })
421                })?
422                .map_err(|e| BusError::Backend(e.to_string()))?;
423            match page {
424                Page::Found(sequence) => {
425                    return Ok(Some(PublishAck {
426                        topic: topic.to_owned(),
427                        partition,
428                        offset: sequence,
429                    }));
430                }
431                Page::Absent => return Ok(None),
432                Page::Advanced(next_offset) => {
433                    offset = next_offset;
434                    empty_batches = 0;
435                }
436                Page::Empty => {
437                    // Messages are pending past `offset` but the 500 ms
438                    // batch delivered nothing — a timing artifact, NOT
439                    // proof of absence. Retry bounded, then fail the
440                    // lookup so the caller retries the whole pass later.
441                    empty_batches += 1;
442                    if empty_batches >= 8 {
443                        return Err(BusError::Backend(format!(
444                            "uid lookup stalled: messages pending past sequence {offset} but batches deliver nothing"
445                        )));
446                    }
447                }
448            }
449        }
450    }
451
452    fn fetch(
453        &self,
454        topic: &str,
455        partition: u32,
456        offset: u64,
457        max: usize,
458    ) -> Result<Vec<StoredEvent>, BusError> {
459        let Some(&partitions) = self.topics.get(topic) else {
460            return Err(BusError::UnknownTopic(topic.to_owned()));
461        };
462        // Same contract as the embedded broker: an out-of-range
463        // partition is a caller bug and must error, not silently read
464        // an empty non-existent subject forever (a mis-partitioned
465        // reader would see "no data" instead of its own mistake).
466        if partition >= partitions {
467            return Err(BusError::Backend(format!("partition {partition} out of range")));
468        }
469        // Contract: "read up to `max` events". `max_messages(0)` has
470        // server-defined semantics and the loop below checks the cap only
471        // after pushing; return the embedded broker's answer directly.
472        if max == 0 {
473            return Ok(Vec::new());
474        }
475        let stream_name = stream_name(topic);
476        let subject = format!("{topic}.p{partition}");
477        let context = self.js.clone();
478        self.executor
479            .run(move || async move {
480                let stream = context.get_stream(&stream_name).await?;
481                let consumer = stream
482                    .create_consumer(async_nats::jetstream::consumer::pull::Config {
483                        deliver_policy: async_nats::jetstream::consumer::DeliverPolicy::ByStartSequence {
484                            start_sequence: offset.max(1),
485                        },
486                        filter_subject: subject,
487                        // Ephemeral consumers without an inactive_threshold
488                        // linger on the JetStream server until the client
489                        // disconnects. Since our async_nats client stays
490                        // connected for the lifetime of the process, every
491                        // fetch call otherwise leaks a consumer.
492                        inactive_threshold: std::time::Duration::from_secs(30),
493                        ..Default::default()
494                    })
495                    .await?;
496                let mut out = Vec::new();
497                let mut batch = consumer
498                    .batch()
499                    .max_messages(max)
500                    .expires(std::time::Duration::from_millis(500))
501                    .messages()
502                    .await?;
503                use futures::StreamExt as _;
504                while let Some(next) = batch.next().await {
505                    // Surface the error instead of silently continuing on:
506                    // an audit-trail consumer that sees a shorter list than
507                    // expected with no error is an evidence gap. The bad
508                    // record stays on JetStream as forensic evidence.
509                    let msg = next?;
510                    let mut ev: StoredEvent = serde_json::from_slice(&msg.payload).map_err(|error| {
511                        async_nats::Error::from(std::io::Error::other(format!("fetch decode: {error}")))
512                    })?;
513                    // Surface an info() failure like the payload-decode
514                    // path above: silently keeping the offset-0
515                    // placeholder would break offset-based pagination
516                    // (reconcilers resume from `last.offset + 1`, so a
517                    // zeroed offset restarts the scan from the head).
518                    let info = msg.info().map_err(|error| {
519                        async_nats::Error::from(std::io::Error::other(format!("fetch info: {error}")))
520                    })?;
521                    ev.offset = info.stream_sequence;
522                    out.push(ev);
523                    if out.len() >= max {
524                        break;
525                    }
526                }
527                Ok::<_, async_nats::Error>(out)
528            })?
529            .map_err(|e| BusError::Backend(e.to_string()))
530    }
531
532    fn partitions(&self, topic: &str) -> Result<u32, BusError> {
533        self.topics
534            .get(topic)
535            .copied()
536            .ok_or_else(|| BusError::UnknownTopic(topic.to_owned()))
537    }
538
539    fn topics(&self) -> Vec<String> {
540        let mut t: Vec<String> = self.topics.keys().cloned().collect();
541        t.sort();
542        t
543    }
544
545    fn maintenance(&self, _now_ms: u64) -> Result<u64, BusError> {
546        self.cold_archive.as_ref().map_or(Ok(0), |archive| {
547            archive.retry_pending_with(|pending| {
548                // Same rationale as `KafkaBus::maintenance`: a crash or
549                // executor timeout between a successful publish and the
550                // subsequent `commit()` leaves the intent offset-None
551                // while the event IS already on the stream. Blindly
552                // re-producing here would double the audit stream. NATS
553                // dedupe via `Nats-Msg-Id` catches this only inside the
554                // stream's `duplicate_window == retention` — a retry
555                // after retention expiry (or after a per-consumer
556                // stream reset) escapes it. Consult the stream first
557                // and only publish when the UID is genuinely absent.
558                if let Some(ack) = self.find_event_by_uid(&pending.topic, &pending.key, &pending.event_uid)? {
559                    return Ok(ack);
560                }
561                self.publish_broker_only(
562                    &pending.topic,
563                    &pending.key,
564                    &pending.value,
565                    pending.stored_at,
566                    &pending.event_uid,
567                )
568            })
569        })
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
576
577    use super::nats_credentials;
578
579    #[test]
580    fn credentials_pair_or_fail_loudly() {
581        assert!(nats_credentials(None, None).unwrap().is_none());
582        assert_eq!(
583            nats_credentials(Some("u".into()), Some("p".into())).unwrap(),
584            Some(("u".into(), "p".into()))
585        );
586        for (user, password) in [(Some("u".to_owned()), None), (None, Some("p".to_owned()))] {
587            let error = nats_credentials(user, password).unwrap_err();
588            assert!(
589                error.to_string().contains("must be set together"),
590                "partial credentials must fail loudly, got: {error}"
591            );
592        }
593    }
594}