1use crate::error::{ErrorData, Result};
33use crate::providers::local_store::{as_i64, as_text, query_all, LocalStore, StoreSpec};
34use crate::traits::{
35 Binding, MessagePayload, Queue, QueueMessage, LEASE_SECONDS, MAX_BATCH_SIZE, MAX_MESSAGE_BYTES,
36};
37use alien_core::bindings::LocalQueueBinding;
38use alien_error::{AlienError, Context as _, IntoAlienError as _};
39use async_trait::async_trait;
40use chrono::Utc;
41use std::path::PathBuf;
42use std::time::Duration;
43use turso::transaction::TransactionBehavior;
44use turso::Connection;
45
46static QUEUE_SPEC: StoreSpec = StoreSpec {
47 db_filename: "localqueue.sqlite",
48 format_version: "localqueue.v1",
49 binding_type: "local queue",
50 schema_ddl: "CREATE TABLE IF NOT EXISTS messages (\
51 id INTEGER PRIMARY KEY AUTOINCREMENT,\
52 payload_type TEXT NOT NULL,\
53 payload_data TEXT NOT NULL,\
54 enqueued_at INTEGER NOT NULL,\
55 visible_at INTEGER NOT NULL,\
56 attempt INTEGER NOT NULL DEFAULT 0,\
57 receipt_handle TEXT\
58 );\
59 CREATE INDEX IF NOT EXISTS idx_messages_visible ON messages (visible_at, enqueued_at, id);",
60};
61
62#[derive(Debug)]
69pub struct LocalQueue {
70 store: LocalStore,
71}
72
73fn queue_error(operation: &str, reason: String) -> ErrorData {
75 ErrorData::QueueOperationFailed {
76 operation: operation.to_string(),
77 reason,
78 }
79}
80
81fn encode_payload(payload: MessagePayload) -> Result<(&'static str, String)> {
84 match payload {
85 MessagePayload::Json(v) => {
86 let data = serde_json::to_string(&v)
87 .into_alien_error()
88 .context(queue_error(
89 "send",
90 "failed to serialize JSON payload".to_string(),
91 ))?;
92 Ok(("json", data))
93 }
94 MessagePayload::Text(s) => Ok(("text", s)),
95 }
96}
97
98fn decode_payload(payload_type: &str, payload_data: String) -> Result<MessagePayload> {
100 match payload_type {
101 "json" => {
102 let value = serde_json::from_str(&payload_data)
103 .into_alien_error()
104 .context(queue_error(
105 "receive",
106 "failed to deserialize stored JSON payload".to_string(),
107 ))?;
108 Ok(MessagePayload::Json(value))
109 }
110 "text" => Ok(MessagePayload::Text(payload_data)),
111 other => Err(AlienError::new(queue_error(
112 "receive",
113 format!("unknown payload_type '{other}' in localqueue.v1 store"),
114 ))),
115 }
116}
117
118impl LocalQueue {
119 pub async fn new(data_dir: PathBuf) -> Result<Self> {
124 Ok(Self {
125 store: LocalStore::open(data_dir, &QUEUE_SPEC).await?,
126 })
127 }
128
129 pub async fn from_binding(binding: LocalQueueBinding) -> Result<Self> {
131 let queue_path = binding
132 .queue_path
133 .into_value("queue", "queue_path")
134 .context(ErrorData::config_invalid(
135 "queue",
136 "Failed to resolve queue_path from binding",
137 ))?;
138
139 Self::new(PathBuf::from(queue_path)).await
140 }
141
142 pub fn data_dir(&self) -> &PathBuf {
144 self.store.data_dir()
145 }
146
147 fn parse_receipt_handle(receipt_handle: &str) -> Option<(i64, String)> {
152 let (id, receipt) = receipt_handle.split_once(':')?;
153 let id: i64 = id.parse().ok()?;
154 if receipt.is_empty() {
155 return None;
156 }
157 Some((id, receipt.to_string()))
158 }
159
160 async fn receive_inner(
164 &self,
165 max_messages: usize,
166 visibility: Duration,
167 ) -> Result<Vec<QueueMessage>> {
168 self.store
169 .with_conn(|conn| async move {
170 let mut conn = conn;
171 let now = Utc::now().timestamp_millis();
172 let visible_until =
173 now.saturating_add(i64::try_from(visibility.as_millis()).unwrap_or(i64::MAX));
174 let limit = i64::try_from(max_messages).unwrap_or(i64::MAX);
175
176 let tx = conn
179 .transaction_with_behavior(TransactionBehavior::Immediate)
180 .await
181 .into_alien_error()
182 .context(queue_error(
183 "receive",
184 "failed to begin immediate transaction".to_string(),
185 ))?;
186
187 let id_rows = query_all(
188 &tx,
189 "SELECT id FROM messages WHERE visible_at <= ?1 \
190 ORDER BY enqueued_at, id LIMIT ?2",
191 (now, limit),
192 )
193 .await
194 .into_alien_error()
195 .context(queue_error(
196 "receive",
197 "failed to scan due messages".to_string(),
198 ))?;
199 let mut ids = Vec::with_capacity(id_rows.len());
200 for row in &id_rows {
201 ids.push(row.first().and_then(as_i64).ok_or_else(|| {
202 AlienError::new(queue_error(
203 "receive",
204 "failed to read due-message row".to_string(),
205 ))
206 })?);
207 }
208
209 let mut messages = Vec::with_capacity(ids.len());
210 for id in ids {
211 let receipt = uuid::Uuid::new_v4().to_string();
213 let claimed = query_all(
214 &tx,
215 "UPDATE messages \
216 SET visible_at = ?1, attempt = attempt + 1, receipt_handle = ?2 \
217 WHERE id = ?3 \
218 RETURNING payload_type, payload_data, attempt",
219 (visible_until, receipt.as_str(), id),
220 )
221 .await
222 .into_alien_error()
223 .context(queue_error(
224 "receive",
225 format!("failed to claim message {id}"),
226 ))?;
227 let row = claimed.first().ok_or_else(|| {
228 AlienError::new(queue_error(
229 "receive",
230 format!("claim of message {id} returned no row"),
231 ))
232 })?;
233 let payload_type = row.first().and_then(as_text).ok_or_else(|| {
234 AlienError::new(queue_error(
235 "receive",
236 format!("claimed message {id} has a non-text payload_type"),
237 ))
238 })?;
239 let payload_data = row.get(1).and_then(as_text).ok_or_else(|| {
240 AlienError::new(queue_error(
241 "receive",
242 format!("claimed message {id} has a non-text payload_data"),
243 ))
244 })?;
245 let attempt = row
246 .get(2)
247 .and_then(as_i64)
248 .and_then(|n| u32::try_from(n).ok())
249 .ok_or_else(|| {
250 AlienError::new(queue_error(
251 "receive",
252 format!("claimed message {id} has an invalid attempt count"),
253 ))
254 })?;
255 messages.push(QueueMessage {
256 payload: decode_payload(&payload_type, payload_data)?,
257 receipt_handle: format!("{id}:{receipt}"),
258 attempt,
259 });
260 }
261
262 tx.commit().await.into_alien_error().context(queue_error(
263 "receive",
264 "failed to commit receive transaction".to_string(),
265 ))?;
266 Ok(messages)
267 })
268 .await
269 }
270}
271
272async fn message_exists(conn: &Connection, id: i64, operation: &str) -> Result<bool> {
277 let rows = query_all(conn, "SELECT 1 FROM messages WHERE id = ?1", (id,))
278 .await
279 .into_alien_error()
280 .context(queue_error(
281 operation,
282 format!("failed to check message {id} existence"),
283 ))?;
284 Ok(!rows.is_empty())
285}
286
287fn stale_receipt_error(id: i64, operation: &str) -> AlienError<ErrorData> {
288 AlienError::new(queue_error(
289 operation,
290 format!(
291 "stale receipt handle for message {id}: the message was redelivered and a newer receipt supersedes this one"
292 ),
293 ))
294}
295
296impl Binding for LocalQueue {}
297
298#[async_trait]
299impl Queue for LocalQueue {
300 async fn send(&self, _queue: &str, message: MessagePayload) -> Result<()> {
301 let (payload_type, payload_data) = encode_payload(message)?;
303 if payload_data.len() > MAX_MESSAGE_BYTES {
304 return Err(AlienError::new(ErrorData::BindingSetupFailed {
305 binding_type: "queue.local".to_string(),
306 reason: format!(
307 "Message size {} bytes exceeds limit of {} bytes",
308 payload_data.len(),
309 MAX_MESSAGE_BYTES
310 ),
311 }));
312 }
313
314 self.store
315 .with_conn(|conn| async move {
316 let now = Utc::now().timestamp_millis();
317 conn.execute(
318 "INSERT INTO messages (payload_type, payload_data, enqueued_at, visible_at, attempt) \
319 VALUES (?1, ?2, ?3, ?3, 0)",
320 (payload_type, payload_data.as_str(), now),
321 )
322 .await
323 .into_alien_error()
324 .context(queue_error("send", "failed to insert message".to_string()))?;
325 Ok(())
326 })
327 .await
328 }
329
330 async fn receive(&self, _queue: &str, max_messages: usize) -> Result<Vec<QueueMessage>> {
331 if max_messages == 0 || max_messages > MAX_BATCH_SIZE {
332 return Err(AlienError::new(ErrorData::BindingSetupFailed {
333 binding_type: "queue.local".to_string(),
334 reason: format!(
335 "Batch size {} is invalid. Must be between 1 and {}",
336 max_messages, MAX_BATCH_SIZE
337 ),
338 }));
339 }
340
341 self.receive_inner(max_messages, Duration::from_secs(LEASE_SECONDS))
342 .await
343 }
344
345 async fn ack(&self, _queue: &str, receipt_handle: &str) -> Result<()> {
346 let Some((id, receipt)) = Self::parse_receipt_handle(receipt_handle) else {
349 return Ok(());
350 };
351
352 self.store
353 .with_conn(|conn| async move {
354 let mut conn = conn;
355 let tx = conn
356 .transaction_with_behavior(TransactionBehavior::Immediate)
357 .await
358 .into_alien_error()
359 .context(queue_error(
360 "ack",
361 "failed to begin immediate transaction".to_string(),
362 ))?;
363 let deleted = tx
364 .execute(
365 "DELETE FROM messages WHERE id = ?1 AND receipt_handle = ?2",
366 (id, receipt.as_str()),
367 )
368 .await
369 .into_alien_error()
370 .context(queue_error("ack", format!("failed to ack message {id}")))?;
371 if deleted == 0 && message_exists(&tx, id, "ack").await? {
372 return Err(stale_receipt_error(id, "ack"));
376 }
377 tx.commit().await.into_alien_error().context(queue_error(
378 "ack",
379 "failed to commit ack transaction".to_string(),
380 ))?;
381 Ok(())
382 })
383 .await
384 }
385
386 async fn nack(&self, _queue: &str, receipt_handle: &str) -> Result<()> {
392 let Some((id, receipt)) = Self::parse_receipt_handle(receipt_handle) else {
393 return Ok(());
394 };
395
396 self.store
397 .with_conn(|conn| async move {
398 let mut conn = conn;
399 let now = Utc::now().timestamp_millis();
400 let tx = conn
401 .transaction_with_behavior(TransactionBehavior::Immediate)
402 .await
403 .into_alien_error()
404 .context(queue_error(
405 "nack",
406 "failed to begin immediate transaction".to_string(),
407 ))?;
408 let updated = tx
409 .execute(
410 "UPDATE messages SET visible_at = ?1 WHERE id = ?2 AND receipt_handle = ?3",
411 (now, id, receipt.as_str()),
412 )
413 .await
414 .into_alien_error()
415 .context(queue_error("nack", format!("failed to nack message {id}")))?;
416 if updated == 0 && message_exists(&tx, id, "nack").await? {
417 return Err(stale_receipt_error(id, "nack"));
418 }
419 tx.commit().await.into_alien_error().context(queue_error(
420 "nack",
421 "failed to commit nack transaction".to_string(),
422 ))?;
423 Ok(())
424 })
425 .await
426 }
427
428 async fn purge(&self, _queue: &str) -> Result<()> {
430 self.store
431 .with_conn(|conn| async move {
432 conn.execute("DELETE FROM messages", ())
433 .await
434 .into_alien_error()
435 .context(queue_error("purge", "failed to purge queue".to_string()))?;
436 Ok(())
437 })
438 .await
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::providers::local_store::open_database;
446 use std::collections::BTreeSet;
447 use std::sync::Arc;
448 use std::time::Duration;
449 use tempfile::TempDir;
450 use tokio::time;
451
452 fn payload_text(msg: &QueueMessage) -> String {
453 match &msg.payload {
454 MessagePayload::Text(s) => s.clone(),
455 MessagePayload::Json(v) => v.to_string(),
456 }
457 }
458
459 fn handle_id(receipt_handle: &str) -> i64 {
461 receipt_handle
462 .split_once(':')
463 .expect("receipt handle must be '{id}:{uuid}'")
464 .0
465 .parse()
466 .expect("receipt handle id must be an integer")
467 }
468
469 async fn raw_conn(queue: &LocalQueue) -> Connection {
471 let db = open_database(&queue.data_dir().join("localqueue.sqlite"), "test")
472 .await
473 .expect("raw open");
474 db.connect().expect("raw connect")
475 }
476
477 async fn create_test_queue() -> (LocalQueue, TempDir) {
478 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
479 let queue = LocalQueue::new(temp_dir.path().join("queue.db"))
480 .await
481 .expect("Failed to create LocalQueue");
482 (queue, temp_dir)
483 }
484
485 #[tokio::test]
486 async fn test_send_and_receive() {
487 let (queue, _temp_dir) = create_test_queue().await;
488
489 queue
490 .send("q", MessagePayload::Text("hello".to_string()))
491 .await
492 .unwrap();
493 queue
494 .send("q", MessagePayload::Text("world".to_string()))
495 .await
496 .unwrap();
497
498 let msgs = queue.receive("q", 10).await.unwrap();
499 assert_eq!(msgs.len(), 2);
500 assert_eq!(payload_text(&msgs[0]), "hello");
501 assert_eq!(payload_text(&msgs[1]), "world");
502 }
503
504 #[tokio::test]
505 async fn test_receive_empty_queue() {
506 let (queue, _temp_dir) = create_test_queue().await;
507
508 let msgs = queue.receive("q", 10).await.unwrap();
509 assert!(msgs.is_empty());
510 }
511
512 #[tokio::test]
513 async fn test_ack_removes_message() {
514 let (queue, _temp_dir) = create_test_queue().await;
515
516 queue
517 .send("q", MessagePayload::Text("msg".to_string()))
518 .await
519 .unwrap();
520
521 let msgs = queue.receive("q", 1).await.unwrap();
522 assert_eq!(msgs.len(), 1);
523
524 queue.ack("q", &msgs[0].receipt_handle).await.unwrap();
526
527 let msgs = queue.receive("q", 10).await.unwrap();
529 assert!(msgs.is_empty());
530 }
531
532 #[tokio::test]
533 async fn test_ack_idempotent() {
534 let (queue, _temp_dir) = create_test_queue().await;
535
536 queue.ack("q", "non-existent-handle").await.unwrap();
538
539 queue
542 .send("q", MessagePayload::Text("msg".to_string()))
543 .await
544 .unwrap();
545 let msgs = queue.receive("q", 1).await.unwrap();
546 assert_eq!(msgs.len(), 1);
547 queue.ack("q", &msgs[0].receipt_handle).await.unwrap();
548 queue.ack("q", &msgs[0].receipt_handle).await.unwrap();
549 }
550
551 #[tokio::test]
552 async fn test_receive_respects_max_messages() {
553 let (queue, _temp_dir) = create_test_queue().await;
554
555 for i in 0..5 {
556 queue
557 .send("q", MessagePayload::Text(format!("msg-{}", i)))
558 .await
559 .unwrap();
560 }
561
562 let msgs = queue.receive("q", 2).await.unwrap();
563 assert_eq!(msgs.len(), 2);
564 assert_eq!(payload_text(&msgs[0]), "msg-0");
565 assert_eq!(payload_text(&msgs[1]), "msg-1");
566 }
567
568 #[tokio::test]
569 async fn test_json_payload() {
570 let (queue, _temp_dir) = create_test_queue().await;
571
572 let payload = serde_json::json!({"key": "value", "num": 42});
573 queue
574 .send("q", MessagePayload::Json(payload.clone()))
575 .await
576 .unwrap();
577
578 let msgs = queue.receive("q", 1).await.unwrap();
579 assert_eq!(msgs.len(), 1);
580 match &msgs[0].payload {
581 MessagePayload::Json(v) => assert_eq!(v, &payload),
582 _ => panic!("Expected JSON payload"),
583 }
584 }
585
586 #[tokio::test]
587 async fn test_message_size_validation() {
588 let (queue, _temp_dir) = create_test_queue().await;
589
590 let large = "x".repeat(MAX_MESSAGE_BYTES + 1);
591 let result = queue.send("q", MessagePayload::Text(large)).await;
592 assert!(result.is_err());
593 }
594
595 #[tokio::test]
596 async fn test_batch_size_validation() {
597 let (queue, _temp_dir) = create_test_queue().await;
598
599 assert!(queue.receive("q", 0).await.is_err());
600 assert!(queue.receive("q", MAX_BATCH_SIZE + 1).await.is_err());
601 }
602
603 #[tokio::test]
604 async fn test_persistence_across_reopens() {
605 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
606 let db_path = temp_dir.path().join("queue.db");
607
608 {
610 let queue = LocalQueue::new(db_path.clone()).await.unwrap();
611 queue
612 .send("q", MessagePayload::Text("persistent".to_string()))
613 .await
614 .unwrap();
615 }
616
617 {
619 let queue = LocalQueue::new(db_path).await.unwrap();
620 let msgs = queue.receive("q", 1).await.unwrap();
621 assert_eq!(msgs.len(), 1);
622 assert_eq!(payload_text(&msgs[0]), "persistent");
623 }
624 }
625
626 #[tokio::test]
627 async fn test_fifo_ordering() {
628 let (queue, _temp_dir) = create_test_queue().await;
629
630 for i in 0..10 {
631 queue
632 .send("q", MessagePayload::Text(format!("{}", i)))
633 .await
634 .unwrap();
635 }
636
637 let msgs = queue.receive("q", 10).await.unwrap();
638 for (i, msg) in msgs.iter().enumerate() {
639 assert_eq!(payload_text(msg), format!("{}", i));
640 }
641 }
642
643 #[tokio::test]
644 async fn test_unknown_format_rejected_on_open() {
645 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
646 let dir = temp_dir.path().join("queue");
647
648 {
650 let queue = LocalQueue::new(dir.clone()).await.expect("initial open");
651 queue
652 .send("q", MessagePayload::Text("m".to_string()))
653 .await
654 .unwrap();
655 }
656 {
657 let db = open_database(&dir.join("localqueue.sqlite"), "test")
658 .await
659 .expect("raw open");
660 let conn = db.connect().expect("raw connect");
661 conn.execute(
662 "UPDATE meta SET value = 'localqueue.v2' WHERE key = 'format'",
663 (),
664 )
665 .await
666 .expect("format overwrite");
667 }
668
669 let err = LocalQueue::new(dir)
671 .await
672 .expect_err("unknown format must be rejected");
673 let msg = err.to_string();
674 assert!(
675 msg.contains("localqueue.v2"),
676 "error must name the found format, got: {msg}"
677 );
678 assert!(
679 msg.contains("localqueue.v1"),
680 "error must name the expected format, got: {msg}"
681 );
682 }
683
684 #[tokio::test]
687 async fn test_visibility_timeout_redelivery_increments_attempt() {
688 let (queue, _temp_dir) = create_test_queue().await;
689
690 queue
691 .send("q", MessagePayload::Text("retry-me".to_string()))
692 .await
693 .unwrap();
694
695 let first = queue
697 .receive_inner(1, Duration::from_millis(100))
698 .await
699 .unwrap();
700 assert_eq!(first.len(), 1);
701 let id = handle_id(&first[0].receipt_handle);
702
703 let hidden = queue.receive("q", 10).await.unwrap();
705 assert!(hidden.is_empty(), "in-flight message must be hidden");
706
707 time::sleep(Duration::from_millis(250)).await;
709 let second = queue.receive("q", 10).await.unwrap();
710 assert_eq!(second.len(), 1, "expired message must be redelivered");
711 assert_eq!(payload_text(&second[0]), "retry-me");
712 assert_eq!(
713 handle_id(&second[0].receipt_handle),
714 id,
715 "redelivery must be the same message row"
716 );
717 assert_ne!(
718 second[0].receipt_handle, first[0].receipt_handle,
719 "each delivery must mint a fresh receipt handle"
720 );
721
722 assert_eq!(first[0].attempt, 1, "first delivery must report attempt 1");
724 assert_eq!(
725 second[0].attempt, 2,
726 "redelivery must report attempt 2 to the caller"
727 );
728
729 let conn = raw_conn(&queue).await;
731 let rows = query_all(&conn, "SELECT attempt FROM messages WHERE id = ?1", (id,))
732 .await
733 .expect("attempt read");
734 let attempt = rows
735 .first()
736 .and_then(|row| row.first())
737 .and_then(as_i64)
738 .expect("attempt value");
739 assert_eq!(attempt, 2, "two deliveries must mean attempt == 2");
740 }
741
742 #[tokio::test]
743 async fn test_stale_receipt_rejected() {
744 let (queue, _temp_dir) = create_test_queue().await;
745
746 queue
747 .send("q", MessagePayload::Text("contested".to_string()))
748 .await
749 .unwrap();
750
751 let a = queue
753 .receive_inner(1, Duration::from_millis(100))
754 .await
755 .unwrap();
756 assert_eq!(a.len(), 1);
757
758 time::sleep(Duration::from_millis(250)).await;
760 let b = queue.receive("q", 1).await.unwrap();
761 assert_eq!(b.len(), 1);
762 assert_ne!(a[0].receipt_handle, b[0].receipt_handle);
763
764 let err = queue
766 .ack("q", &a[0].receipt_handle)
767 .await
768 .expect_err("stale receipt ack must be rejected");
769 assert!(
770 err.to_string().to_lowercase().contains("stale"),
771 "error should identify the stale receipt, got: {err}"
772 );
773
774 queue.ack("q", &b[0].receipt_handle).await.unwrap();
776 let remaining = queue.receive("q", 10).await.unwrap();
777 assert!(remaining.is_empty(), "acked message must be gone");
778 }
779
780 #[tokio::test]
781 async fn test_nack_makes_message_immediately_visible() {
782 let (queue, _temp_dir) = create_test_queue().await;
783
784 queue
785 .send("q", MessagePayload::Text("try-again".to_string()))
786 .await
787 .unwrap();
788
789 let msgs = queue.receive("q", 1).await.unwrap();
790 assert_eq!(msgs.len(), 1);
791
792 assert!(queue.receive("q", 10).await.unwrap().is_empty());
794
795 queue.nack("q", &msgs[0].receipt_handle).await.unwrap();
796
797 let redelivered = queue.receive("q", 10).await.unwrap();
799 assert_eq!(redelivered.len(), 1, "nacked message must be redelivered");
800 assert_eq!(payload_text(&redelivered[0]), "try-again");
801 assert_ne!(
802 redelivered[0].receipt_handle, msgs[0].receipt_handle,
803 "redelivery must mint a fresh receipt handle"
804 );
805 }
806
807 #[tokio::test]
808 async fn test_purge_empties_queue() {
809 let (queue, _temp_dir) = create_test_queue().await;
810
811 for i in 0..3 {
812 queue
813 .send("q", MessagePayload::Text(format!("m{i}")))
814 .await
815 .unwrap();
816 }
817 let in_flight = queue.receive("q", 1).await.unwrap();
819 assert_eq!(in_flight.len(), 1);
820
821 queue.purge("q").await.unwrap();
822
823 assert!(queue.receive("q", 10).await.unwrap().is_empty());
824 let conn = raw_conn(&queue).await;
825 let rows = query_all(&conn, "SELECT COUNT(*) FROM messages", ())
826 .await
827 .expect("count read");
828 let count = rows
829 .first()
830 .and_then(|row| row.first())
831 .and_then(as_i64)
832 .expect("count value");
833 assert_eq!(count, 0, "purge must delete every row, leased or not");
834 }
835
836 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
839 async fn test_two_handle_concurrent_receive_no_double_delivery() {
840 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
841 let dir = temp_dir.path().join("queue");
842 let queue_a = Arc::new(LocalQueue::new(dir.clone()).await.expect("open handle a"));
844 let queue_b = Arc::new(LocalQueue::new(dir.clone()).await.expect("open handle b"));
845
846 let n = 30;
847 for i in 0..n {
848 queue_a
849 .send("q", MessagePayload::Text(format!("msg-{i}")))
850 .await
851 .unwrap();
852 }
853
854 let mut tasks = Vec::new();
858 for t in 0..6 {
859 let queue = if t % 2 == 0 {
860 queue_a.clone()
861 } else {
862 queue_b.clone()
863 };
864 tasks.push(tokio::spawn(async move {
865 let mut got: Vec<(i64, String)> = Vec::new();
866 let mut consecutive_empty = 0;
867 while consecutive_empty < 3 {
868 let batch = queue.receive("q", 5).await.expect("receive ok");
869 assert!(batch.len() <= 5, "batch must respect max_messages");
870 if batch.is_empty() {
871 consecutive_empty += 1;
872 time::sleep(Duration::from_millis(10)).await;
873 continue;
874 }
875 consecutive_empty = 0;
876 for msg in batch {
877 got.push((handle_id(&msg.receipt_handle), payload_text(&msg)));
878 }
879 }
880 got
881 }));
882 }
883
884 let mut all: Vec<(i64, String)> = Vec::new();
885 for task in tasks {
886 all.extend(task.await.expect("task join"));
887 }
888
889 assert_eq!(
892 all.len(),
893 n,
894 "total deliveries must equal messages sent (no double delivery)"
895 );
896
897 let ids: BTreeSet<i64> = all.iter().map(|(id, _)| *id).collect();
899 assert_eq!(ids.len(), n, "every delivered message id must be unique");
900
901 let payloads: BTreeSet<String> = all.iter().map(|(_, p)| p.clone()).collect();
903 let expected: BTreeSet<String> = (0..n).map(|i| format!("msg-{i}")).collect();
904 assert_eq!(
905 payloads, expected,
906 "union of deliveries must cover all messages exactly once"
907 );
908 }
909}