Skip to main content

faucet_source_sqs/
stream.rs

1//! The SQS `Source` implementation: long-poll `ReceiveMessage`, buffer to
2//! `batch_size`, delete each page's receipt handles **after** the page has been
3//! written downstream, and terminate on `idle_timeout_secs` / `max_messages`.
4//!
5//! ## Why deletion happens after the yield
6//!
7//! In an `async_stream` generator, statements before a `yield` run *before* the
8//! consumer sees the page; statements after it resume only once the consumer has
9//! come back for the next one — i.e. after the pipeline wrote the page to the
10//! sink and persisted it. Deleting on the near side of the `yield` would destroy
11//! the messages before anything durable had happened, so a sink error, an abort,
12//! or a crash would lose them permanently: at-most-once.
13//!
14//! So each page's receipt handles are parked in `pending` and deleted at the top
15//! of the following iteration (and once more after the final page resumes). A
16//! failure anywhere in between simply means the deletes never happen and SQS
17//! redelivers after the visibility timeout — at-least-once, as documented. This
18//! mirrors the Pub/Sub and NATS sources, which ack the same way (#456 C1).
19
20use 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
28/// AWS SQS source. See the crate README for semantics.
29pub struct SqsSource {
30    config: SqsSourceConfig,
31    client: Client,
32}
33
34/// Decode one SQS message body: the parsed JSON value if the body is valid
35/// JSON, otherwise the raw body wrapped as a JSON string. Pure.
36pub(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    /// Create a new SQS source. Validates the config and builds the AWS client;
42    /// no queue I/O happens until the first `ReceiveMessage` at stream time
43    /// (construction is offline).
44    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    /// Delete a page's receipt handles, chunked to the 10-entry API cap. A
56    /// whole-request failure propagates as a typed error; per-entry failures
57    /// are logged and left for SQS to redeliver (at-least-once).
58    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    /// Drain the queue to termination (`idle_timeout_secs` / `max_messages` —
103    /// at least one is enforced at construction) and return every message.
104    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            // `buffer` and `handles` stay index-aligned: one handle slot per
133            // buffered record (None if a message somehow lacked a receipt
134            // handle — such a record is emitted but not deleted, so SQS
135            // redelivers it, preserving at-least-once).
136            let mut buffer: Vec<Value> = Vec::new();
137            let mut handles: Vec<Option<String>> = Vec::new();
138            // Receipt handles of pages already yielded but not yet deleted. Drained
139            // at the top of the next iteration — by then the consumer has resumed
140            // us, so the page is durable downstream (see the module docs).
141            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                // Reached the message cap → stop.
151                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                // Emit every full page. Its handles are parked, not deleted: the
198                // page is not durable until the consumer resumes us.
199                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                    // Resumed ⇒ the page was written downstream; safe to delete.
205                    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            // Flush whatever is left as a final (short) page, then delete the
221            // last two pages' handles: `pending` (yielded in the loop above) and
222            // this page's, which is durable once the consumer resumes us. If the
223            // consumer stops polling instead, nothing is deleted and SQS
224            // redelivers — the safe direction.
225            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    /// Side-effect-free probe: `GetQueueAttributes` (no messages consumed). The
265    /// default first-page probe could block for the full long-poll window on a
266    /// quiet queue.
267    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()); // unroutable
310        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}