Skip to main content

a3s_flow/worker/
local_file.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use serde::Serialize;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use tokio::fs::File;
7use tokio::io::AsyncWriteExt;
8use tokio::sync::Mutex;
9use uuid::Uuid;
10
11use crate::error::{FlowError, Result};
12
13pub use super::task::LocalFileDeadLetteredTask;
14use super::{timestamp_nanos_saturating, FlowTask, FlowTaskLease, FlowTaskQueue};
15
16/// JSON-backed local durable task queue.
17///
18/// Tasks are stored as one JSON file per pending item under `<root>/pending`.
19/// The queue serializes access inside the current process. It is intended for
20/// embedded hosts and local crash/restart durability of pending tasks; it does
21/// not provide cross-process locking. Heartbeats atomically rename inflight
22/// files, making the replacement file name a new fencing token and lease-age
23/// timestamp.
24#[derive(Debug, Clone)]
25pub struct LocalFileFlowTaskQueue {
26    root: PathBuf,
27    lock: Arc<Mutex<()>>,
28}
29
30impl LocalFileFlowTaskQueue {
31    /// Creates a local durable queue rooted at `root`.
32    pub fn new(root: impl Into<PathBuf>) -> Self {
33        Self {
34            root: root.into(),
35            lock: Arc::new(Mutex::new(())),
36        }
37    }
38
39    /// Returns the queue root directory.
40    pub fn root(&self) -> &Path {
41        &self.root
42    }
43
44    fn pending_dir(&self) -> PathBuf {
45        self.root.join("pending")
46    }
47
48    fn inflight_dir(&self) -> PathBuf {
49        self.root.join("inflight")
50    }
51
52    fn dead_letter_dir(&self) -> PathBuf {
53        self.root.join("dead")
54    }
55
56    fn temp_path(&self, id: Uuid) -> PathBuf {
57        self.root.join(format!(".{id}.tmp"))
58    }
59
60    fn queue_file_name(now: DateTime<Utc>, id: Uuid) -> String {
61        let timestamp = timestamp_nanos_saturating(now);
62        format!("{timestamp:020}-{id}.json")
63    }
64
65    fn file_timestamp_nanos(path: &Path) -> Option<i64> {
66        path.file_name()
67            .and_then(|name| name.to_str())
68            .and_then(|name| name.split_once('-'))
69            .and_then(|(timestamp, _)| timestamp.parse::<i64>().ok())
70    }
71
72    fn pending_path(&self, name: &str) -> PathBuf {
73        self.pending_dir().join(name)
74    }
75
76    fn inflight_path(&self, lease_id: &str) -> Result<PathBuf> {
77        if !Self::is_canonical_lease_id(lease_id) {
78            return Err(FlowError::LeaseLost(lease_id.to_string()));
79        }
80        Ok(self.inflight_dir().join(lease_id))
81    }
82
83    fn is_canonical_lease_id(lease_id: &str) -> bool {
84        let Some((timestamp, uuid_with_extension)) = lease_id.split_once('-') else {
85            return false;
86        };
87        if timestamp.len() != 20 || !timestamp.bytes().all(|byte| byte.is_ascii_digit()) {
88            return false;
89        }
90        let Ok(timestamp_value) = timestamp.parse::<i64>() else {
91            return false;
92        };
93        if format!("{timestamp_value:020}") != timestamp {
94            return false;
95        }
96
97        let Some(uuid_text) = uuid_with_extension.strip_suffix(".json") else {
98            return false;
99        };
100        let Ok(uuid) = Uuid::parse_str(uuid_text) else {
101            return false;
102        };
103        uuid.get_version_num() == 4
104            && uuid.get_variant() == uuid::Variant::RFC4122
105            && uuid.hyphenated().to_string() == uuid_text
106    }
107
108    fn dead_letter_path(&self, name: &str) -> PathBuf {
109        self.dead_letter_dir().join(name)
110    }
111
112    async fn json_files(dir: PathBuf) -> Result<Vec<PathBuf>> {
113        let mut files = Vec::new();
114        let mut dir = match tokio::fs::read_dir(dir).await {
115            Ok(dir) => dir,
116            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(files),
117            Err(err) => return Err(FlowError::Io(err)),
118        };
119
120        while let Some(entry) = dir.next_entry().await? {
121            let path = entry.path();
122            if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
123                files.push(path);
124            }
125        }
126        files.sort();
127        Ok(files)
128    }
129
130    async fn pending_files(&self) -> Result<Vec<PathBuf>> {
131        Self::json_files(self.pending_dir()).await
132    }
133
134    async fn inflight_files(&self) -> Result<Vec<PathBuf>> {
135        Self::json_files(self.inflight_dir()).await
136    }
137
138    async fn dead_letter_files(&self) -> Result<Vec<PathBuf>> {
139        Self::json_files(self.dead_letter_dir()).await
140    }
141
142    async fn read_task_file(path: &Path) -> Result<FlowTask> {
143        let bytes = tokio::fs::read(path).await?;
144        serde_json::from_slice(&bytes).map_err(|err| {
145            FlowError::Store(format!(
146                "failed to decode queued task from {}: {err}",
147                path.display()
148            ))
149        })
150    }
151
152    async fn write_json_file<T: Serialize>(&self, path: &Path, value: &T) -> Result<()> {
153        let id = Uuid::new_v4();
154        let temp_path = self.temp_path(id);
155
156        let mut file = File::create(&temp_path).await?;
157        file.write_all(serde_json::to_string(value)?.as_bytes())
158            .await?;
159        file.write_all(b"\n").await?;
160        file.flush().await?;
161        file.sync_data().await?;
162        drop(file);
163
164        tokio::fs::rename(temp_path, path).await?;
165        Ok(())
166    }
167
168    async fn requeue_inflight_paths(&self, paths: Vec<PathBuf>) -> Result<usize> {
169        tokio::fs::create_dir_all(self.pending_dir()).await?;
170        let mut count = 0usize;
171        for path in paths {
172            let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
173                continue;
174            };
175            tokio::fs::rename(&path, self.pending_path(file_name)).await?;
176            count += 1;
177        }
178        Ok(count)
179    }
180
181    async fn expired_inflight_paths(&self, cutoff: DateTime<Utc>) -> Result<Vec<PathBuf>> {
182        let cutoff = timestamp_nanos_saturating(cutoff);
183        Ok(self
184            .inflight_files()
185            .await?
186            .into_iter()
187            .filter(|path| {
188                Self::file_timestamp_nanos(path).is_some_and(|leased_at| leased_at <= cutoff)
189            })
190            .collect())
191    }
192
193    /// Returns the number of currently leased task files.
194    pub async fn inflight_len(&self) -> Result<usize> {
195        let _guard = self.lock.lock().await;
196        Ok(self.inflight_files().await?.len())
197    }
198
199    /// Returns the number of dead-lettered task files.
200    pub async fn dead_letter_len(&self) -> Result<usize> {
201        let _guard = self.lock.lock().await;
202        Ok(self.dead_letter_files().await?.len())
203    }
204
205    /// Loads dead-lettered tasks in durable file order.
206    pub async fn dead_lettered_tasks(&self) -> Result<Vec<LocalFileDeadLetteredTask>> {
207        let _guard = self.lock.lock().await;
208        let mut records = Vec::new();
209        for path in self.dead_letter_files().await? {
210            let bytes = tokio::fs::read(&path).await?;
211            let record = serde_json::from_slice(&bytes).map_err(|err| {
212                FlowError::Store(format!(
213                    "failed to decode dead-lettered task from {}: {err}",
214                    path.display()
215                ))
216            })?;
217            records.push(record);
218        }
219        Ok(records)
220    }
221
222    /// Returns leases at or before `cutoff` to pending dispatch.
223    pub async fn requeue_inflight_older_than(&self, cutoff: DateTime<Utc>) -> Result<usize> {
224        let _guard = self.lock.lock().await;
225        let expired = self.expired_inflight_paths(cutoff).await?;
226        self.requeue_inflight_paths(expired).await
227    }
228
229    /// Moves leases at or before `cutoff` into durable dead-letter files.
230    pub async fn dead_letter_inflight_older_than(
231        &self,
232        cutoff: DateTime<Utc>,
233        reason: impl Into<String>,
234    ) -> Result<usize> {
235        let _guard = self.lock.lock().await;
236        tokio::fs::create_dir_all(self.dead_letter_dir()).await?;
237        let reason = reason.into();
238        let mut count = 0usize;
239        for path in self.expired_inflight_paths(cutoff).await? {
240            let Some(lease_id) = path
241                .file_name()
242                .and_then(|name| name.to_str())
243                .map(str::to_string)
244            else {
245                continue;
246            };
247            let task = Self::read_task_file(&path).await?;
248            let record = LocalFileDeadLetteredTask {
249                lease_id,
250                task,
251                reason: reason.clone(),
252                dead_lettered_at: Utc::now(),
253            };
254            let dead_path = self.dead_letter_path(&Self::queue_file_name(
255                record.dead_lettered_at,
256                Uuid::new_v4(),
257            ));
258            self.write_json_file(&dead_path, &record).await?;
259            tokio::fs::remove_file(&path).await?;
260            count += 1;
261        }
262        Ok(count)
263    }
264}
265
266#[async_trait]
267impl FlowTaskQueue for LocalFileFlowTaskQueue {
268    async fn enqueue(&self, task: FlowTask) -> Result<()> {
269        let _guard = self.lock.lock().await;
270        tokio::fs::create_dir_all(self.pending_dir()).await?;
271
272        let id = Uuid::new_v4();
273        let file_name = Self::queue_file_name(Utc::now(), id);
274        let temp_path = self.temp_path(id);
275        let pending_path = self.pending_path(&file_name);
276
277        let mut file = File::create(&temp_path).await?;
278        file.write_all(serde_json::to_string(&task)?.as_bytes())
279            .await?;
280        file.write_all(b"\n").await?;
281        file.flush().await?;
282        file.sync_data().await?;
283        drop(file);
284
285        tokio::fs::rename(temp_path, pending_path).await?;
286        Ok(())
287    }
288
289    async fn lease(&self) -> Result<Option<FlowTaskLease>> {
290        let _guard = self.lock.lock().await;
291        tokio::fs::create_dir_all(self.inflight_dir()).await?;
292        let Some(path) = self.pending_files().await?.into_iter().next() else {
293            return Ok(None);
294        };
295        if path.file_name().and_then(|name| name.to_str()).is_none() {
296            return Err(FlowError::Store(format!(
297                "queued task path {} does not have a valid file name",
298                path.display()
299            )));
300        };
301        let lease_id = Self::queue_file_name(Utc::now(), Uuid::new_v4());
302        let inflight_path = self.inflight_path(&lease_id)?;
303        tokio::fs::rename(&path, &inflight_path).await?;
304
305        let task = Self::read_task_file(&inflight_path).await?;
306        Ok(Some(FlowTaskLease { lease_id, task }))
307    }
308
309    async fn heartbeat(&self, lease_id: &str) -> Result<String> {
310        let _guard = self.lock.lock().await;
311        let current_path = self.inflight_path(lease_id)?;
312        let renewed_lease_id = Self::queue_file_name(Utc::now(), Uuid::new_v4());
313        let renewed_path = self.inflight_path(&renewed_lease_id)?;
314        match tokio::fs::rename(current_path, renewed_path).await {
315            Ok(()) => Ok(renewed_lease_id),
316            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
317                Err(FlowError::LeaseLost(lease_id.to_string()))
318            }
319            Err(err) => Err(FlowError::Io(err)),
320        }
321    }
322
323    async fn ack(&self, lease_id: &str) -> Result<()> {
324        let _guard = self.lock.lock().await;
325        let path = self.inflight_path(lease_id)?;
326        match tokio::fs::remove_file(&path).await {
327            Ok(()) => Ok(()),
328            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
329                Err(FlowError::LeaseLost(lease_id.to_string()))
330            }
331            Err(err) => Err(FlowError::Io(err)),
332        }
333    }
334
335    async fn requeue_inflight(&self) -> Result<usize> {
336        let _guard = self.lock.lock().await;
337        let paths = self.inflight_files().await?;
338        self.requeue_inflight_paths(paths).await
339    }
340
341    async fn len(&self) -> Result<usize> {
342        let _guard = self.lock.lock().await;
343        Ok(self.pending_files().await?.len())
344    }
345}