Skip to main content

apalis_file_storage/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3use apalis_codec::json::JsonCodec;
4use futures_core::{Stream, stream::BoxStream};
5use futures_util::{
6    StreamExt,
7    future::{Ready, ready},
8};
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10use serde_json::Value;
11use std::{
12    collections::{BTreeMap, HashMap},
13    fmt::Debug,
14    fs::{File, OpenOptions},
15    io::{self, BufRead, Seek, SeekFrom, Write},
16    marker::PhantomData,
17    num::ParseIntError,
18    path::{Path, PathBuf},
19    pin::Pin,
20    str::FromStr,
21    sync::{Arc, Mutex, RwLock},
22    task::{Context, Poll},
23    time::Duration,
24};
25use tempfile::NamedTempFile;
26
27use crate::error::FileStorageError;
28
29use self::util::RawTask;
30use apalis_core::{
31    backend::{
32        Backend, BackendConfig, TaskResult, TryNewBackend, WaitForCompletion, WireFormatBackend,
33        finalize::Durable,
34    },
35    error::BoxDynError,
36    features_table,
37    task::{
38        ExecutionContext, Task,
39        builder::TaskBuilder,
40        metadata::MetadataStore,
41        status::Status,
42        task_id::{RandomId, TaskId},
43    },
44    worker::{
45        context::WorkerContext,
46        ext::ack::{Acknowledge, AcknowledgeLayer},
47    },
48};
49use std::io::BufReader;
50
51use fd_lock::RwLock as FileLock;
52
53mod adapter;
54mod error;
55mod shared;
56mod sink;
57mod util;
58
59pub use self::shared::SharedJsonStore;
60pub use adapter::Adapter;
61
62/// Handles the sync policy for when to flush pending changes to disk.
63#[derive(Debug, Clone)]
64#[non_exhaustive]
65pub enum SyncPolicy {
66    /// Flush to disk after every change.
67    Instant,
68    /// Accumulate changes; caller drives `tick()` on a timer.
69    Periodic(Duration),
70    /// Never flush automatically; caller drives `flush()`.
71    Manual,
72}
73
74#[derive(Debug)]
75enum PendingChange {
76    /// Append a new entry (already serialized, includes `\n`).
77    Append(Vec<u8>),
78    /// Rewrite the line_id` with new content.
79    RewriteLine {
80        line_id: usize,
81        /// The fully serialized replacement line, including `\n`.
82        new_bytes: Vec<u8>,
83    },
84}
85
86/// A backend that persists to a file using json encoding
87///
88/// *Warning*: This backend is not optimized for high-throughput scenarios and is best suited for development, testing, or low-volume workloads.
89///
90/// # Example
91///
92/// Creates a temporary JSON storage backend
93/// ```rust
94/// # use apalis_file_storage::JsonStorage;;
95/// # pub fn setup_json_storage() -> JsonStorage<u32> {
96/// let mut backend: JsonStorage<u32> = JsonStorage::new_temp().unwrap();
97/// # backend
98/// # }
99/// ```
100#[doc = features_table! {
101    setup = r#"
102        # {
103        #   use apalis_file_storage::JsonStorage;;
104        #   let mut backend: JsonStorage<u32> = JsonStorage::new_temp().unwrap();
105        #   backend
106        # };
107    "#,
108    Backend => supported("Basic Backend functionality", true),
109    TaskSink => supported("Ability to push new tasks", true),
110    Serialization => limited("Serialization support for arguments. Only accepts `json`", false),
111    WebUI => not_implemented("Expose a web interface for monitoring tasks"),
112    FetchById => not_implemented("Allow fetching a task by its ID"),
113    RegisterWorker => not_supported("Allow registering a worker with the backend"),
114    "[`PipeExt`]" => supported("Allow other backends to pipe to this backend", false),
115    BackendFactory => supported("Share the same storage across multiple workers via [`SharedJsonStore`]", false),
116    Workflow => supported("Flexible enough to support workflows", true),
117    WaitForCompletion => supported("Wait for tasks to complete without blocking", true),
118    ResumeById => not_implemented("Resume a task by its ID"),
119    ResumeAbandoned => not_implemented("Resume abandoned tasks"),
120    ListWorkers => not_supported("List all workers registered with the backend"),
121    ListTasks => not_implemented("List all tasks in the backend"),
122}]
123#[derive(Debug)]
124pub struct FileStorage<Args, A: Adapter> {
125    adapter: A,
126    lock: Arc<RwLock<FileLock<File>>>,
127    path: PathBuf,
128    /// In-memory view of all entries; index == job_id.
129    entries: Arc<RwLock<Vec<RawTask>>>,
130    /// Staged mutations not yet flushed to disk.
131    pending: Arc<RwLock<Vec<PendingChange>>>,
132    /// Next job_id to yield from the `Stream` impl.
133    read_cursor: usize,
134    sync_policy: SyncPolicy,
135    last_flush: std::time::Instant,
136    codec: JsonCodec<Value>,
137    _args: PhantomData<Args>,
138}
139
140impl<Args, A> Clone for FileStorage<Args, A>
141where
142    A: Adapter + Clone,
143{
144    fn clone(&self) -> Self {
145        Self {
146            adapter: self.adapter.clone(),
147            lock: Arc::clone(&self.lock),
148            path: self.path.clone(),
149            entries: Arc::clone(&self.entries),
150            pending: Arc::clone(&self.pending),
151            read_cursor: self.read_cursor,
152            sync_policy: self.sync_policy.clone(),
153            last_flush: self.last_flush,
154            codec: self.codec.clone(),
155            _args: PhantomData,
156        }
157    }
158}
159
160/// A JSON adapter for serializing and deserializing tasks.
161#[derive(Debug, Clone, Copy, Default)]
162pub struct JsonAdapter;
163
164/// A `FileStorage` using `JsonAdapter` for line encoding.
165pub type JsonStorage<Args> = FileStorage<Args, JsonAdapter>;
166
167impl Adapter for JsonAdapter {
168    type Line = serde_json::Value;
169
170    type Error = serde_json::Error;
171
172    fn serialize(&self, line: &Self::Line) -> Result<Vec<u8>, Self::Error> {
173        let mut bytes = serde_json::to_vec(line)?;
174        bytes.push(b'\n');
175        Ok(bytes)
176    }
177
178    fn deserialize(&self, raw: &[u8]) -> Result<Self::Line, Self::Error> {
179        serde_json::from_slice(raw.trim_ascii())
180    }
181
182    fn to_entry(&self, line: Self::Line) -> Result<RawTask, Self::Error> {
183        serde_json::from_value(line)
184    }
185
186    fn from_entry(entry: &RawTask) -> Result<Self::Line, Self::Error> {
187        serde_json::to_value(entry)
188    }
189}
190
191impl<A: Adapter, Args> FileStorage<Args, A> {
192    /// Creates a new `FileStorage` instance using the specified file path.
193    pub fn new(path: impl AsRef<Path>) -> Result<Self, FileStorageError<A>>
194    where
195        A: Default,
196    {
197        let adapter = A::default();
198        let path = path.as_ref().to_path_buf();
199
200        let file = OpenOptions::new()
201            .create(true)
202            .read(true)
203            .write(true)
204            .truncate(true)
205            .open(&path)
206            .map_err(FileStorageError::Io)?;
207
208        let lock = Arc::new(RwLock::new(FileLock::new(file)));
209
210        let entries = {
211            let guard = lock.read().map_err(|e| {
212                FileStorageError::Io(std::io::Error::new(
213                    std::io::ErrorKind::WouldBlock,
214                    e.to_string(),
215                ))
216            })?;
217            let guard = guard
218                .try_read()
219                .map_err(|_| FileStorageError::WouldBlockLock)?;
220            Self::load_entries(&adapter, &guard)?
221        };
222
223        Ok(Self {
224            adapter,
225            lock,
226            path,
227            entries: Arc::new(RwLock::new(entries)),
228            pending: Default::default(),
229            read_cursor: 0,
230            sync_policy: SyncPolicy::Instant,
231            last_flush: std::time::Instant::now(),
232            codec: JsonCodec::default(),
233            _args: PhantomData,
234        })
235    }
236
237    /// Creates a new temporary `FileStorage` instance.
238    pub fn new_temp() -> Result<Self, FileStorageError<A>>
239    where
240        A: Default,
241    {
242        let p = NamedTempFile::new()?.path().to_path_buf();
243        Self::new(p)
244    }
245
246    /// Attach a result to the entry identified by `line_id`.
247    pub fn set_result(
248        &mut self,
249        line_id: usize,
250        result: serde_json::Value,
251    ) -> Result<(), FileStorageError<A>> {
252        {
253            let mut entries = self
254                .entries
255                .try_write()
256                .map_err(|_| FileStorageError::WouldBlockLock)?;
257            let entry = entries
258                .get_mut(line_id)
259                .ok_or(FileStorageError::JobNotFound { line_id })?;
260
261            entry.result = Some(result);
262
263            // Serialize the updated entry through the adapter's native Line type.
264            let line = A::from_entry(entry).map_err(FileStorageError::AdapterError)?;
265            let new_bytes = self
266                .adapter
267                .serialize(&line)
268                .map_err(FileStorageError::AdapterError)?;
269
270            self.pending
271                .try_write()
272                .map_err(|_| FileStorageError::WouldBlockLock)?
273                .push(PendingChange::RewriteLine { line_id, new_bytes });
274        }
275
276        if matches!(self.sync_policy, SyncPolicy::Instant) {
277            self.flush()?;
278        }
279        Ok(())
280    }
281
282    /// Persist all pending changes under an exclusive file lock.
283    pub fn flush(&mut self) -> Result<(), FileStorageError<A>> {
284        if self
285            .pending
286            .try_read()
287            .map_err(|_| FileStorageError::WouldBlockLock)?
288            .is_empty()
289        {
290            return Ok(());
291        }
292
293        let mut outer_guard = self.lock.write().map_err(|e| {
294            FileStorageError::Io(std::io::Error::new(
295                std::io::ErrorKind::WouldBlock,
296                e.to_string(),
297            ))
298        })?;
299        let mut guard = outer_guard
300            .try_write()
301            .map_err(|_| FileStorageError::WouldBlockLock)?;
302
303        // Partition: rewrites require a full-file atomic swap; appends do not.
304        let has_rewrites = self
305            .pending
306            .try_read()
307            .map_err(|_| FileStorageError::WouldBlockLock)?
308            .iter()
309            .any(|c| matches!(c, PendingChange::RewriteLine { .. }));
310
311        if has_rewrites {
312            // Build the complete new file from the in-memory entries,
313            // applying any pending appends along the way so that the
314            // serialized file is always consistent with `self.entries`.
315            //
316            // Collect rewrite targets so we can substitute updated bytes.
317            let mut rewrites: std::collections::HashMap<usize, Vec<u8>> =
318                std::collections::HashMap::new();
319            let mut extra_appends: Vec<Vec<u8>> = Vec::new();
320
321            for change in self
322                .pending
323                .try_write()
324                .map_err(|_| FileStorageError::WouldBlockLock)?
325                .drain(..)
326            {
327                match change {
328                    PendingChange::RewriteLine { line_id, new_bytes } => {
329                        rewrites.insert(line_id, new_bytes);
330                    }
331                    PendingChange::Append(bytes) => {
332                        extra_appends.push(bytes);
333                    }
334                }
335            }
336
337            // Write tmp file.
338            let tmp_path = self.path.with_extension("tmp");
339            {
340                let mut tmp = File::create(&tmp_path).map_err(FileStorageError::Io)?;
341
342                let raw_tasks = self
343                    .entries
344                    .try_read()
345                    .map_err(|_| FileStorageError::WouldBlockLock)?;
346                if let Some(headers) = self.adapter.header(&raw_tasks) {
347                    tmp.write_all(&headers).map_err(FileStorageError::Io)?;
348                }
349                for (job_id, entry) in raw_tasks.iter().enumerate() {
350                    let bytes = if let Some(b) = rewrites.remove(&job_id) {
351                        b
352                    } else {
353                        let line = A::from_entry(entry).map_err(FileStorageError::AdapterError)?;
354                        self.adapter
355                            .serialize(&line)
356                            .map_err(FileStorageError::AdapterError)?
357                    };
358                    tmp.write_all(&bytes).map_err(FileStorageError::Io)?;
359                }
360
361                tmp.flush().map_err(FileStorageError::Io)?;
362            }
363
364            // Atomic rename over the live file.
365            std::fs::rename(&tmp_path, &self.path).map_err(FileStorageError::Io)?;
366
367            // Re-open the renamed file so `guard` / `lock` stays valid.
368            // We re-open for read+write and replace the lock's inner file.
369            // (fd_lock works on the file descriptor, so we update it.)
370            drop(guard);
371            drop(outer_guard);
372            let new_file = OpenOptions::new()
373                .read(true)
374                .write(true)
375                .open(&self.path)
376                .map_err(FileStorageError::Io)?;
377            self.lock = Arc::new(RwLock::new(FileLock::new(new_file)));
378        } else {
379            // Appends only — fast path: seek to end and write.
380            for change in self
381                .pending
382                .try_write()
383                .map_err(|_| FileStorageError::WouldBlockLock)?
384                .drain(..)
385            {
386                if let PendingChange::Append(bytes) = change {
387                    guard.seek(SeekFrom::End(0)).map_err(FileStorageError::Io)?;
388                    guard.write_all(&bytes).map_err(FileStorageError::Io)?;
389                }
390            }
391            guard.flush().map_err(FileStorageError::Io)?;
392        }
393
394        self.last_flush = std::time::Instant::now();
395        Ok(())
396    }
397
398    /// Drive periodic syncing. Call this from your timer loop.
399    pub fn tick(&mut self) -> Result<(), FileStorageError<A>> {
400        if let SyncPolicy::Periodic(interval) = self.sync_policy.clone() {
401            if self.last_flush.elapsed() >= interval {
402                self.flush()?;
403            }
404        }
405        Ok(())
406    }
407
408    /// Read every non-blank line from `file` and decode via the adapter.
409    fn load_entries(adapter: &A, file: &File) -> Result<Vec<RawTask>, FileStorageError<A>> {
410        let mut reader = BufReader::new(file);
411        reader
412            .seek(SeekFrom::Start(0))
413            .map_err(FileStorageError::Io)?;
414
415        let mut entries = Vec::new();
416        let mut raw: Vec<u8> = Vec::new();
417
418        loop {
419            raw.clear();
420            let n = reader
421                .read_until(b'\n', &mut raw)
422                .map_err(FileStorageError::Io)?;
423            if n == 0 {
424                break;
425            }
426            let trimmed = raw.trim_ascii();
427            if trimmed.is_empty() {
428                continue;
429            }
430
431            if adapter.is_header(trimmed) {
432                continue;
433            }
434            let line = adapter
435                .deserialize(trimmed)
436                .map_err(FileStorageError::AdapterError)?;
437            entries.push(
438                adapter
439                    .to_entry(line)
440                    .map_err(FileStorageError::AdapterError)?,
441            );
442        }
443        Ok(entries)
444    }
445}
446
447impl<A: Adapter + Unpin, Args: Unpin> Stream for FileStorage<Args, A> {
448    type Item = Result<(usize, RawTask), FileStorageError<A>>;
449
450    fn poll_next(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
451        let this = self.get_mut();
452        let raw_tasks = this
453            .entries
454            .try_read()
455            .map_err(|_| FileStorageError::WouldBlockLock)?;
456        if this.read_cursor < raw_tasks.len() {
457            let line_id = this.read_cursor;
458            let job = raw_tasks[line_id].clone();
459            this.read_cursor += 1;
460            Poll::Ready(Some(Ok((line_id, job))))
461        } else {
462            Poll::Pending
463        }
464    }
465}
466
467impl<Args, A> Backend for FileStorage<Args, A>
468where
469    Args: 'static + Send + Serialize + for<'de> Deserialize<'de> + Unpin,
470    A: Adapter + Unpin,
471    A::Error: std::error::Error + Send + Sync + 'static,
472{
473    type Task = Task<Value>;
474    type Error = FileStorageError<A>;
475
476    fn poll_ready(
477        &mut self,
478        cx: &mut Context<'_>,
479        _: &WorkerContext,
480    ) -> Poll<Result<(), Self::Error>> {
481        if self.lock.try_read().is_ok() {
482            Poll::Ready(Ok(()))
483        } else {
484            cx.waker().wake_by_ref();
485            Poll::Pending
486        }
487    }
488
489    fn poll_next(
490        &mut self,
491        cx: &mut Context<'_>,
492        _: &WorkerContext,
493    ) -> Poll<Option<Result<Self::Task, Self::Error>>> {
494        self.poll_next_unpin(cx).map_ok(|(line_id, mut job)| {
495            job.ctx.insert("line_id", line_id.to_string()).unwrap();
496            let task = TaskBuilder::new(job.args)
497                .with_metadata(job.ctx)
498                .task_id(job.task_id);
499            task.build()
500        })
501    }
502
503    fn poll_close(
504        &mut self,
505        _: &mut Context<'_>,
506        _: &WorkerContext,
507    ) -> Poll<Result<(), Self::Error>> {
508        self.flush()?;
509        Poll::Ready(Ok(()))
510    }
511}
512
513impl<Args, A> BackendConfig for FileStorage<Args, A>
514where
515    Args: 'static + Send + Serialize + for<'de> Deserialize<'de> + Unpin,
516    A: Adapter + Unpin + Clone,
517    A::Error: std::error::Error + Send + Sync + 'static,
518{
519    type Args = Args;
520    type Id = RandomId;
521    type Kind = Durable;
522    type Config = PathBuf;
523    type Layer = AcknowledgeLayer<Self>;
524    fn middleware(&mut self, _: &mut WorkerContext) -> Self::Layer {
525        AcknowledgeLayer::new(self.clone())
526    }
527
528    fn config(&self) -> &Self::Config {
529        &self.path
530    }
531}
532
533impl<Args, A> WireFormatBackend for FileStorage<Args, A>
534where
535    Args: 'static + Send + Serialize + for<'de> Deserialize<'de> + Unpin,
536    A: Adapter + Unpin,
537    A::Error: std::error::Error + Send + Sync + 'static,
538{
539    type Codec = JsonCodec<Value>;
540    type Compact = Value;
541    fn codec(&self) -> &Self::Codec {
542        &self.codec
543    }
544}
545
546/// A CSV adapter for serializing and deserializing tasks.
547#[derive(Debug, Clone, Default)]
548pub struct CsvAdapter {
549    /// Column names, in BTreeMap (alphabetical) order.
550    /// Populated on first serialize (write path) or first deserialize (read path).
551    header: Arc<Mutex<Option<Vec<String>>>>,
552}
553
554/// A `FileStorage` using `CsvAdapter` for line encoding.
555pub type CsvStorage<Args> = FileStorage<Args, CsvAdapter>;
556
557impl Adapter for CsvAdapter {
558    type Line = BTreeMap<String, String>;
559
560    type Error = io::Error;
561
562    fn serialize(&self, line: &Self::Line) -> Result<Vec<u8>, Self::Error> {
563        let mut out = Vec::new();
564
565        let mut header = self
566            .header
567            .lock()
568            .map_err(|_| io::Error::other("header mutex poisoned"))?;
569
570        if header.is_none() {
571            let cols: Vec<String> = line.keys().cloned().collect();
572            out.extend_from_slice(format!("{}\n", cols.join(",")).as_bytes());
573            *header = Some(cols);
574        }
575
576        let row = line
577            .values()
578            .map(|v| v.as_str())
579            .collect::<Vec<_>>()
580            .join(",");
581
582        out.extend_from_slice(format!("{row}\n").as_bytes());
583
584        Ok(out)
585    }
586
587    fn deserialize(&self, raw: &[u8]) -> Result<Self::Line, Self::Error> {
588        let row = std::str::from_utf8(raw)
589            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
590            .trim_end_matches('\n');
591
592        let header = self
593            .header
594            .lock()
595            .map_err(|_| io::Error::other("header mutex poisoned"))?;
596
597        let cols = header.as_ref().ok_or_else(|| {
598            io::Error::new(
599                io::ErrorKind::InvalidData,
600                "CsvAdapter: deserialize called before header was parsed",
601            )
602        })?;
603
604        let map = row
605            .split(',')
606            .zip(cols.iter())
607            .map(|(value, key)| (key.clone(), value.to_owned()))
608            .collect::<BTreeMap<_, _>>();
609
610        Ok(map)
611    }
612
613    fn to_entry(&self, line: Self::Line) -> Result<RawTask, Self::Error> {
614        let args = util::to_value(Some("args"), &line);
615
616        let mut result = None;
617
618        if line.contains_key("result") {
619            result = Some(util::to_value(Some("result"), &line));
620        }
621
622        let idempotency_key = line.get("idempotency_key").cloned();
623
624        let task_id = line
625            .get("task_id")
626            .and_then(|s| FromStr::from_str(s).ok())
627            .unwrap_or(TaskId::from_string(RandomId::default()));
628
629        let ctx = line
630            .iter()
631            .filter(|(k, _)| k.starts_with("ctx."))
632            .map(|(k, v)| (k.clone(), v.clone()))
633            .collect::<HashMap<String, String>>();
634
635        let ctx = MetadataStore::from_map(ctx);
636
637        Ok(RawTask {
638            task_id,
639            args,
640            ctx,
641            result,
642            idempotency_key,
643        })
644    }
645
646    fn from_entry(entry: &RawTask) -> Result<Self::Line, Self::Error> {
647        let mut line = BTreeMap::new();
648
649        line.insert("task_id".to_owned(), entry.task_id.to_string());
650
651        let args = util::from_value(Some("args"), &entry.args);
652
653        line.extend(args);
654
655        let result = entry
656            .result
657            .as_ref()
658            .map(|r| util::from_value(Some("result"), r))
659            .unwrap_or_else(|| util::from_value(Some("result"), &serde_json::Value::Null));
660
661        line.extend(result);
662
663        if let Some(idempotency_key) = &entry.idempotency_key {
664            line.insert("idempotency_key".to_owned(), idempotency_key.clone());
665        }
666
667        for (k, value) in entry.ctx.iter() {
668            line.insert(format!("ctx.{k}"), value.clone());
669        }
670
671        Ok(line)
672    }
673
674    fn is_header(&self, raw: &[u8]) -> bool {
675        let Ok(mut header) = self.header.lock() else {
676            return false;
677        };
678
679        if header.is_none() {
680            if let Ok(row) = std::str::from_utf8(raw) {
681                let cols: Vec<String> = row
682                    .trim_end_matches('\n')
683                    .split(',')
684                    .map(|s| s.to_owned())
685                    .collect();
686
687                *header = Some(cols);
688            }
689
690            return true; // this line is the header, skip it
691        }
692
693        false
694    }
695
696    fn header(&self, entries: &Vec<RawTask>) -> Option<Vec<u8>> {
697        let first = entries.first()?;
698
699        let line = Self::from_entry(first).ok()?;
700
701        let cols: Vec<String> = line.keys().cloned().collect();
702        Some(format!("{}\n", cols.join(",")).as_bytes().to_vec())
703    }
704}
705
706impl<Args, Res, A> Acknowledge<Res> for FileStorage<Args, A>
707where
708    Args: Send + 'static + Debug,
709    Res: Serialize,
710    A: Adapter + Clone,
711{
712    type Error = FileStorageError<A>;
713
714    type Future = Ready<Result<(), Self::Error>>;
715
716    fn ack(&mut self, res: &Result<Res, BoxDynError>, ctx: &ExecutionContext) -> Self::Future {
717        let res = |this: &mut Self| {
718            let val = serde_json::to_value(res.as_ref().map_err(|e| e.to_string()))?;
719            let line_id = ctx
720                .metadata()
721                .get("line_id")
722                .unwrap()
723                .parse()
724                .map_err(|e: ParseIntError| FileStorageError::Parse(e.to_string()))?;
725            this.set_result(line_id, val)?;
726            Ok(())
727        };
728
729        ready(res(self))
730    }
731}
732
733impl<Args, A, O> WaitForCompletion<O> for FileStorage<Args, A>
734where
735    O: Send + DeserializeOwned + 'static + Unpin + Serialize,
736    A: Adapter + Unpin + Sync + Clone,
737    A::Error: std::error::Error + Send + Sync + 'static,
738    Args: Serialize + DeserializeOwned + 'static + Send + Sync + Unpin,
739{
740    type ResultStream = BoxStream<'static, Result<TaskResult<O>, FileStorageError<A>>>;
741    fn wait_for(&mut self, task_ids: impl IntoIterator<Item = TaskId>) -> Self::ResultStream {
742        use futures_util::StreamExt;
743        use std::{collections::HashSet, time::Duration};
744
745        let task_ids: HashSet<_> = task_ids.into_iter().collect();
746        struct PollState<Res, T, A: Adapter> {
747            vault: FileStorage<T, A>,
748            pending_tasks: HashSet<TaskId>,
749            poll_interval: Duration,
750            _phantom: std::marker::PhantomData<Res>,
751        }
752        let state = PollState {
753            vault: self.clone(),
754            pending_tasks: task_ids,
755            poll_interval: Duration::from_millis(100),
756            _phantom: std::marker::PhantomData,
757        };
758        futures_util::stream::unfold(state, |mut state: PollState<Value, Args, A>| {
759            async move {
760                if state.pending_tasks.is_empty() {
761                    return None;
762                }
763
764                loop {
765                    // Check for completed tasks
766                    let vault = &state.vault;
767
768                    let completed_task = {
769                        let vault = vault.entries.try_read().ok()?;
770                        vault.iter().find_map(|value| {
771                            let attempt = value
772                                .ctx
773                                .get("attempt")
774                                .map(FromStr::from_str)
775                                .transpose()
776                                .unwrap_or_default()
777                                .unwrap_or_default();
778                            let task_id = value.task_id.clone();
779                            if state.pending_tasks.contains(&task_id) {
780                                Some((task_id, value.result.clone()?, attempt))
781                            } else {
782                                None
783                            }
784                        })
785                    };
786
787                    if let Some((task_id, result, attempt)) = completed_task {
788                        state.pending_tasks.remove(&task_id);
789                        let result: Result<O, String> = serde_json::from_value(result).unwrap();
790                        return Some((
791                            Ok(TaskResult {
792                                task_id,
793                                status: Status::Done,
794                                result,
795                                attempt,
796                            }),
797                            state,
798                        ));
799                    }
800
801                    // No completed tasks, wait and try again
802                    apalis_core::timer::sleep(state.poll_interval).await;
803                }
804            }
805        })
806        .boxed()
807    }
808
809    async fn check_status(
810        &mut self,
811        task_ids: impl IntoIterator<Item = TaskId> + Send,
812    ) -> Result<Vec<TaskResult<O>>, Self::Error> {
813        use apalis_core::task::status::Status;
814        use std::collections::HashSet;
815        let task_ids: HashSet<_> = task_ids.into_iter().collect();
816        let mut results = Vec::new();
817        for task_id in task_ids {
818            if let Some(value) = self
819                .entries
820                .try_read()
821                .unwrap()
822                .iter()
823                .find(|s| s.task_id == task_id)
824            {
825                let attempt = value
826                    .ctx
827                    .get("attempt")
828                    .map(FromStr::from_str)
829                    .transpose()
830                    .unwrap_or_default()
831                    .unwrap_or_default();
832                if value.result.is_none() {
833                    results.push(TaskResult {
834                        task_id: task_id.clone(),
835                        status: Status::Pending,
836                        result: Err("Task not completed yet".to_owned()),
837                        attempt,
838                    });
839                    continue;
840                }
841                let result = match serde_json::from_value::<Result<O, String>>(
842                    value.result.clone().unwrap(),
843                ) {
844                    Ok(result) => TaskResult {
845                        task_id: task_id.clone(),
846                        status: Status::Done,
847                        result,
848                        attempt,
849                    },
850                    Err(e) => TaskResult {
851                        task_id: task_id.clone(),
852                        status: Status::Failed,
853                        result: Err(format!("Deserialization error: {e}")),
854                        attempt,
855                    },
856                };
857                results.push(result);
858            }
859        }
860        Ok(results)
861    }
862}
863
864impl<Args: Send + 'static, A> TryNewBackend for FileStorage<Args, A>
865where
866    A: Adapter + Default + Unpin + Sync + Clone,
867    A::Error: std::error::Error + Send + Sync + 'static,
868    Args: Serialize + DeserializeOwned + 'static + Send + Sync + Unpin,
869{
870    type Backend = Self;
871    fn try_new(config: Self::Config) -> Result<Self::Backend, Self::Error> {
872        Self::new(config)
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use std::time::Duration;
880
881    use apalis_core::{
882        backend::TaskSink,
883        error::BoxDynError,
884        worker::{
885            builder::WorkerBuilder, context::WorkerContext, ext::event_listener::EventListenerExt,
886        },
887    };
888
889    const ITEMS: u32 = 100;
890
891    #[tokio::test]
892    async fn json_worker() {
893        let mut json_store = JsonStorage::new_temp().unwrap();
894        for i in 0..ITEMS {
895            json_store.push(i).await.unwrap();
896        }
897
898        async fn task(task: u32, worker: WorkerContext) -> Result<(), BoxDynError> {
899            tokio::time::sleep(Duration::from_secs(1)).await;
900            if task == ITEMS - 1 {
901                worker.stop().unwrap();
902                return Err("Worker stopped!")?;
903            }
904            Ok(())
905        }
906
907        let worker = WorkerBuilder::new("rango-tango-json")
908            .backend(json_store)
909            .on_event(|worker, ev| {
910                println!("On Event = {ev:?} from = {}", worker.name());
911            })
912            .build(task);
913        worker.run().await.unwrap();
914    }
915
916    #[tokio::test]
917    async fn csv_worker() {
918        #[derive(Debug, Serialize, Deserialize, Default)]
919        struct Email {
920            to: String,
921            subject: String,
922            index: u32,
923        }
924
925        let mut csv_store = CsvStorage::new_temp().unwrap();
926        for i in 0..ITEMS {
927            csv_store
928                .push(Email {
929                    subject: "Test".to_string(),
930                    to: "test".to_string(),
931                    index: i,
932                })
933                .await
934                .unwrap();
935        }
936
937        async fn task(task: Email, worker: WorkerContext) -> Result<(), BoxDynError> {
938            tokio::time::sleep(Duration::from_secs(1)).await;
939            if task.index == ITEMS - 1 {
940                worker.stop().unwrap();
941                return Err("Worker stopped!")?;
942            }
943            Ok(())
944        }
945
946        let worker = WorkerBuilder::new("rango-tango-csv")
947            .backend(csv_store)
948            .on_event(|worker, ev| {
949                println!("On Event = {ev:?} from = {}", worker.name());
950            })
951            .build(task);
952        worker.run().await.unwrap();
953    }
954}