use async_nats::{ Client, jetstream::{self, consumer::pull, message::AckKind, stream::Config as StreamConfig}, }; use async_trait::async_trait; use domain::{ errors::DomainError, events::{AckHandle, DomainEvent, EventEnvelope, EventId, retry_backoff}, ports::EventConsumer, }; use futures::{ StreamExt, stream::{self, BoxStream}, }; use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use crate::{config::NatsConfig, payload::NatsEventMessage, subject::consumer_subject_filter}; // ── JetStream ack handle ───────────────────────────────────────────────────── struct NatsJetStreamAckHandle { message: async_nats::jetstream::Message, event_id: Option, max_deliver: i64, } /// How to nak a failed delivery: the same backoff schedule as the SQLite /// queue, keyed by JetStream's delivery count (1 on first delivery). struct NakPlan { delay: std::time::Duration, /// This was the last delivery `max_deliver` allows. exhausted: bool, } fn nak_plan(delivered: i64, max_deliver: i64) -> NakPlan { NakPlan { delay: retry_backoff(u32::try_from(delivered.max(0)).unwrap_or(u32::MAX)), exhausted: max_deliver > 0 && delivered >= max_deliver, } } #[async_trait] impl AckHandle for NatsJetStreamAckHandle { async fn ack(&self) -> Result<(), DomainError> { tracing::debug!( "acknowledging message with sequence {}", self.message.info().unwrap().stream_sequence ); self.message .ack() .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } async fn nack(&self) -> Result<(), DomainError> { let info = self .message .info() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; let plan = nak_plan(info.delivered, self.max_deliver); if plan.exhausted { tracing::error!( event_id = ?self.event_id.map(|id| id.value()), sequence = info.stream_sequence, delivered = info.delivered, "event exhausted max deliveries; it remains in the stream — see JetStream max-deliveries advisory" ); } else { tracing::debug!( sequence = info.stream_sequence, delivered = info.delivered, "nak with backoff {:?}", plan.delay ); } self.message .ack_with(AckKind::Nak(Some(plan.delay))) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } /// JetStream counts every redelivery toward `max_deliver`, deferrals /// included. The delay is at least the remaining claim lease, so the /// deliveries allowed by `max_deliver` still span more than one lease. async fn defer(&self, delay: std::time::Duration) -> Result<(), DomainError> { self.message .ack_with(AckKind::Nak(Some(delay))) .await .map_err(|e| DomainError::InfrastructureError(e.to_string())) } } // ── Core NATS ack handle (no-op) ───────────────────────────────────────────── struct NoopAck; #[async_trait] impl AckHandle for NoopAck { async fn ack(&self) -> Result<(), DomainError> { Ok(()) } async fn nack(&self) -> Result<(), DomainError> { Ok(()) } async fn defer(&self, _: std::time::Duration) -> Result<(), DomainError> { Ok(()) } } // ── Envelope construction helpers ──────────────────────────────────────────── fn decode_message(bytes: &[u8]) -> Result<(Option, DomainEvent), DomainError> { let message: NatsEventMessage = serde_json::from_slice(bytes) .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; let id = message .event_id .as_deref() .map(|s| { uuid::Uuid::parse_str(s) .map(EventId::from_uuid) .map_err(|e| DomainError::InfrastructureError(format!("event_id: {e}"))) }) .transpose()?; Ok((id, DomainEvent::try_from(message.payload)?)) } fn decode_js( msg: async_nats::jetstream::Message, max_deliver: i64, ) -> Result { let (id, event) = decode_message(&msg.payload)?; Ok(EventEnvelope::new( id, event, Box::new(NatsJetStreamAckHandle { message: msg, event_id: id, max_deliver, }), )) } fn decode_core(msg: async_nats::Message) -> Result { let (id, event) = decode_message(&msg.payload)?; Ok(EventEnvelope::new(id, event, Box::new(NoopAck))) } // ── Channel-bridge shared by both consumers ────────────────────────────────── type EnvelopeRx = Arc>>>; fn consume_from_rx(rx: EnvelopeRx) -> BoxStream<'static, Result> { Box::pin(stream::unfold(rx, |rx| async move { let item = rx.lock().await.recv().await?; Some((item, rx)) })) } /// How long JetStream remembers `Nats-Msg-Id`s. A row re-relayed within this /// window (crash between publish and mark-done, or a lease reclaim) is dropped /// by the broker; a duplicate arriving later is caught by the processed-event /// ledger, which keeps its rows for 30 days. Set explicitly so it does not /// silently follow the server default. const DEDUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(60 * 60); const ACK_WAIT: std::time::Duration = std::time::Duration::from_secs(600); /// `Some(updated)` when an existing stream's config differs from what this /// build needs. `get_or_create_stream` only applies config on creation, so a /// stream created by an older build would otherwise keep the server default /// duplicate window (2 minutes). Only the fields this code owns are changed; /// everything else the operator set is preserved. fn stream_config_update(existing: &StreamConfig) -> Option { (existing.duplicate_window != DEDUP_WINDOW).then(|| StreamConfig { duplicate_window: DEDUP_WINDOW, ..existing.clone() }) } /// `Some(updated)` when an existing durable consumer disagrees on `max_deliver` /// or `ack_wait`. The update is a clone of the existing config with only those /// two fields changed, so operator-set fields survive and immutable ones /// (durable name, filter subject, deliver policy) cannot mismatch. fn consumer_config_update( existing: &jetstream::consumer::Config, desired_max_deliver: i64, ) -> Option { if existing.max_deliver == desired_max_deliver && existing.ack_wait == ACK_WAIT { return None; } Some(jetstream::consumer::Config { ack_wait: ACK_WAIT, max_deliver: desired_max_deliver, ..existing.clone() }) } // ── JetStream consumer ──────────────────────────────────────────────────────── pub struct NatsJetStreamConsumer { rx: EnvelopeRx, } impl NatsJetStreamConsumer { pub async fn create(cfg: &NatsConfig, client: Client) -> anyhow::Result { let js = jetstream::new(client); let stream = js .get_or_create_stream(StreamConfig { name: cfg.stream_name.clone(), subjects: vec![consumer_subject_filter(&cfg.subject_prefix)], max_messages: 100_000, duplicate_window: DEDUP_WINDOW, ..Default::default() }) .await?; if let Some(updated) = stream_config_update(&stream.cached_info().config) { tracing::warn!( stream = %cfg.stream_name, "existing JetStream stream has a different duplicate window; updating to {:?}", DEDUP_WINDOW ); if let Err(e) = js.update_stream(updated).await { tracing::warn!( "could not update stream {}: {e}; continuing. Fix manually: nats stream edit {} --dupe-window {}s", cfg.stream_name, cfg.stream_name, DEDUP_WINDOW.as_secs() ); } } let subject_filter = consumer_subject_filter(&cfg.subject_prefix); let consumer = stream .get_or_create_consumer( cfg.consumer_name.as_str(), pull::Config { durable_name: Some(cfg.consumer_name.clone()), filter_subject: subject_filter, ack_wait: ACK_WAIT, max_deliver: cfg.max_deliver, ..Default::default() }, ) .await?; if let Some(updated) = consumer_config_update(&consumer.cached_info().config, cfg.max_deliver) { tracing::warn!( consumer = %cfg.consumer_name, "existing JetStream consumer has different max_deliver/ack_wait; updating to max_deliver={}", cfg.max_deliver ); if let Err(e) = stream.update_consumer(updated).await { tracing::warn!( "could not update consumer {}: {e}; continuing. Fix manually: nats consumer edit {} {} --max-deliver {}", cfg.consumer_name, cfg.stream_name, cfg.consumer_name, cfg.max_deliver ); } } let (tx, rx) = mpsc::channel(128); let max_deliver = cfg.max_deliver; tokio::spawn(async move { loop { let mut messages = match consumer.messages().await { Err(e) => { tracing::error!("failed to fetch messages: {}", e); let _ = tx .send(Err(DomainError::InfrastructureError(e.to_string()))) .await; return; } Ok(m) => m, }; while let Some(result) = messages.next().await { let envelope = result .map_err(|e| DomainError::InfrastructureError(e.to_string())) .and_then(|m| decode_js(m, max_deliver)); if tx.send(envelope).await.is_err() { tracing::info!("consumer channel closed, stopping message processing"); return; } tracing::debug!("message sent to consumer channel"); } } }); Ok(Self { rx: Arc::new(Mutex::new(rx)), }) } } impl EventConsumer for NatsJetStreamConsumer { fn consume(&self) -> BoxStream<'_, Result> { consume_from_rx(Arc::clone(&self.rx)) } } // ── Core NATS consumer ──────────────────────────────────────────────────────── pub struct NatsCoreConsumer { rx: EnvelopeRx, } impl NatsCoreConsumer { pub async fn create(cfg: &NatsConfig, client: Client) -> anyhow::Result { let subject = consumer_subject_filter(&cfg.subject_prefix); let mut subscriber = client.subscribe(subject).await?; let (tx, rx) = mpsc::channel(128); tokio::spawn(async move { while let Some(msg) = subscriber.next().await { let envelope = decode_core(msg); tracing::debug!("message received and decoded, sending to consumer channel"); if tx.send(envelope).await.is_err() { tracing::info!("consumer channel closed, stopping message processing"); break; } } }); Ok(Self { rx: Arc::new(Mutex::new(rx)), }) } } impl EventConsumer for NatsCoreConsumer { fn consume(&self) -> BoxStream<'_, Result> { consume_from_rx(Arc::clone(&self.rx)) } } fn _assert_send_sync() { fn check() {} check::(); check::(); } #[cfg(test)] #[path = "tests/consumer.rs"] mod tests;