faucet_source_nats/
stream.rs1use 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
24pub struct NatsSource {
31 config: NatsSourceConfig,
32 client: OnceCell<async_nats::Client>,
33}
34
35impl NatsSource {
36 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 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
55fn 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
64enum Polled {
66 Record(Value),
68 Closed,
70 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 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 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 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 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 to_ack = std::mem::take(&mut page_msgs);
203 }
204
205 if stop {
206 break;
207 }
208 }
209
210 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 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
309fn 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
329fn idle_expired(deadline: Option<Instant>) -> bool {
331 deadline.is_some_and(|d| Instant::now() >= d)
332}
333
334async 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 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}