faucet_source_sqs/
stream.rs1use crate::config::{MAX_RECEIVE_BATCH, SqsSourceConfig};
21use aws_sdk_sqs::Client;
22use aws_sdk_sqs::types::DeleteMessageBatchRequestEntry;
23use faucet_core::{FaucetError, Stream, StreamPage};
24use serde_json::Value;
25use std::pin::Pin;
26use std::time::{Duration, Instant};
27
28pub struct SqsSource {
30 config: SqsSourceConfig,
31 client: Client,
32}
33
34pub(crate) fn decode_body(body: &str) -> Value {
37 serde_json::from_str::<Value>(body).unwrap_or_else(|_| Value::String(body.to_string()))
38}
39
40impl SqsSource {
41 pub async fn new(config: SqsSourceConfig) -> Result<Self, FaucetError> {
45 config.validate()?;
46 let client = faucet_common_sqs::build_client(
47 config.region.as_deref(),
48 config.endpoint_url.as_deref(),
49 &config.credentials,
50 )
51 .await?;
52 Ok(Self { config, client })
53 }
54
55 async fn delete_handles(&self, handles: &[String]) -> Result<(), FaucetError> {
59 for chunk in handles.chunks(MAX_RECEIVE_BATCH as usize) {
60 let entries: Vec<DeleteMessageBatchRequestEntry> = chunk
61 .iter()
62 .enumerate()
63 .map(|(i, rh)| {
64 DeleteMessageBatchRequestEntry::builder()
65 .id(i.to_string())
66 .receipt_handle(rh)
67 .build()
68 .map_err(|e| {
69 FaucetError::Source(format!("sqs: delete entry build failed: {e}"))
70 })
71 })
72 .collect::<Result<_, _>>()?;
73 let out = self
74 .client
75 .delete_message_batch()
76 .queue_url(&self.config.queue_url)
77 .set_entries(Some(entries))
78 .send()
79 .await
80 .map_err(|e| {
81 FaucetError::Source(format!(
82 "sqs: DeleteMessageBatch on '{}' failed: {}",
83 self.config.queue_url,
84 e.into_service_error()
85 ))
86 })?;
87 for f in out.failed() {
88 tracing::warn!(
89 queue = %self.config.queue_url,
90 id = f.id(),
91 code = f.code(),
92 "sqs: message delete failed; message will be redelivered"
93 );
94 }
95 }
96 Ok(())
97 }
98}
99
100#[faucet_core::async_trait]
101impl faucet_core::Source for SqsSource {
102 async fn fetch_with_context(
105 &self,
106 context: &std::collections::HashMap<String, Value>,
107 ) -> Result<Vec<Value>, FaucetError> {
108 use futures::StreamExt;
109 let mut pages = self.stream_pages(context, self.config.batch_size);
110 let mut all = Vec::new();
111 while let Some(page) = pages.next().await {
112 all.extend(page?.records);
113 }
114 Ok(all)
115 }
116
117 fn stream_pages<'a>(
118 &'a self,
119 _context: &'a std::collections::HashMap<String, Value>,
120 _batch_size: usize,
121 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
122 let batch_size = self.config.batch_size;
123 let chunk = if batch_size == 0 {
124 usize::MAX
125 } else {
126 batch_size
127 };
128
129 Box::pin(async_stream::try_stream! {
130 let idle = self.config.idle_timeout_secs.map(Duration::from_secs);
131 let max = self.config.max_messages;
132 let mut buffer: Vec<Value> = Vec::new();
137 let mut handles: Vec<Option<String>> = Vec::new();
138 let mut pending: Vec<String> = Vec::new();
142 let mut total = 0usize;
143 let mut last_activity = Instant::now();
144
145 loop {
146 if !pending.is_empty() {
147 self.delete_handles(&std::mem::take(&mut pending)).await?;
148 }
149
150 let remaining = match max {
152 Some(m) if total >= m => break,
153 Some(m) => Some(m - total),
154 None => None,
155 };
156 let want = remaining
157 .map_or(MAX_RECEIVE_BATCH, |r| r.min(MAX_RECEIVE_BATCH as usize) as i32);
158
159 let resp = self
160 .client
161 .receive_message()
162 .queue_url(&self.config.queue_url)
163 .max_number_of_messages(want)
164 .wait_time_seconds(self.config.wait_time_seconds)
165 .send()
166 .await
167 .map_err(|e| {
168 FaucetError::Source(format!(
169 "sqs: ReceiveMessage on '{}' failed: {}",
170 self.config.queue_url,
171 e.into_service_error()
172 ))
173 })?;
174
175 let messages = resp.messages();
176 if messages.is_empty() {
177 if let Some(window) = idle
178 && last_activity.elapsed() >= window
179 {
180 tracing::info!(
181 queue = %self.config.queue_url,
182 idle_secs = window.as_secs(),
183 "sqs: idle termination reached"
184 );
185 break;
186 }
187 continue;
188 }
189 last_activity = Instant::now();
190
191 for msg in messages {
192 buffer.push(decode_body(msg.body().unwrap_or("")));
193 handles.push(msg.receipt_handle().map(str::to_string));
194 total += 1;
195 }
196
197 while buffer.len() >= chunk {
200 let page: Vec<Value> = buffer.drain(..chunk).collect();
201 let to_delete: Vec<String> =
202 handles.drain(..chunk).flatten().collect();
203 yield StreamPage { records: page, bookmark: None };
204 pending.extend(to_delete);
206 }
207
208 if let Some(m) = max
209 && total >= m
210 {
211 tracing::info!(
212 queue = %self.config.queue_url,
213 max = m,
214 "sqs: max_messages reached"
215 );
216 break;
217 }
218 }
219
220 if !buffer.is_empty() {
226 let to_delete: Vec<String> = handles.into_iter().flatten().collect();
227 yield StreamPage { records: buffer, bookmark: None };
228 pending.extend(to_delete);
229 }
230 if !pending.is_empty() {
231 self.delete_handles(&pending).await?;
232 }
233 tracing::info!(
234 queue = %self.config.queue_url,
235 records = total,
236 "sqs source stream complete"
237 );
238 })
239 }
240
241 fn config_schema(&self) -> Value {
242 serde_json::to_value(faucet_core::schema_for!(SqsSourceConfig))
243 .expect("schema serialization")
244 }
245
246 fn connector_name(&self) -> &'static str {
247 "sqs"
248 }
249
250 fn dataset_uri(&self) -> String {
251 let name = self
252 .config
253 .queue_url
254 .rsplit('/')
255 .find(|s| !s.is_empty())
256 .unwrap_or(self.config.queue_url.as_str());
257 format!(
258 "sqs://{}/{}",
259 self.config.region.as_deref().unwrap_or("default"),
260 name
261 )
262 }
263
264 async fn check(
268 &self,
269 ctx: &faucet_core::CheckContext,
270 ) -> Result<faucet_core::CheckReport, FaucetError> {
271 use faucet_core::{CheckReport, Probe};
272 let start = std::time::Instant::now();
273 let fut = self
274 .client
275 .get_queue_attributes()
276 .queue_url(&self.config.queue_url)
277 .send();
278 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
279 Err(_) => Probe::fail("get_queue_attributes", start.elapsed(), "timed out"),
280 Ok(Ok(_)) => Probe::pass("get_queue_attributes", start.elapsed()),
281 Ok(Err(e)) => Probe::fail(
282 "get_queue_attributes",
283 start.elapsed(),
284 e.into_service_error().to_string(),
285 ),
286 };
287 Ok(CheckReport::single(probe))
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use faucet_core::Source as _;
295
296 fn decode(s: &str) -> Value {
297 decode_body(s)
298 }
299
300 #[test]
301 fn decode_body_parses_json_else_string() {
302 assert_eq!(decode(r#"{"a":1}"#), serde_json::json!({"a": 1}));
303 assert_eq!(decode("[1,2,3]"), serde_json::json!([1, 2, 3]));
304 assert_eq!(decode("not json"), Value::String("not json".into()));
305 assert_eq!(decode(""), Value::String(String::new()));
306 }
307
308 async fn offline_source(mut config: SqsSourceConfig) -> SqsSource {
309 config.endpoint_url = Some("http://127.0.0.1:1".into()); config.region = Some("us-east-1".into());
311 config.credentials = faucet_common_sqs::SqsCredentials::AccessKey {
312 access_key_id: "test".into(),
313 secret_access_key: "test".into(),
314 session_token: None,
315 };
316 SqsSource::new(config).await.expect("source builds")
317 }
318
319 #[tokio::test]
320 async fn new_validates_config() {
321 let err = match SqsSource::new(SqsSourceConfig::new("https://q")).await {
322 Err(e) => e,
323 Ok(_) => panic!("config without a termination knob must be rejected"),
324 };
325 assert!(err.to_string().contains("idle_timeout_secs"), "{err}");
326 }
327
328 #[tokio::test]
329 async fn identity_overrides() {
330 let mut cfg = SqsSourceConfig::new("https://sqs.us-east-1.amazonaws.com/1/events");
331 cfg.max_messages = Some(10);
332 let source = offline_source(cfg).await;
333 assert_eq!(source.connector_name(), "sqs");
334 assert_eq!(source.dataset_uri(), "sqs://us-east-1/events");
335 assert_eq!(source.state_key(), None);
336 assert!(!source.supports_exactly_once());
337 let schema = source.config_schema();
338 assert!(
339 schema["properties"]["queue_url"].is_object(),
340 "schema exposes config fields"
341 );
342 }
343
344 #[tokio::test]
345 async fn stream_pages_surfaces_receive_errors() {
346 use futures::StreamExt;
347 let mut cfg = SqsSourceConfig::new("https://q");
348 cfg.max_messages = Some(10);
349 cfg.wait_time_seconds = 0;
350 let source = offline_source(cfg).await;
351 let ctx = std::collections::HashMap::new();
352 let mut pages = source.stream_pages(&ctx, 10);
353 let first = pages.next().await.expect("one item");
354 let err = first.unwrap_err();
355 assert!(err.to_string().contains("ReceiveMessage"), "{err}");
356 }
357
358 #[tokio::test]
359 async fn check_probe_fails_cleanly_offline() {
360 let mut cfg = SqsSourceConfig::new("https://q");
361 cfg.max_messages = Some(10);
362 let source = offline_source(cfg).await;
363 let report = source
364 .check(&faucet_core::CheckContext {
365 timeout: Duration::from_millis(500),
366 })
367 .await
368 .unwrap();
369 assert_eq!(
370 report.failed_count(),
371 1,
372 "unreachable endpoint → fail probe"
373 );
374 }
375}