Skip to main content

laminar_connectors/nats/source/
mod.rs

1//! NATS source: `JetStream` pull consumer with bounded asynchronous acknowledgement, or core
2//! subscribe (at-most-once). Messages are acknowledged only after successful deserialization;
3//! the source remains explicitly ephemeral and does not couple broker acks to checkpoints.
4
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use arrow_schema::SchemaRef;
10use async_nats::jetstream::{self, consumer::pull};
11use async_trait::async_trait;
12use bytes::Bytes;
13use crossfire::{mpsc, AsyncRx, MAsyncTx, TryRecvError};
14use futures_util::stream::FuturesUnordered;
15use futures_util::StreamExt;
16use tokio::sync::{mpsc as tokio_mpsc, watch, Notify};
17use tokio::task::JoinHandle;
18use tracing::{debug, warn};
19
20use super::config::{build_connect_options, AckPolicy, DeliverPolicy, Mode, NatsSourceConfig};
21use super::metrics::NatsSourceMetrics;
22use super::setup::{
23    classify_connect_error, classify_create_consumer_error, classify_get_stream_error,
24    classify_subscribe_error, track_connection_tasks,
25};
26use crate::checkpoint::SourceCheckpoint;
27use crate::config::ConnectorConfig;
28use crate::connector::{
29    ConnectorTaskGuard, ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceConnector,
30    SourceConsistency, SourceContract, SourceInputMode, SourcePosition, SourceStart,
31    SourceTopology,
32};
33use crate::error::ConnectorError;
34use crate::serde::{self, RecordDeserializer};
35
36mod configuration;
37use configuration::build_pull_config;
38
39const ACK_IO_TIMEOUT: Duration = Duration::from_secs(5);
40const CLOSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
41const MAX_ACK_CONCURRENCY: usize = 64;
42const MAX_ACK_BACKLOG: usize = 16_384;
43
44/// `ack` is `Some` only on the `JetStream` path.
45struct Incoming {
46    payload: Bytes,
47    ack: Option<jetstream::Message>,
48}
49
50struct AckRuntime {
51    tx: Option<tokio_mpsc::Sender<jetstream::Message>>,
52    shutdown: watch::Sender<bool>,
53    task: TrackedTask,
54}
55
56struct Running {
57    deserializer: Box<dyn RecordDeserializer>,
58    rx: Option<AsyncRx<mpsc::Array<Incoming>>>,
59    shutdown: watch::Sender<bool>,
60    reader: TrackedTask,
61    ack_runtime: Option<AckRuntime>,
62}
63
64struct TrackedTask {
65    handle: Option<JoinHandle<()>>,
66    reaper_guard: Option<ConnectorTaskGuard>,
67    name: &'static str,
68}
69
70enum TaskWait {
71    Completed(Result<(), tokio::task::JoinError>),
72    TimedOut,
73}
74
75impl TrackedTask {
76    fn spawn(
77        owner: &ConnectorTaskOwner,
78        name: &'static str,
79        future: impl std::future::Future<Output = ()> + Send + 'static,
80    ) -> Result<Self, ConnectorError> {
81        let task_guard = owner.track().ok_or_else(|| {
82            ConnectorError::Internal("NATS source task generation is already retired".into())
83        })?;
84        let reaper_guard = owner.track().ok_or_else(|| {
85            ConnectorError::Internal("NATS source task generation is already retired".into())
86        })?;
87        let handle = tokio::spawn(async move {
88            let _task_guard = task_guard;
89            future.await;
90        });
91        Ok(Self {
92            handle: Some(handle),
93            reaper_guard: Some(reaper_guard),
94            name,
95        })
96    }
97
98    async fn wait_until(&mut self, deadline: tokio::time::Instant) -> TaskWait {
99        let Some(handle) = self.handle.as_mut() else {
100            return TaskWait::Completed(Ok(()));
101        };
102        match tokio::time::timeout_at(deadline, handle).await {
103            Ok(result) => {
104                self.handle.take();
105                self.reaper_guard.take();
106                TaskWait::Completed(result)
107            }
108            Err(_) => TaskWait::TimedOut,
109        }
110    }
111
112    fn retire(&mut self) {
113        let Some(handle) = self.handle.take() else {
114            self.reaper_guard.take();
115            return;
116        };
117        let reaper_guard = self.reaper_guard.take();
118        let Ok(runtime) = tokio::runtime::Handle::try_current() else {
119            // Runtime destruction drops the task future and its task guard. That guard is the
120            // completion proof; a join task is useful for cleanup but is not itself the proof.
121            drop(handle);
122            drop(reaper_guard);
123            return;
124        };
125        let name = self.name;
126        drop(runtime.spawn(async move {
127            let _reaper_guard = reaper_guard;
128            if let Err(error) = handle.await {
129                debug!(task = name, %error, "retired NATS source task reaped");
130            }
131        }));
132    }
133}
134
135impl Drop for TrackedTask {
136    fn drop(&mut self) {
137        self.retire();
138    }
139}
140
141impl Running {
142    fn request_shutdown(&self) {
143        self.shutdown.send_replace(true);
144    }
145}
146
147impl Drop for Running {
148    fn drop(&mut self) {
149        // Drop is the final backstop for a cancelled startup/close future. Tasks retain their
150        // generation guards until they actually exit; the reapers retain the join handles.
151        self.request_shutdown();
152        self.rx.take();
153        if let Some(ack_runtime) = self.ack_runtime.as_mut() {
154            ack_runtime.request_shutdown();
155            ack_runtime.task.retire();
156        }
157        self.reader.retire();
158    }
159}
160
161impl AckRuntime {
162    fn spawn(
163        cfg: &NatsSourceConfig,
164        metrics: NatsSourceMetrics,
165        owner: &ConnectorTaskOwner,
166    ) -> Result<Self, ConnectorError> {
167        let (backlog, concurrency) = ack_runtime_limits(cfg);
168        let (tx, rx) = tokio_mpsc::channel(backlog);
169        let (shutdown, shutdown_rx) = watch::channel(false);
170        let task = TrackedTask::spawn(
171            owner,
172            "ack-worker",
173            run_ack_worker(rx, shutdown_rx, concurrency, metrics),
174        )?;
175        Ok(Self {
176            tx: Some(tx),
177            shutdown,
178            task,
179        })
180    }
181
182    fn request_shutdown(&mut self) {
183        self.shutdown.send_replace(true);
184        self.tx.take();
185    }
186}
187
188impl Drop for AckRuntime {
189    fn drop(&mut self) {
190        self.request_shutdown();
191    }
192}
193
194/// NATS source — core and `JetStream` modes.
195pub struct NatsSource {
196    schema: SchemaRef,
197    config: Option<NatsSourceConfig>,
198    data_ready: Arc<Notify>,
199    metrics: NatsSourceMetrics,
200    running: Option<Running>,
201    task_owner: ConnectorTaskOwner,
202    task_tracker: ConnectorTaskTracker,
203}
204
205impl NatsSource {
206    /// Metrics register on `registry` if provided.
207    #[must_use]
208    pub fn new(schema: SchemaRef, registry: Option<&prometheus::Registry>) -> Self {
209        let (task_owner, task_tracker) = ConnectorTaskOwner::new();
210        Self {
211            schema,
212            config: None,
213            data_ready: Arc::new(Notify::new()),
214            metrics: NatsSourceMetrics::new(registry),
215            running: None,
216            task_owner,
217            task_tracker,
218        }
219    }
220
221    /// Available after [`SourceConnector::start`].
222    #[must_use]
223    pub fn config(&self) -> Option<&NatsSourceConfig> {
224        self.config.as_ref()
225    }
226
227    /// Snapshot accessor for the prometheus-backed metrics struct.
228    #[must_use]
229    pub fn metrics_handle(&self) -> &NatsSourceMetrics {
230        &self.metrics
231    }
232
233    async fn open_jetstream(
234        &mut self,
235        cfg: &NatsSourceConfig,
236        deserializer: Box<dyn RecordDeserializer>,
237    ) -> Result<(), ConnectorError> {
238        let client = connect(cfg, &self.task_owner).await?;
239        let js = jetstream::new(client);
240
241        let stream_name = cfg
242            .stream
243            .as_deref()
244            .ok_or_else(|| err("stream name missing after validation"))?;
245        let consumer_name = cfg
246            .consumer
247            .as_deref()
248            .ok_or_else(|| err("consumer name missing after validation"))?;
249
250        let pull_cfg = build_pull_config(cfg, consumer_name)?;
251        let stream = js
252            .get_stream(stream_name)
253            .await
254            .map_err(|error| classify_get_stream_error(&error, stream_name))?;
255        let consumer = stream
256            .create_consumer(pull_cfg)
257            .await
258            .map_err(|error| classify_create_consumer_error(&error, consumer_name))?;
259
260        let (tx, rx) = mpsc::bounded_async::<Incoming>(cfg.fetch_batch * 2);
261        let (shutdown, shutdown_rx) = watch::channel(false);
262        let requires_ack = cfg.ack_policy == AckPolicy::Explicit;
263        let ack_runtime = if requires_ack {
264            Some(AckRuntime::spawn(
265                cfg,
266                self.metrics.clone(),
267                &self.task_owner,
268            )?)
269        } else {
270            None
271        };
272
273        let reader = JsReader {
274            consumer,
275            tx,
276            shutdown: shutdown_rx,
277            consecutive_errors: Arc::new(AtomicU32::new(0)),
278            data_ready: Arc::clone(&self.data_ready),
279            metrics: self.metrics.clone(),
280            batch_size: cfg.fetch_batch,
281            max_wait: cfg.fetch_max_wait,
282            lag_poll_interval: cfg.lag_poll_interval,
283            requires_ack,
284        };
285        let reader = TrackedTask::spawn(&self.task_owner, "jetstream-reader", reader.run())?;
286
287        self.running = Some(Running {
288            deserializer,
289            rx: Some(rx),
290            shutdown,
291            reader,
292            ack_runtime,
293        });
294        Ok(())
295    }
296
297    async fn open_core(
298        &mut self,
299        cfg: &NatsSourceConfig,
300        deserializer: Box<dyn RecordDeserializer>,
301    ) -> Result<(), ConnectorError> {
302        let client = connect(cfg, &self.task_owner).await?;
303        let subject = cfg
304            .subject
305            .clone()
306            .ok_or_else(|| err("subject missing after validation"))?;
307        let subscriber = if let Some(group) = cfg.queue_group.as_deref() {
308            client
309                .queue_subscribe(subject, group.to_string())
310                .await
311                .map_err(|error| classify_subscribe_error(&error, "NATS queue subscribe"))?
312        } else {
313            client
314                .subscribe(subject)
315                .await
316                .map_err(|error| classify_subscribe_error(&error, "NATS subscribe"))?
317        };
318
319        let (tx, rx) = mpsc::bounded_async::<Incoming>(cfg.fetch_batch * 2);
320        let (shutdown, shutdown_rx) = watch::channel(false);
321
322        let reader = CoreReader {
323            subscriber,
324            tx,
325            shutdown: shutdown_rx,
326            data_ready: Arc::clone(&self.data_ready),
327        };
328        let reader = TrackedTask::spawn(&self.task_owner, "core-reader", reader.run())?;
329
330        self.running = Some(Running {
331            deserializer,
332            rx: Some(rx),
333            shutdown,
334            reader,
335            ack_runtime: None,
336        });
337        Ok(())
338    }
339}
340
341#[async_trait]
342impl SourceConnector for NatsSource {
343    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
344        Some(self.task_tracker.clone())
345    }
346
347    async fn resolve_schema(
348        &mut self,
349        config: &ConnectorConfig,
350        explicit: Option<SchemaRef>,
351    ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
352        let parsed = NatsSourceConfig::from_config(config)?;
353        crate::serde::schema_contract::reader_binding(config, parsed.format, explicit)
354    }
355
356    fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
357        let format = match config.get("format") {
358            Some(value) => serde::Format::parse(value)
359                .map_err(|error| ConnectorError::ConfigurationError(error.to_string()))?,
360            None => self
361                .config
362                .as_ref()
363                .map_or(serde::Format::Json, |config| config.format),
364        };
365        let input_mode = if format == serde::Format::Debezium {
366            SourceInputMode::KeyedUpsert
367        } else {
368            SourceInputMode::AppendOnly
369        };
370
371        // Neither Core NATS nor the current JetStream implementation can
372        // rewind an abandoned checkpoint attempt deterministically. Durable
373        // consumers alone are insufficient for LaminarDB replay semantics.
374        Ok(SourceContract::new(
375            SourceConsistency::Ephemeral,
376            SourceTopology::Singleton,
377            input_mode,
378        ))
379    }
380
381    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError> {
382        let (config, position, _) = request.into_parts();
383        if matches!(&position, SourcePosition::Initialized { .. }) {
384            return Err(ConnectorError::ConfigurationError(
385                "NATS has no sealed topology startup contract".into(),
386            ));
387        }
388        if let SourcePosition::Resume { attempt, .. } = position {
389            return Err(ConnectorError::ConfigurationError(format!(
390                "NATS is an ephemeral source and cannot resume checkpoint attempt {attempt:?}"
391            )));
392        }
393        let config = &config;
394
395        let cfg = NatsSourceConfig::from_config(config)?;
396        // Keep the candidate schema local until network admission succeeds so
397        // cancelling start leaves the existing instance unchanged.
398        let explicit = config
399            .arrow_schema()
400            .or_else(|| (!self.schema.fields().is_empty()).then(|| self.schema.clone()));
401        let binding = crate::serde::schema_contract::reader_binding(config, cfg.format, explicit)?;
402        if config
403            .schema_binding()
404            .is_some_and(|expected| expected.logical != binding.logical)
405        {
406            return Err(ConnectorError::SchemaMismatch(
407                "NATS reader differs from its committed contract".into(),
408            ));
409        }
410        let candidate_schema = Arc::new(binding.logical);
411        let deserializer = serde::create_deserializer(cfg.format)
412            .map_err(|e| err(&format!("deserializer for format {:?}: {e}", cfg.format)))?;
413        match cfg.mode {
414            Mode::JetStream => self.open_jetstream(&cfg, deserializer).await?,
415            Mode::Core => self.open_core(&cfg, deserializer).await?,
416        }
417        self.schema = candidate_schema;
418        self.config = Some(cfg);
419        Ok(())
420    }
421
422    async fn poll_batch(
423        &mut self,
424        max_records: usize,
425    ) -> Result<Option<SourceBatch>, ConnectorError> {
426        let Some(running) = self.running.as_mut() else {
427            return Ok(None);
428        };
429
430        let mut payloads: Vec<Bytes> = Vec::new();
431        let mut new_acks: Vec<jetstream::Message> = Vec::new();
432        let mut reader_disconnected = false;
433
434        while payloads.len() < max_records {
435            let incoming = match running
436                .rx
437                .as_mut()
438                .expect("running NATS source owns its receiver")
439                .try_recv()
440            {
441                Ok(m) => m,
442                Err(TryRecvError::Empty) => break,
443                Err(TryRecvError::Disconnected) => {
444                    reader_disconnected = true;
445                    break;
446                }
447            };
448            payloads.push(incoming.payload);
449            if let Some(msg) = incoming.ack {
450                new_acks.push(msg);
451            }
452        }
453
454        if payloads.is_empty() {
455            if reader_disconnected {
456                return Err(ConnectorError::ReadError(
457                    "NATS reader task terminated unexpectedly".into(),
458                ));
459            }
460            return Ok(None);
461        }
462
463        let records: Vec<&[u8]> = payloads.iter().map(Bytes::as_ref).collect();
464        let bytes_total: u64 = records.iter().map(|r| r.len() as u64).sum();
465        // Deserialize before scheduling acks: on failure the handles drop unacked and the broker
466        // redelivers after ack_wait. Ack enqueue is non-blocking to keep this poll path bounded.
467        let batch = running
468            .deserializer
469            .deserialize_batch(&records, &self.schema)
470            .map_err(|e| err(&format!("deserialize batch: {e}")))?;
471
472        enqueue_acks(running.ack_runtime.as_ref(), new_acks, &self.metrics);
473
474        self.metrics
475            .record_poll(batch.num_rows() as u64, bytes_total);
476
477        Ok(Some(SourceBatch::new(batch)))
478    }
479
480    fn schema(&self) -> SchemaRef {
481        self.schema.clone()
482    }
483
484    fn checkpoint(&self) -> SourceCheckpoint {
485        // Ephemeral sources deliberately expose no recovery cursor. JetStream acknowledgements
486        // are delivery progress, not checkpoint-owned state.
487        SourceCheckpoint::new()
488    }
489
490    async fn close(&mut self) -> Result<(), ConnectorError> {
491        let Some(mut running) = self.running.take() else {
492            return Ok(());
493        };
494        let close_deadline = tokio::time::Instant::now() + CLOSE_DRAIN_TIMEOUT;
495        running.request_shutdown();
496        match running.reader.wait_until(close_deadline).await {
497            TaskWait::Completed(Ok(())) => {}
498            TaskWait::Completed(Err(error)) => {
499                warn!(%error, "NATS reader task failed while closing");
500            }
501            TaskWait::TimedOut => warn!(
502                "NATS reader exceeded its close deadline; its tracked reaper retains shutdown ownership"
503            ),
504        }
505        // Drop unread messages only after the reader has stopped. Their unacked JetStream
506        // handles remain eligible for broker redelivery.
507        running.rx.take();
508
509        if let Some(mut ack_runtime) = running.ack_runtime.take() {
510            ack_runtime.request_shutdown();
511            match ack_runtime.task.wait_until(close_deadline).await {
512                TaskWait::Completed(Ok(())) => {}
513                TaskWait::Completed(Err(error)) => {
514                    warn!(%error, "NATS ack worker failed while closing");
515                    self.metrics.record_abandoned_acks();
516                }
517                TaskWait::TimedOut => {
518                    warn!(
519                        "NATS ack worker exceeded its close deadline; its tracked reaper retains shutdown ownership"
520                    );
521                }
522            }
523        }
524        Ok(())
525    }
526
527    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
528        Some(Arc::clone(&self.data_ready))
529    }
530}
531
532// ── helpers ──
533
534fn err(msg: &str) -> ConnectorError {
535    ConnectorError::ConfigurationError(msg.to_string())
536}
537
538fn ack_runtime_limits(cfg: &NatsSourceConfig) -> (usize, usize) {
539    let broker_limit = usize::try_from(cfg.max_ack_pending)
540        .ok()
541        .filter(|limit| *limit > 0);
542    let fallback = cfg.fetch_batch.saturating_mul(2);
543    let backlog = broker_limit.unwrap_or(fallback).clamp(1, MAX_ACK_BACKLOG);
544    let concurrency = cfg.fetch_batch.clamp(1, MAX_ACK_CONCURRENCY).min(backlog);
545    (backlog, concurrency)
546}
547
548fn enqueue_acks(
549    runtime: Option<&AckRuntime>,
550    messages: Vec<jetstream::Message>,
551    metrics: &NatsSourceMetrics,
552) {
553    if messages.is_empty() {
554        return;
555    }
556    let Some(runtime) = runtime else {
557        metrics.record_ack_enqueue_errors(messages.len());
558        warn!(
559            rejected = messages.len(),
560            "JetStream ack worker is unavailable; broker will redeliver"
561        );
562        return;
563    };
564
565    let mut rejected = 0usize;
566    for message in messages {
567        // Increment before publication: a fast worker may complete immediately after try_send.
568        metrics.record_ack_enqueued();
569        if runtime
570            .tx
571            .as_ref()
572            .is_none_or(|tx| tx.try_send(message).is_err())
573        {
574            metrics.record_ack_error();
575            rejected += 1;
576        }
577    }
578    if rejected > 0 {
579        warn!(
580            rejected,
581            "JetStream ack backlog is full or closed; broker will redeliver"
582        );
583    }
584}
585
586async fn run_ack_worker(
587    rx: tokio_mpsc::Receiver<jetstream::Message>,
588    shutdown: watch::Receiver<bool>,
589    concurrency: usize,
590    metrics: NatsSourceMetrics,
591) {
592    // Ack calls stay scoped under the worker. Its single generation guard therefore proves that
593    // the receiver and every in-flight acknowledgement have all been dropped or completed.
594    let task_metrics = metrics.clone();
595    let abandoned = run_bounded_queue(rx, shutdown, concurrency, move |message| {
596        let metrics = task_metrics.clone();
597        async move {
598            acknowledge_message(message, &metrics).await;
599        }
600    })
601    .await;
602    if abandoned > 0 {
603        metrics.record_ack_abandoned(abandoned);
604        warn!(
605            abandoned,
606            "discarded queued JetStream acknowledgements during shutdown; broker will redeliver"
607        );
608    }
609}
610
611async fn run_bounded_queue<T, F, Fut>(
612    mut rx: tokio_mpsc::Receiver<T>,
613    mut shutdown: watch::Receiver<bool>,
614    concurrency: usize,
615    process: F,
616) -> usize
617where
618    T: Send + 'static,
619    F: Fn(T) -> Fut,
620    Fut: std::future::Future<Output = ()> + Send,
621{
622    debug_assert!(concurrency > 0);
623    let mut in_flight = FuturesUnordered::new();
624    'input: loop {
625        if shutdown_requested(&shutdown) || rx.is_closed() {
626            break;
627        }
628        while in_flight.len() >= concurrency {
629            tokio::select! {
630                biased;
631                _ = shutdown.changed() => break 'input,
632                _ = in_flight.next() => {}
633            }
634            if rx.is_closed() {
635                break 'input;
636            }
637        }
638
639        tokio::select! {
640            biased;
641            _ = shutdown.changed() => break,
642            _ = in_flight.next(), if !in_flight.is_empty() => {}
643            message = rx.recv() => {
644                let Some(message) = message else {
645                    break;
646                };
647                in_flight.push(process(message));
648            }
649        }
650    }
651
652    // Closing the receiver makes queued-but-unstarted messages immediately eligible for broker
653    // redelivery. Only work already admitted to `in_flight` is allowed to consume the close budget.
654    rx.close();
655    let mut abandoned = 0usize;
656    while rx.try_recv().is_ok() {
657        abandoned = abandoned.saturating_add(1);
658    }
659    while in_flight.next().await.is_some() {}
660    abandoned
661}
662
663async fn acknowledge_message(message: jetstream::Message, metrics: &NatsSourceMetrics) {
664    match tokio::time::timeout(ACK_IO_TIMEOUT, message.double_ack()).await {
665        Ok(Ok(())) => metrics.record_ack(),
666        Ok(Err(error)) => {
667            metrics.record_ack_error();
668            warn!(%error, "JetStream ack failed; broker will redeliver");
669        }
670        Err(_) => {
671            metrics.record_ack_error();
672            warn!(
673                timeout_ms = ACK_IO_TIMEOUT.as_millis(),
674                "JetStream ack timed out; broker will redeliver"
675            );
676        }
677    }
678}
679
680async fn connect(
681    cfg: &NatsSourceConfig,
682    owner: &ConnectorTaskOwner,
683) -> Result<async_nats::Client, ConnectorError> {
684    track_connection_tasks(build_connect_options(&cfg.auth, &cfg.tls)?, owner, "source")?
685        .connect(&cfg.servers)
686        .await
687        .map_err(|error| classify_connect_error(&error))
688}
689
690fn map_ack_policy(p: AckPolicy) -> async_nats::jetstream::consumer::AckPolicy {
691    use async_nats::jetstream::consumer::AckPolicy as Nats;
692    match p {
693        AckPolicy::Explicit => Nats::Explicit,
694        AckPolicy::None => Nats::None,
695    }
696}
697
698/// 500ms, 1s, 2s, 4s, cap 5s.
699fn fetch_backoff_base(consecutive_errors: u32) -> Duration {
700    let exp = consecutive_errors.saturating_sub(1).min(4);
701    let ms = 500u64.saturating_mul(1u64 << exp);
702    Duration::from_millis(ms.min(5000))
703}
704
705/// `base ± 20%`. Tests pass a fixed `entropy` seed.
706fn with_jitter(base: Duration, entropy: u64) -> Duration {
707    let base_ms = u64::try_from(base.as_millis()).unwrap_or(u64::MAX);
708    let range = (base_ms / 5).max(1); // 20%
709    let window = range * 2 + 1;
710    let offset = entropy % window;
711    let jittered = base_ms.saturating_add(offset).saturating_sub(range);
712    Duration::from_millis(jittered)
713}
714
715fn fetch_backoff(consecutive_errors: u32, entropy: u64) -> Duration {
716    with_jitter(fetch_backoff_base(consecutive_errors), entropy)
717}
718
719/// Wall-clock nanos for `with_jitter`. `Instant::now().elapsed()` is ~0
720/// and produces correlated jitter across tasks.
721#[allow(clippy::cast_possible_truncation)]
722fn entropy_now() -> u64 {
723    std::time::SystemTime::now()
724        .duration_since(std::time::UNIX_EPOCH)
725        .unwrap_or_default()
726        .as_nanos() as u64
727}
728
729fn shutdown_requested(shutdown: &watch::Receiver<bool>) -> bool {
730    *shutdown.borrow() || shutdown.has_changed().is_err()
731}
732
733struct JsReader {
734    consumer: jetstream::consumer::Consumer<pull::Config>,
735    tx: MAsyncTx<mpsc::Array<Incoming>>,
736    shutdown: watch::Receiver<bool>,
737    consecutive_errors: Arc<AtomicU32>,
738    data_ready: Arc<Notify>,
739    metrics: NatsSourceMetrics,
740    batch_size: usize,
741    max_wait: Duration,
742    /// `Duration::ZERO` disables the poll.
743    lag_poll_interval: Duration,
744    requires_ack: bool,
745}
746
747impl JsReader {
748    async fn run(self) {
749        let Self {
750            mut consumer,
751            tx,
752            mut shutdown,
753            consecutive_errors,
754            data_ready,
755            metrics,
756            batch_size,
757            max_wait,
758            lag_poll_interval,
759            requires_ack,
760        } = self;
761
762        let mut last_lag_poll = Instant::now();
763        let lag_poll_enabled = !lag_poll_interval.is_zero();
764
765        loop {
766            if shutdown_requested(&shutdown) {
767                break;
768            }
769            let fetch_result = tokio::select! {
770                biased;
771                _ = shutdown.changed() => break,
772                r = consumer.fetch().max_messages(batch_size).expires(max_wait).messages() => r,
773            };
774
775            let mut stream = match fetch_result {
776                Ok(s) => s,
777                Err(e) => {
778                    let errs = consecutive_errors.fetch_add(1, Ordering::AcqRel) + 1;
779                    metrics.record_fetch_error();
780                    warn!(
781                        error = %e,
782                        consecutive_errors = errs,
783                        "nats fetch() errored; backing off",
784                    );
785                    let backoff = fetch_backoff(errs, entropy_now());
786                    tokio::select! {
787                        biased;
788                        _ = shutdown.changed() => break,
789                        () = tokio::time::sleep(backoff) => {}
790                    }
791                    continue;
792                }
793            };
794
795            let mut forwarded = 0usize;
796            let mut stream_errors = 0usize;
797            loop {
798                let msg_result = tokio::select! {
799                    biased;
800                    _ = shutdown.changed() => return,
801                    r = stream.next() => match r {
802                        Some(r) => r,
803                        None => break,
804                    },
805                };
806                let msg = match msg_result {
807                    Ok(m) => m,
808                    Err(e) => {
809                        metrics.record_fetch_error();
810                        stream_errors += 1;
811                        warn!(error = %e, "nats message error");
812                        continue;
813                    }
814                };
815                let payload = msg.payload.clone();
816                let incoming = Incoming {
817                    payload,
818                    ack: requires_ack.then_some(msg),
819                };
820                let send_result = tokio::select! {
821                    biased;
822                    _ = shutdown.changed() => return,
823                    result = tx.send(incoming) => result,
824                };
825                if send_result.is_err() {
826                    debug!("nats reader: downstream channel closed");
827                    return;
828                }
829                forwarded += 1;
830            }
831
832            // Reset on progress; an iteration with only errors counts
833            // as one failure; idle iterations don't bump.
834            if forwarded > 0 {
835                consecutive_errors.store(0, Ordering::Release);
836                data_ready.notify_one();
837            } else if stream_errors > 0 {
838                let errs = consecutive_errors.fetch_add(1, Ordering::AcqRel) + 1;
839                let backoff = fetch_backoff(errs, entropy_now());
840                tokio::select! {
841                    biased;
842                    _ = shutdown.changed() => break,
843                    () = tokio::time::sleep(backoff) => {}
844                }
845            }
846
847            if lag_poll_enabled && last_lag_poll.elapsed() >= lag_poll_interval {
848                last_lag_poll = Instant::now();
849                match consumer.info().await {
850                    Ok(info) => metrics.set_consumer_lag(info.num_pending),
851                    Err(e) => warn!(error = %e, "consumer.info() failed; skipping lag update"),
852                }
853            }
854        }
855    }
856}
857
858struct CoreReader {
859    subscriber: async_nats::Subscriber,
860    tx: MAsyncTx<mpsc::Array<Incoming>>,
861    shutdown: watch::Receiver<bool>,
862    data_ready: Arc<Notify>,
863}
864
865impl CoreReader {
866    async fn run(self) {
867        let Self {
868            mut subscriber,
869            tx,
870            mut shutdown,
871            data_ready,
872        } = self;
873
874        loop {
875            if shutdown_requested(&shutdown) {
876                break;
877            }
878            let msg = tokio::select! {
879                biased;
880                _ = shutdown.changed() => break,
881                m = subscriber.next() => match m {
882                    Some(m) => m,
883                    None => break,
884                },
885            };
886            let incoming = Incoming {
887                payload: msg.payload,
888                ack: None,
889            };
890            let send_result = tokio::select! {
891                biased;
892                _ = shutdown.changed() => break,
893                result = tx.send(incoming) => result,
894            };
895            if send_result.is_err() {
896                break;
897            }
898            data_ready.notify_one();
899        }
900        // Wake the coordinator so it observes the now-disconnected channel immediately instead
901        // of treating a terminated Core subscription as an indefinitely idle source.
902        data_ready.notify_one();
903    }
904}
905
906#[cfg(test)]
907mod tests;