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}