Skip to main content

alien_bindings/providers/queue/
local.rs

1//! Local disk-persisted queue backed by turso (`localqueue.v1`), multi-process safe.
2//!
3//! # Connection strategy
4//!
5//! turso is async-native and its `Connection` is `Send + Sync`, so there is no
6//! `spawn_blocking` boundary and no `Mutex<Connection>` anywhere. `LocalQueue`
7//! holds one `turso::Database` handle on `<dataDir>/localqueue.sqlite`; each
8//! operation opens its **own** short-lived connection from it and drops it
9//! when the operation completes, so no statement state leaks between
10//! operations.
11//!
12//! Correctness under concurrent access (multiple handles on one file, i.e.
13//! multiple processes) comes from turso's multi-process WAL mode — enabled
14//! explicitly, experimental upstream, and gated by the multi-handle tests
15//! below — plus a `busy_timeout` (writers wait for the write lock instead of
16//! failing with `Busy`).
17//!
18//! # Receive design: one `BEGIN IMMEDIATE` transaction per batch
19//!
20//! The pinned `localqueue.v1` receive is one atomic
21//! `UPDATE ... SET receipt_handle = ?uuid ... RETURNING ...`. A single bound
22//! parameter cannot mint a **distinct** UUID per claimed row, so this
23//! implementation uses the sanctioned equivalent: one `BEGIN IMMEDIATE`
24//! transaction per batch that selects the due ids, then runs that exact
25//! per-row `UPDATE ... RETURNING` with a fresh UUID for each, and commits.
26//! `IMMEDIATE` takes the write lock at `BEGIN`, so concurrent receivers (in
27//! any process) serialize on the whole claim: a message is delivered to
28//! exactly one receiver per visibility window.
29//!
30//! See `crates/alien-bindings/FORMAT.md` for the on-disk `localqueue.v1`
31//! contract, including the `"{id}:{uuid}"` caller-facing receipt-handle format.
32use 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/// Local disk-persisted queue on turso (`localqueue.v1`).
63///
64/// Implements the `Queue` trait (send/receive/ack/nack/purge). Messages
65/// survive process restarts, and multiple `LocalQueue`
66/// handles — including handles in different OS processes — can safely share
67/// one data directory.
68#[derive(Debug)]
69pub struct LocalQueue {
70    store: LocalStore,
71}
72
73/// Build the standard queue operation error context.
74fn queue_error(operation: &str, reason: String) -> ErrorData {
75    ErrorData::QueueOperationFailed {
76        operation: operation.to_string(),
77        reason,
78    }
79}
80
81/// Split a `MessagePayload` into its stored `(payload_type, payload_data)`
82/// columns: `("json", <serialized JSON>)` or `("text", <raw string>)`.
83fn 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
98/// Rebuild a `MessagePayload` from its stored columns.
99fn 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    /// Create a new local queue store rooted at the given data directory.
120    ///
121    /// The directory is created if missing; the store lives at
122    /// `<data_dir>/localqueue.sqlite`.
123    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    /// Create a LocalQueue from a LocalQueueBinding.
130    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    /// Get the data directory path (the directory that holds `localqueue.sqlite`).
143    pub fn data_dir(&self) -> &PathBuf {
144        self.store.data_dir()
145    }
146
147    /// Split a caller-facing `"{id}:{uuid}"` receipt handle into its parts.
148    ///
149    /// Returns `None` for a handle this store could never have issued; ack and
150    /// nack treat that exactly like an already-deleted message (idempotent Ok).
151    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    /// Claim up to `max_messages` due messages with the given visibility
161    /// timeout. This is `receive` minus the batch-size validation and with the
162    /// timeout injectable, so tests can force fast redelivery.
163    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                // IMMEDIATE takes the write lock at BEGIN: the select-then-claim
177                // below is one critical section across all handles and processes.
178                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                    // The pinned claim statement, with a fresh UUID per row.
212                    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
272/// Does a message row with this id still exist (under the current transaction)?
273///
274/// Inside `ack`/`nack` this runs on the open `BEGIN IMMEDIATE` transaction
275/// (turso's `Transaction` derefs to `Connection`).
276async 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        // Encode once, then measure the encoded bytes we will actually store.
302        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        // A handle this store never issued behaves like an already-deleted
347        // message: idempotent Ok (preserves the historical ack contract).
348        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                    // The row is still there but under a different (newer) receipt:
373                    // this caller lost its lease. Rejecting prevents a slow consumer
374                    // from deleting work that has been handed to someone else.
375                    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    /// Negative-acknowledge a message: make it immediately visible again.
387    ///
388    /// Same receipt rules as `ack`: a stale receipt (the message was
389    /// redelivered and a newer receipt supersedes this one) is rejected; a
390    /// receipt for an already-deleted message is an idempotent no-op.
391    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    /// Delete every message in the queue, visible or in flight.
429    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    /// Parse the message id out of a `"{id}:{uuid}"` receipt handle.
460    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    /// Open a raw connection to the store for white-box column inspection.
470    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        // Ack the message
525        queue.ack("q", &msgs[0].receipt_handle).await.unwrap();
526
527        // No messages should be available (acked, not expired)
528        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        // Acking a receipt handle that never existed should succeed.
537        queue.ack("q", "non-existent-handle").await.unwrap();
538
539        // Acking an already-deleted message (double ack with the same, current
540        // receipt) must also succeed: the row is gone, so the ack is a no-op.
541        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        // Send a message and drop the queue
609        {
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        // Reopen and verify message persists
618        {
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        // Create a valid store, then rewrite its format marker to a future version.
649        {
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        // Reopening must fail fast, naming both the found and expected formats.
670        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    // ---- localqueue.v1 semantics proofs ----
685
686    #[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        // Receive with a short visibility timeout and do NOT ack.
696        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        // While the message is in flight, it must not be redelivered.
704        let hidden = queue.receive("q", 10).await.unwrap();
705        assert!(hidden.is_empty(), "in-flight message must be hidden");
706
707        // After the visibility timeout expires the message is redelivered ...
708        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        // ... with the real attempt count surfaced to the caller ...
723        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        // ... with attempt incremented once per delivery (1 then 2).
730        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        // Handle A receives with a short visibility timeout and stalls.
752        let a = queue
753            .receive_inner(1, Duration::from_millis(100))
754            .await
755            .unwrap();
756        assert_eq!(a.len(), 1);
757
758        // The message expires and is redelivered to handle B.
759        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        // A's receipt is stale (B holds the current one): ack must be rejected.
765        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        // The message must still be there for B, whose current receipt works.
775        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        // In flight under the default 30s lease: hidden without a nack.
793        assert!(queue.receive("q", 10).await.unwrap().is_empty());
794
795        queue.nack("q", &msgs[0].receipt_handle).await.unwrap();
796
797        // Immediately visible again — no waiting on the visibility timeout.
798        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        // Put one message in flight so purge covers both visible and leased rows.
818        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    // ---- Multi-process-safety proof ----
837
838    #[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        // Two independent handles on the SAME data_dir == two processes sharing the file.
843        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        // Six concurrent receivers split across the two handles, each draining
855        // in batches. All deliveries happen well inside the default 30s
856        // visibility window, so ANY duplicate is a double delivery.
857        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        // Exactly N deliveries in total — this catches double delivery even
890        // before any dedup: 31 deliveries of 30 messages must fail here.
891        assert_eq!(
892            all.len(),
893            n,
894            "total deliveries must equal messages sent (no double delivery)"
895        );
896
897        // No duplicate message ids ...
898        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        // ... and no duplicate payloads; the union covers every message.
902        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}