Skip to main content

capp_queue/backend/
fjall.rs

1use std::{marker::PhantomData, path::Path, sync::Mutex};
2
3use async_trait::async_trait;
4use fjall::{Database, Keyspace, KeyspaceCreateOptions, PersistMode};
5use serde::{Serialize, de::DeserializeOwned};
6use uuid::Uuid;
7
8use crate::{Task, TaskId, TaskQueue, TaskQueueError, TaskSerializer};
9
10/// Fjall-backed task queue (default backend).
11///
12/// Layout:
13/// - `tasks`: task_id -> serialized task bytes
14/// - `queue`: task_id (uuid v7 bytes) -> empty value (work to do)
15/// - `inflight`: task_id -> empty value (leased to a worker, not yet acked)
16/// - `dlq`: task_id -> serialized task bytes
17pub struct FjallTaskQueue<D, S>
18where
19    S: TaskSerializer,
20{
21    db: Database,
22    tasks: Keyspace,
23    queue: Keyspace,
24    inflight: Keyspace,
25    dlq: Keyspace,
26    // Serialize push/pop/ack/nack/set to keep ordering simple.
27    lock: Mutex<()>,
28    _marker: PhantomData<(D, S)>,
29}
30
31impl<D, S> FjallTaskQueue<D, S>
32where
33    S: TaskSerializer,
34{
35    pub fn open(path: impl AsRef<Path>) -> Result<Self, TaskQueueError> {
36        let db = Database::builder(path).open()?;
37        let tasks = db.keyspace("tasks", KeyspaceCreateOptions::default)?;
38        let queue = db.keyspace("queue", KeyspaceCreateOptions::default)?;
39        let inflight = db.keyspace("inflight", KeyspaceCreateOptions::default)?;
40        let dlq = db.keyspace("dlq", KeyspaceCreateOptions::default)?;
41
42        Ok(Self {
43            db,
44            tasks,
45            queue,
46            inflight,
47            dlq,
48            lock: Mutex::new(()),
49            _marker: PhantomData,
50        })
51    }
52
53    fn task_id_to_bytes(task_id: &TaskId) -> [u8; 16] {
54        *task_id.get().as_bytes()
55    }
56
57    fn task_id_from_bytes(bytes: &[u8]) -> Result<TaskId, TaskQueueError> {
58        let uuid = Uuid::from_slice(bytes).map_err(|e| {
59            TaskQueueError::QueueError(format!("Invalid task id bytes: {e}"))
60        })?;
61        Ok(TaskId::from_uuid(uuid))
62    }
63}
64
65#[async_trait]
66impl<D, S> TaskQueue<D> for FjallTaskQueue<D, S>
67where
68    D: std::fmt::Debug
69        + Clone
70        + Serialize
71        + DeserializeOwned
72        + Send
73        + Sync
74        + 'static,
75    S: TaskSerializer + Send + Sync,
76{
77    async fn push(&self, task: &Task<D>) -> Result<(), TaskQueueError> {
78        let _guard = self.lock.lock().unwrap();
79        let task_bytes = S::serialize_task(task)?;
80
81        let task_id_bytes = Self::task_id_to_bytes(&task.task_id);
82
83        self.tasks.insert(task_id_bytes, &task_bytes)?;
84        self.queue.insert(task_id_bytes, &[] as &[u8])?;
85        // Covers the retry path: a task popped earlier is being requeued.
86        self.inflight.remove(task_id_bytes)?;
87
88        // Best-effort sync to disk for durability.
89        self.db.persist(PersistMode::SyncAll)?;
90        Ok(())
91    }
92
93    async fn pop(&self) -> Result<Task<D>, TaskQueueError> {
94        let _guard = self.lock.lock().unwrap();
95
96        let Some(entry) = self.queue.first_key_value() else {
97            return Err(TaskQueueError::QueueEmpty);
98        };
99
100        let (task_id_bytes, _) = entry.into_inner()?;
101        let task_id_bytes = task_id_bytes.to_vec();
102        self.queue.remove(task_id_bytes.clone())?;
103        self.inflight.insert(task_id_bytes.clone(), &[] as &[u8])?;
104        self.db.persist(PersistMode::SyncAll)?;
105
106        let task_id = Self::task_id_from_bytes(&task_id_bytes)?;
107        let task_bytes = self
108            .tasks
109            .get(task_id_bytes)?
110            .ok_or(TaskQueueError::TaskNotFound(task_id))?;
111
112        S::deserialize_task(&task_bytes)
113    }
114
115    async fn ack(&self, task_id: &TaskId) -> Result<(), TaskQueueError> {
116        let _guard = self.lock.lock().unwrap();
117        let task_id_bytes = Self::task_id_to_bytes(task_id);
118        self.tasks.remove(task_id_bytes)?;
119        self.inflight.remove(task_id_bytes)?;
120        self.db.persist(PersistMode::SyncAll)?;
121        Ok(())
122    }
123
124    async fn nack(&self, task: &Task<D>) -> Result<(), TaskQueueError> {
125        let _guard = self.lock.lock().unwrap();
126        let task_id_bytes = Self::task_id_to_bytes(&task.task_id);
127        let task_bytes = S::serialize_task(task)?;
128
129        self.dlq.insert(task_id_bytes, &task_bytes)?;
130        self.tasks.remove(task_id_bytes)?;
131        self.inflight.remove(task_id_bytes)?;
132        self.db.persist(PersistMode::SyncAll)?;
133        Ok(())
134    }
135
136    async fn set(&self, task: &Task<D>) -> Result<(), TaskQueueError> {
137        let _guard = self.lock.lock().unwrap();
138        let task_id_bytes = Self::task_id_to_bytes(&task.task_id);
139        let task_bytes = S::serialize_task(task)?;
140        self.tasks.insert(task_id_bytes, &task_bytes)?;
141        Ok(())
142    }
143
144    async fn recover_inflight(&self) -> Result<u64, TaskQueueError> {
145        let _guard = self.lock.lock().unwrap();
146
147        let mut recovered = 0u64;
148        let mut keys = Vec::new();
149        for item in self.inflight.iter() {
150            let (key, _) = item.into_inner()?;
151            keys.push(key.to_vec());
152        }
153
154        for key in keys {
155            self.queue.insert(key.clone(), &[] as &[u8])?;
156            self.inflight.remove(key)?;
157            recovered += 1;
158        }
159
160        if recovered > 0 {
161            self.db.persist(PersistMode::SyncAll)?;
162        }
163        Ok(recovered)
164    }
165}
166
167impl<D, S> std::fmt::Debug for FjallTaskQueue<D, S>
168where
169    S: TaskSerializer,
170{
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("FjallTaskQueue").finish()
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use crate::JsonSerializer;
180    use serde::{Deserialize, Serialize};
181    use tempfile::tempdir;
182
183    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
184    struct TestData {
185        value: u32,
186    }
187
188    fn make_queue() -> (tempfile::TempDir, FjallTaskQueue<TestData, JsonSerializer>)
189    {
190        let dir = tempdir().unwrap();
191        let queue = FjallTaskQueue::open(dir.path()).unwrap();
192        (dir, queue)
193    }
194
195    #[tokio::test]
196    async fn push_and_pop() -> Result<(), TaskQueueError> {
197        let (_dir, queue) = make_queue();
198        let task = Task::new(TestData { value: 7 });
199
200        queue.push(&task).await?;
201        let popped = queue.pop().await?;
202        assert_eq!(popped.payload, task.payload);
203        Ok(())
204    }
205
206    #[tokio::test]
207    async fn queue_empty() -> Result<(), TaskQueueError> {
208        let (_dir, queue) = make_queue();
209        let res = queue.pop().await;
210        assert!(matches!(res, Err(TaskQueueError::QueueEmpty)));
211        Ok(())
212    }
213
214    #[tokio::test]
215    async fn ack_removes_task() -> Result<(), TaskQueueError> {
216        let (_dir, queue) = make_queue();
217        let task = Task::new(TestData { value: 1 });
218
219        queue.push(&task).await?;
220        let popped = queue.pop().await?;
221        queue.ack(&popped.task_id).await?;
222
223        let res = queue.pop().await;
224        assert!(matches!(res, Err(TaskQueueError::QueueEmpty)));
225        Ok(())
226    }
227
228    #[tokio::test]
229    async fn nack_moves_to_dlq() -> Result<(), TaskQueueError> {
230        let (_dir, queue) = make_queue();
231        let task = Task::new(TestData { value: 99 });
232
233        queue.push(&task).await?;
234        let mut popped = queue.pop().await?;
235        popped.set_retry("fail");
236        queue.nack(&popped).await?;
237
238        let stored = queue
239            .dlq
240            .get(
241                FjallTaskQueue::<TestData, JsonSerializer>::task_id_to_bytes(
242                    &popped.task_id,
243                ),
244            )?
245            .map(|bytes| JsonSerializer::deserialize_task::<TestData>(&bytes));
246        assert!(matches!(stored, Some(Ok(_))));
247        Ok(())
248    }
249
250    #[tokio::test]
251    async fn set_updates_task() -> Result<(), TaskQueueError> {
252        let (_dir, queue) = make_queue();
253        let mut task = Task::new(TestData { value: 3 });
254        queue.push(&task).await?;
255
256        task.payload.value = 5;
257        queue.set(&task).await?;
258
259        let popped = queue.pop().await?;
260        assert_eq!(popped.payload.value, 5);
261        Ok(())
262    }
263
264    fn inflight_contains<S: TaskSerializer>(
265        queue: &FjallTaskQueue<TestData, S>,
266        task_id: &TaskId,
267    ) -> bool {
268        let key = FjallTaskQueue::<TestData, S>::task_id_to_bytes(task_id);
269        queue
270            .inflight
271            .get(key)
272            .map(|v| v.is_some())
273            .unwrap_or(false)
274    }
275
276    #[tokio::test]
277    async fn pop_moves_to_inflight() -> Result<(), TaskQueueError> {
278        let (_dir, queue) = make_queue();
279        let task = Task::new(TestData { value: 11 });
280
281        queue.push(&task).await?;
282        let popped = queue.pop().await?;
283
284        assert!(inflight_contains(&queue, &popped.task_id));
285        Ok(())
286    }
287
288    #[tokio::test]
289    async fn ack_clears_inflight() -> Result<(), TaskQueueError> {
290        let (_dir, queue) = make_queue();
291        let task = Task::new(TestData { value: 12 });
292
293        queue.push(&task).await?;
294        let popped = queue.pop().await?;
295        queue.ack(&popped.task_id).await?;
296
297        assert!(!inflight_contains(&queue, &popped.task_id));
298        Ok(())
299    }
300
301    #[tokio::test]
302    async fn nack_clears_inflight() -> Result<(), TaskQueueError> {
303        let (_dir, queue) = make_queue();
304        let task = Task::new(TestData { value: 13 });
305
306        queue.push(&task).await?;
307        let mut popped = queue.pop().await?;
308        popped.set_retry("fail");
309        queue.nack(&popped).await?;
310
311        assert!(!inflight_contains(&queue, &popped.task_id));
312        Ok(())
313    }
314
315    #[tokio::test]
316    async fn push_retry_clears_inflight() -> Result<(), TaskQueueError> {
317        let (_dir, queue) = make_queue();
318        let mut task = Task::new(TestData { value: 14 });
319
320        queue.push(&task).await?;
321        let popped = queue.pop().await?;
322        assert!(inflight_contains(&queue, &popped.task_id));
323
324        task.set_retry("transient");
325        queue.push(&task).await?;
326
327        assert!(!inflight_contains(&queue, &task.task_id));
328        Ok(())
329    }
330
331    #[tokio::test]
332    async fn recover_inflight_moves_back_to_queue() -> Result<(), TaskQueueError> {
333        let dir = tempdir().unwrap();
334        let task_id = {
335            let queue: FjallTaskQueue<TestData, JsonSerializer> =
336                FjallTaskQueue::open(dir.path())?;
337            let task = Task::new(TestData { value: 99 });
338            queue.push(&task).await?;
339            let popped = queue.pop().await?;
340            assert!(inflight_contains(&queue, &popped.task_id));
341            popped.task_id
342        };
343
344        // Reopen — simulates fresh process startup after crash.
345        let queue: FjallTaskQueue<TestData, JsonSerializer> =
346            FjallTaskQueue::open(dir.path())?;
347        let recovered = queue.recover_inflight().await?;
348        assert_eq!(recovered, 1);
349
350        let re_popped = queue.pop().await?;
351        assert_eq!(re_popped.task_id, task_id);
352        Ok(())
353    }
354}