1use std::time::Duration;
9
10use aws_sdk_sqs::Client;
11use aws_sdk_sqs::primitives::Blob;
12use aws_sdk_sqs::types::{
13 Message as AwsMessage, MessageAttributeValue, MessageSystemAttributeName,
14};
15use base64::Engine as _;
16use base64::engine::general_purpose::STANDARD as BASE64;
17use bytes::Bytes;
18use ruststream::{AckError, Headers, IncomingMessage, Partitioned};
19use tokio::task::JoinHandle;
20
21use crate::error::sdk_err;
22
23pub const PARTITION_KEY_HEADER: &str = "partition-key";
28
29pub const RECEIVE_COUNT_HEADER: &str = "sqs-receive-count";
31
32pub(crate) const ENCODING_ATTRIBUTE: &str = "ruststream-payload-encoding";
35
36pub struct SqsMessage {
46 payload: Bytes,
47 headers: Headers,
48 client: Client,
49 queue_url: String,
50 receipt: String,
51 extender: JoinHandle<()>,
52}
53
54impl std::fmt::Debug for SqsMessage {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 f.debug_struct("SqsMessage")
57 .field("payload_len", &self.payload.len())
58 .field("queue_url", &self.queue_url)
59 .finish_non_exhaustive()
60 }
61}
62
63impl Drop for SqsMessage {
64 fn drop(&mut self) {
65 self.extender.abort();
68 }
69}
70
71impl SqsMessage {
72 pub(crate) fn new(
73 message: &AwsMessage,
74 client: Client,
75 queue_url: String,
76 receipt: String,
77 visibility: Duration,
78 ) -> Self {
79 let (payload, headers) = decode_message(message);
80 let extender = tokio::spawn(extend_visibility(
85 client.clone(),
86 queue_url.clone(),
87 receipt.clone(),
88 visibility,
89 ));
90 Self {
91 payload,
92 headers,
93 client,
94 queue_url,
95 receipt,
96 extender,
97 }
98 }
99
100 async fn delete(&self) -> Result<(), AckError> {
101 self.client
102 .delete_message()
103 .queue_url(&self.queue_url)
104 .receipt_handle(&self.receipt)
105 .send()
106 .await
107 .map(|_| ())
108 .map_err(|e| AckError::Broker(sdk_err(&e)))
109 }
110
111 async fn set_visibility(&self, seconds: i32) -> Result<(), AckError> {
112 self.client
113 .change_message_visibility()
114 .queue_url(&self.queue_url)
115 .receipt_handle(&self.receipt)
116 .visibility_timeout(seconds)
117 .send()
118 .await
119 .map(|_| ())
120 .map_err(|e| AckError::Broker(sdk_err(&e)))
121 }
122}
123
124impl Partitioned for SqsMessage {
125 fn partition_key(&self) -> Option<&[u8]> {
126 self.headers.get(PARTITION_KEY_HEADER)
127 }
128}
129
130impl IncomingMessage for SqsMessage {
131 fn payload(&self) -> &[u8] {
132 &self.payload
133 }
134
135 fn headers(&self) -> &Headers {
136 &self.headers
137 }
138
139 async fn ack(self) -> Result<(), AckError> {
140 self.extender.abort();
141 self.delete().await
142 }
143
144 async fn nack(self, requeue: bool) -> Result<(), AckError> {
145 self.extender.abort();
146 if requeue {
147 self.set_visibility(0).await
148 } else {
149 self.delete().await
152 }
153 }
154
155 fn supports_nack_after(&self) -> bool {
159 true
160 }
161
162 async fn nack_after(self, delay: Duration) -> Result<(), AckError> {
163 self.extender.abort();
164 let seconds = i32::try_from(delay.as_secs().min(43_200)).unwrap_or(43_200);
167 self.set_visibility(seconds).await
168 }
169
170 fn partition_key(&self) -> Option<&[u8]> {
171 Partitioned::partition_key(self)
172 }
173}
174
175async fn extend_visibility(
179 client: Client,
180 queue_url: String,
181 receipt: String,
182 visibility: Duration,
183) {
184 let period = (visibility / 2).max(Duration::from_secs(1));
185 let seconds = i32::try_from(visibility.as_secs().min(43_200)).unwrap_or(43_200);
186 loop {
187 tokio::time::sleep(period).await;
188 let outcome = client
189 .change_message_visibility()
190 .queue_url(&queue_url)
191 .receipt_handle(&receipt)
192 .visibility_timeout(seconds)
193 .send()
194 .await;
195 if let Err(err) = outcome {
196 tracing::debug!(
197 queue_url = %queue_url,
198 error = %aws_sdk_sqs::error::DisplayErrorContext(&err),
199 "sqs visibility extension failed"
200 );
201 }
202 }
203}
204
205fn decode_message(message: &AwsMessage) -> (Bytes, Headers) {
206 let mut headers = Headers::new();
207 let mut base64_payload = false;
208 if let Some(attributes) = message.message_attributes() {
209 for (name, value) in attributes {
210 if name == ENCODING_ATTRIBUTE {
211 base64_payload = value.string_value() == Some("base64");
212 continue;
213 }
214 if let Some(text) = value.string_value() {
215 headers.insert(name.clone(), text.to_owned());
216 } else if let Some(blob) = value.binary_value() {
217 headers.insert(name.clone(), Bytes::copy_from_slice(blob.as_ref()));
218 }
219 }
220 }
221 if let Some(system) = message.attributes() {
222 if let Some(group) = system.get(&MessageSystemAttributeName::MessageGroupId) {
223 headers.insert(PARTITION_KEY_HEADER, group.clone());
224 }
225 if let Some(count) = system.get(&MessageSystemAttributeName::ApproximateReceiveCount) {
226 headers.insert(RECEIVE_COUNT_HEADER, count.clone());
227 }
228 }
229
230 let body = message.body().unwrap_or_default();
231 let payload = if base64_payload {
232 BASE64
233 .decode(body)
234 .map_or_else(|_| Bytes::copy_from_slice(body.as_bytes()), Bytes::from)
235 } else {
236 Bytes::copy_from_slice(body.as_bytes())
237 };
238 (payload, headers)
239}
240
241pub(crate) fn encode_body(payload: &[u8]) -> (String, bool) {
244 std::str::from_utf8(payload).map_or_else(
245 |_| (BASE64.encode(payload), true),
246 |text| (text.to_owned(), false),
247 )
248}
249
250pub(crate) fn encode_attributes(
253 headers: &Headers,
254 base64_marker: bool,
255) -> (
256 std::collections::HashMap<String, MessageAttributeValue>,
257 Option<String>,
258) {
259 let mut attributes = std::collections::HashMap::new();
260 let mut group = None;
261 for (name, value) in headers.iter() {
262 if name == PARTITION_KEY_HEADER {
263 group = Some(String::from_utf8_lossy(value).into_owned());
264 continue;
265 }
266 let attribute = std::str::from_utf8(value).map_or_else(
267 |_| {
268 MessageAttributeValue::builder()
269 .data_type("Binary")
270 .binary_value(Blob::new(value))
271 .build()
272 },
273 |text| {
274 MessageAttributeValue::builder()
275 .data_type("String")
276 .string_value(text)
277 .build()
278 },
279 );
280 if let Ok(attribute) = attribute {
281 attributes.insert(name.to_owned(), attribute);
282 }
283 }
284 if base64_marker
285 && let Ok(marker) = MessageAttributeValue::builder()
286 .data_type("String")
287 .string_value("base64")
288 .build()
289 {
290 attributes.insert(ENCODING_ATTRIBUTE.to_owned(), marker);
291 }
292 (attributes, group)
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn utf8_payloads_pass_through() {
301 let (body, marker) = encode_body(b"{\"id\":1}");
302 assert_eq!(body, "{\"id\":1}");
303 assert!(!marker);
304 }
305
306 #[test]
307 fn binary_payloads_travel_base64_with_marker() {
308 let raw = [0u8, 159, 146, 150];
309 let (body, marker) = encode_body(&raw);
310 assert!(marker);
311 assert_eq!(BASE64.decode(body).expect("valid base64"), raw);
312 }
313
314 #[test]
315 fn partition_key_header_becomes_the_group_id() {
316 let mut headers = Headers::new();
317 headers.insert(PARTITION_KEY_HEADER, "user-42");
318 headers.insert("x-tenant", "acme");
319 let (attributes, group) = encode_attributes(&headers, false);
320 assert_eq!(group.as_deref(), Some("user-42"));
321 assert!(attributes.contains_key("x-tenant"));
322 assert!(!attributes.contains_key(PARTITION_KEY_HEADER));
323 }
324
325 fn offline_client() -> Client {
328 let config = aws_config::SdkConfig::builder()
329 .behavior_version(aws_config::BehaviorVersion::latest())
330 .region(aws_config::Region::new("us-east-1"))
331 .build();
332 Client::new(&config)
333 }
334
335 #[tokio::test]
339 async fn deliveries_advertise_native_delayed_redelivery() {
340 let raw = AwsMessage::builder()
341 .body("{}")
342 .receipt_handle("receipt")
343 .build();
344 let message = SqsMessage::new(
345 &raw,
346 offline_client(),
347 "http://localhost:4566/000000000000/queue".to_owned(),
348 "receipt".to_owned(),
349 Duration::from_secs(30),
350 );
351 assert!(message.supports_nack_after());
352 }
353}