Skip to main content

faucet_source_nats/
stream.rs

1//! `NatsSource` — the NATS consumer implementation (the one module that does I/O).
2//!
3//! Two modes, selected by config:
4//! - **Core NATS** — `client.subscribe(subject)` (optionally a queue group).
5//!   Fire-and-forget delivery: no bookmark, not resumable.
6//! - **JetStream** — pull from a durable consumer bound to an existing stream.
7//!   Each page's messages are acked *after* the page is yielded (i.e. after the
8//!   pipeline has written the previous page to the sink), giving at-least-once
9//!   delivery without claiming exactly-once.
10//!
11//! Both drain until `max_messages` or `idle_timeout_secs` fires, buffering up to
12//! `batch_size` records per [`StreamPage`] so memory stays bounded.
13
14use crate::config::NatsSourceConfig;
15use async_trait::async_trait;
16use faucet_core::{FaucetError, Source, Stream, StreamPage};
17use futures::StreamExt;
18use serde_json::Value;
19use std::collections::HashMap;
20use std::pin::Pin;
21use std::time::{Duration, Instant};
22use tokio::sync::OnceCell;
23
24/// A source that drains messages from a NATS subject (or a JetStream durable
25/// consumer) and emits each payload as a JSON record.
26///
27/// The client is built lazily on the first fetch/stream (see
28/// [`NatsSource::new`]), so an unreachable server fails on the first poll rather
29/// than at construction time.
30pub struct NatsSource {
31    config: NatsSourceConfig,
32    client: OnceCell<async_nats::Client>,
33}
34
35impl NatsSource {
36    /// Create a new NATS source. Validates the config but does **not** connect —
37    /// the client is built lazily on the first fetch/stream.
38    pub async fn new(config: NatsSourceConfig) -> Result<Self, FaucetError> {
39        config.validate()?;
40        Ok(Self {
41            config,
42            client: OnceCell::new(),
43        })
44    }
45
46    /// Lazily build (once) and return the shared NATS client.
47    async fn client(&self) -> Result<async_nats::Client, FaucetError> {
48        self.client
49            .get_or_try_init(|| faucet_common_nats::connect(&self.config.connection))
50            .await
51            .cloned()
52    }
53}
54
55/// Parse a raw NATS payload into a JSON record: valid JSON is passed through,
56/// anything else becomes a JSON string of the (lossy) UTF-8 text.
57fn payload_to_value(payload: &[u8]) -> Value {
58    match serde_json::from_slice::<Value>(payload) {
59        Ok(v) => v,
60        Err(_) => Value::String(String::from_utf8_lossy(payload).into_owned()),
61    }
62}
63
64/// The per-message poll outcome fed to the shared drain loop.
65enum Polled {
66    /// A decoded record (JetStream carries the message for a deferred ack).
67    Record(Value),
68    /// The underlying subscription/stream closed.
69    Closed,
70    /// The poll budget elapsed with no message.
71    Idle,
72}
73
74#[async_trait]
75impl Source for NatsSource {
76    async fn fetch_with_context(
77        &self,
78        context: &HashMap<String, Value>,
79    ) -> Result<Vec<Value>, FaucetError> {
80        // Reuse the streaming path so there is a single drain implementation.
81        let mut pages = self.stream_pages(context, self.config.batch_size);
82        let mut out = Vec::new();
83        while let Some(page) = pages.next().await {
84            out.extend(page?.records);
85        }
86        Ok(out)
87    }
88
89    /// Stream messages page-by-page. The trait-level `batch_size` argument is
90    /// ignored in favour of the config field (the user-facing knob).
91    ///
92    /// No page carries a bookmark: core NATS is fire-and-forget and the
93    /// JetStream path acks rather than persisting a resumable position, so this
94    /// source is not resumable / exactly-once (the defaults hold).
95    fn stream_pages<'a>(
96        &'a self,
97        _context: &'a HashMap<String, Value>,
98        _batch_size: usize,
99    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
100        let batch_size = self.config.batch_size;
101        let page_chunk = if batch_size == 0 {
102            usize::MAX
103        } else {
104            batch_size
105        };
106        let cap = if batch_size == 0 { 1024 } else { batch_size };
107        let max_messages = self.config.max_messages.unwrap_or(usize::MAX);
108        let idle = self.config.idle_timeout_secs.map(Duration::from_secs);
109        let poll_fallback = Duration::from_millis(500);
110
111        Box::pin(async_stream::try_stream! {
112            let client = self.client().await?;
113
114            if self.config.is_jetstream() {
115                // ── JetStream pull-consumer mode ────────────────────────────
116                let stream_name = self
117                    .config
118                    .jetstream_stream
119                    .as_deref()
120                    .expect("validated: jetstream_stream is Some in JetStream mode");
121                let consumer_name = self
122                    .config
123                    .jetstream_consumer
124                    .as_deref()
125                    .expect("validated: jetstream_consumer is Some in JetStream mode");
126
127                let js = async_nats::jetstream::new(client);
128                let js_stream = js
129                    .get_stream(stream_name)
130                    .await
131                    .map_err(|e| FaucetError::Source(format!("nats jetstream get_stream '{stream_name}': {e}")))?;
132                let consumer: async_nats::jetstream::consumer::PullConsumer = js_stream
133                    .get_consumer(consumer_name)
134                    .await
135                    .map_err(|e| FaucetError::Source(format!("nats jetstream get_consumer '{consumer_name}': {e}")))?;
136                let mut messages = consumer
137                    .messages()
138                    .await
139                    .map_err(|e| FaucetError::Source(format!("nats jetstream messages(): {e}")))?;
140
141                let mut buffer: Vec<Value> = Vec::with_capacity(cap);
142                // Messages for the page currently being buffered, acked after
143                // the page is yielded (i.e. once the pipeline has written it).
144                let mut page_msgs: Vec<async_nats::jetstream::Message> = Vec::with_capacity(cap);
145                let mut to_ack: Vec<async_nats::jetstream::Message> = Vec::new();
146                let mut total = 0usize;
147                let mut last_at = Instant::now();
148
149                loop {
150                    ack_all(std::mem::take(&mut to_ack)).await;
151
152                    let (budget, deadline) = poll_budget(idle, last_at, poll_fallback);
153                    let mut stop = false;
154                    let mut fatal: Option<FaucetError> = None;
155
156                    let polled = tokio::select! {
157                        biased;
158                        _ = tokio::signal::ctrl_c() => {
159                            tracing::info!("nats source: ctrl_c received, stopping");
160                            Polled::Closed
161                        }
162                        next = tokio::time::timeout(budget, messages.next()) => match next {
163                            Ok(Some(Ok(msg))) => {
164                                last_at = Instant::now();
165                                let record = payload_to_value(&msg.payload);
166                                page_msgs.push(msg);
167                                Polled::Record(record)
168                            }
169                            Ok(Some(Err(e))) => {
170                                fatal = Some(FaucetError::Source(format!("nats jetstream recv: {e}")));
171                                Polled::Idle
172                            }
173                            Ok(None) => Polled::Closed,
174                            Err(_elapsed) => Polled::Idle,
175                        }
176                    };
177
178                    if let Some(e) = fatal {
179                        Err(e)?;
180                    }
181
182                    match polled {
183                        Polled::Record(record) => {
184                            buffer.push(record);
185                            total += 1;
186                            if total >= max_messages {
187                                stop = true;
188                            }
189                        }
190                        Polled::Closed => stop = true,
191                        Polled::Idle => {
192                            if idle_expired(deadline) {
193                                stop = true;
194                            }
195                        }
196                    }
197
198                    if !buffer.is_empty() && buffer.len() >= page_chunk {
199                        let records = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
200                        yield StreamPage { records, bookmark: None };
201                        // Resumed ⇒ the page was written; ack its messages next iteration.
202                        to_ack = std::mem::take(&mut page_msgs);
203                    }
204
205                    if stop {
206                        break;
207                    }
208                }
209
210                // Flush any acks pending from the last full page, then the
211                // trailing partial page (and its acks).
212                ack_all(std::mem::take(&mut to_ack)).await;
213                if !buffer.is_empty() {
214                    yield StreamPage { records: buffer, bookmark: None };
215                    ack_all(std::mem::take(&mut page_msgs)).await;
216                }
217                tracing::info!(messages = total, "nats source: jetstream stream complete");
218            } else {
219                // ── Core NATS subscription mode ─────────────────────────────
220                let mut sub: Pin<Box<async_nats::Subscriber>> = Box::pin(match &self.config.queue_group {
221                    Some(group) => client
222                        .queue_subscribe(self.config.subject.clone(), group.clone())
223                        .await
224                        .map_err(|e| FaucetError::Source(format!("nats queue_subscribe '{}': {e}", self.config.subject)))?,
225                    None => client
226                        .subscribe(self.config.subject.clone())
227                        .await
228                        .map_err(|e| FaucetError::Source(format!("nats subscribe '{}': {e}", self.config.subject)))?,
229                });
230
231                let mut buffer: Vec<Value> = Vec::with_capacity(cap);
232                let mut total = 0usize;
233                let mut last_at = Instant::now();
234
235                loop {
236                    let (budget, deadline) = poll_budget(idle, last_at, poll_fallback);
237                    let mut stop = false;
238
239                    let polled = tokio::select! {
240                        biased;
241                        _ = tokio::signal::ctrl_c() => {
242                            tracing::info!("nats source: ctrl_c received, stopping");
243                            Polled::Closed
244                        }
245                        next = tokio::time::timeout(budget, sub.next()) => match next {
246                            Ok(Some(msg)) => {
247                                last_at = Instant::now();
248                                Polled::Record(payload_to_value(&msg.payload))
249                            }
250                            Ok(None) => Polled::Closed,
251                            Err(_elapsed) => Polled::Idle,
252                        }
253                    };
254
255                    match polled {
256                        Polled::Record(record) => {
257                            buffer.push(record);
258                            total += 1;
259                            if total >= max_messages {
260                                stop = true;
261                            }
262                        }
263                        Polled::Closed => stop = true,
264                        Polled::Idle => {
265                            if idle_expired(deadline) {
266                                stop = true;
267                            }
268                        }
269                    }
270
271                    if !buffer.is_empty() && buffer.len() >= page_chunk {
272                        let records = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
273                        yield StreamPage { records, bookmark: None };
274                    }
275
276                    if stop {
277                        break;
278                    }
279                }
280
281                if !buffer.is_empty() {
282                    yield StreamPage { records: buffer, bookmark: None };
283                }
284                tracing::info!(messages = total, "nats source: core stream complete");
285            }
286        })
287    }
288
289    fn config_schema(&self) -> Value {
290        serde_json::to_value(faucet_core::schema_for!(NatsSourceConfig)).unwrap_or(Value::Null)
291    }
292
293    fn connector_name(&self) -> &'static str {
294        "nats"
295    }
296
297    fn dataset_uri(&self) -> String {
298        let server = self
299            .config
300            .connection
301            .servers
302            .first()
303            .map(String::as_str)
304            .unwrap_or("unknown");
305        format!("nats://{server}?subject={}", self.config.subject)
306    }
307}
308
309/// Compute the poll timeout for this iteration and the idle deadline (if any).
310/// With no idle timeout configured we poll in short bursts so `ctrl_c` and
311/// `max_messages` termination stay responsive.
312fn poll_budget(
313    idle: Option<Duration>,
314    last_at: Instant,
315    fallback: Duration,
316) -> (Duration, Option<Instant>) {
317    match idle {
318        Some(t) => {
319            let deadline = last_at + t;
320            let budget = deadline
321                .checked_duration_since(Instant::now())
322                .unwrap_or(Duration::ZERO);
323            (budget, Some(deadline))
324        }
325        None => (fallback, None),
326    }
327}
328
329/// Whether the idle deadline (if set) has passed.
330fn idle_expired(deadline: Option<Instant>) -> bool {
331    deadline.is_some_and(|d| Instant::now() >= d)
332}
333
334/// Ack a page's JetStream messages best-effort — a failed ack triggers at most
335/// a redelivery (at-least-once), never data loss, so it is logged not fatal.
336async fn ack_all(messages: Vec<async_nats::jetstream::Message>) {
337    for msg in messages {
338        if let Err(e) = msg.ack().await {
339            tracing::warn!(error = %e, "nats source: jetstream ack failed (message may be redelivered)");
340        }
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn payload_json_passthrough() {
350        let v = payload_to_value(br#"{"id":1,"name":"a"}"#);
351        assert_eq!(v["id"], 1);
352        assert_eq!(v["name"], "a");
353    }
354
355    #[test]
356    fn payload_non_json_becomes_string() {
357        let v = payload_to_value(b"hello world");
358        assert_eq!(v, Value::String("hello world".into()));
359    }
360
361    #[test]
362    fn payload_invalid_utf8_lossy_string() {
363        let v = payload_to_value(&[0xff, 0xfe, 0x00]);
364        assert!(v.is_string());
365    }
366
367    #[test]
368    fn poll_budget_none_uses_fallback() {
369        let (budget, deadline) = poll_budget(None, Instant::now(), Duration::from_millis(500));
370        assert_eq!(budget, Duration::from_millis(500));
371        assert!(deadline.is_none());
372    }
373
374    #[test]
375    fn poll_budget_idle_sets_deadline() {
376        let (_budget, deadline) = poll_budget(
377            Some(Duration::from_secs(5)),
378            Instant::now(),
379            Duration::from_millis(500),
380        );
381        assert!(deadline.is_some());
382    }
383
384    #[test]
385    fn idle_expired_true_when_past() {
386        let past = Instant::now() - Duration::from_secs(1);
387        assert!(idle_expired(Some(past)));
388    }
389
390    #[test]
391    fn idle_expired_false_when_none() {
392        assert!(!idle_expired(None));
393    }
394
395    #[tokio::test]
396    async fn new_validates_config() {
397        let mut cfg = NatsSourceConfig::new("x");
398        cfg.idle_timeout_secs = None;
399        cfg.max_messages = None;
400        assert!(NatsSource::new(cfg).await.is_err());
401    }
402
403    #[tokio::test]
404    async fn connector_name_and_uri() {
405        let source = NatsSource::new(NatsSourceConfig::new("events.>"))
406            .await
407            .unwrap();
408        assert_eq!(source.connector_name(), "nats");
409        assert!(source.dataset_uri().contains("subject=events.>"));
410    }
411
412    #[tokio::test]
413    async fn unreachable_server_errors_on_first_poll() {
414        let mut cfg = NatsSourceConfig::new("events.>");
415        cfg.connection.servers = vec!["nats://127.0.0.1:1".into()];
416        cfg.idle_timeout_secs = Some(1);
417        let source = NatsSource::new(cfg)
418            .await
419            .expect("lazy construction succeeds");
420        // First poll connects and must surface a typed error, not panic.
421        let ctx = HashMap::new();
422        let mut pages = source.stream_pages(&ctx, 10);
423        let first = pages.next().await;
424        assert!(matches!(first, Some(Err(FaucetError::Custom(_)))));
425    }
426}