a3s_flow/worker/
local_file.rs1use 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::{FlowTask, FlowTaskLease, FlowTaskQueue};
15
16#[derive(Debug, Clone)]
25pub struct LocalFileFlowTaskQueue {
26 root: PathBuf,
27 lock: Arc<Mutex<()>>,
28}
29
30impl LocalFileFlowTaskQueue {
31 pub fn new(root: impl Into<PathBuf>) -> Self {
32 Self {
33 root: root.into(),
34 lock: Arc::new(Mutex::new(())),
35 }
36 }
37
38 pub fn root(&self) -> &Path {
39 &self.root
40 }
41
42 fn pending_dir(&self) -> PathBuf {
43 self.root.join("pending")
44 }
45
46 fn inflight_dir(&self) -> PathBuf {
47 self.root.join("inflight")
48 }
49
50 fn dead_letter_dir(&self) -> PathBuf {
51 self.root.join("dead")
52 }
53
54 fn temp_path(&self, id: Uuid) -> PathBuf {
55 self.root.join(format!(".{id}.tmp"))
56 }
57
58 fn queue_file_name(now: DateTime<Utc>, id: Uuid) -> String {
59 let timestamp = now
60 .timestamp_nanos_opt()
61 .unwrap_or_else(|| now.timestamp_micros() * 1_000);
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) -> PathBuf {
77 self.inflight_dir().join(lease_id)
78 }
79
80 fn dead_letter_path(&self, name: &str) -> PathBuf {
81 self.dead_letter_dir().join(name)
82 }
83
84 async fn json_files(dir: PathBuf) -> Result<Vec<PathBuf>> {
85 let mut files = Vec::new();
86 let mut dir = match tokio::fs::read_dir(dir).await {
87 Ok(dir) => dir,
88 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(files),
89 Err(err) => return Err(FlowError::Io(err)),
90 };
91
92 while let Some(entry) = dir.next_entry().await? {
93 let path = entry.path();
94 if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
95 files.push(path);
96 }
97 }
98 files.sort();
99 Ok(files)
100 }
101
102 async fn pending_files(&self) -> Result<Vec<PathBuf>> {
103 Self::json_files(self.pending_dir()).await
104 }
105
106 async fn inflight_files(&self) -> Result<Vec<PathBuf>> {
107 Self::json_files(self.inflight_dir()).await
108 }
109
110 async fn dead_letter_files(&self) -> Result<Vec<PathBuf>> {
111 Self::json_files(self.dead_letter_dir()).await
112 }
113
114 async fn read_task_file(path: &Path) -> Result<FlowTask> {
115 let bytes = tokio::fs::read(path).await?;
116 serde_json::from_slice(&bytes).map_err(|err| {
117 FlowError::Store(format!(
118 "failed to decode queued task from {}: {err}",
119 path.display()
120 ))
121 })
122 }
123
124 async fn write_json_file<T: Serialize>(&self, path: &Path, value: &T) -> Result<()> {
125 let id = Uuid::new_v4();
126 let temp_path = self.temp_path(id);
127
128 let mut file = File::create(&temp_path).await?;
129 file.write_all(serde_json::to_string(value)?.as_bytes())
130 .await?;
131 file.write_all(b"\n").await?;
132 file.flush().await?;
133 file.sync_data().await?;
134 drop(file);
135
136 tokio::fs::rename(temp_path, path).await?;
137 Ok(())
138 }
139
140 async fn requeue_inflight_paths(&self, paths: Vec<PathBuf>) -> Result<usize> {
141 tokio::fs::create_dir_all(self.pending_dir()).await?;
142 let mut count = 0usize;
143 for path in paths {
144 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
145 continue;
146 };
147 tokio::fs::rename(&path, self.pending_path(file_name)).await?;
148 count += 1;
149 }
150 Ok(count)
151 }
152
153 async fn expired_inflight_paths(&self, cutoff: DateTime<Utc>) -> Result<Vec<PathBuf>> {
154 let cutoff = cutoff
155 .timestamp_nanos_opt()
156 .unwrap_or_else(|| cutoff.timestamp_micros() * 1_000);
157 Ok(self
158 .inflight_files()
159 .await?
160 .into_iter()
161 .filter(|path| {
162 Self::file_timestamp_nanos(path).is_some_and(|leased_at| leased_at <= cutoff)
163 })
164 .collect())
165 }
166
167 pub async fn inflight_len(&self) -> Result<usize> {
168 let _guard = self.lock.lock().await;
169 Ok(self.inflight_files().await?.len())
170 }
171
172 pub async fn dead_letter_len(&self) -> Result<usize> {
173 let _guard = self.lock.lock().await;
174 Ok(self.dead_letter_files().await?.len())
175 }
176
177 pub async fn dead_lettered_tasks(&self) -> Result<Vec<LocalFileDeadLetteredTask>> {
178 let _guard = self.lock.lock().await;
179 let mut records = Vec::new();
180 for path in self.dead_letter_files().await? {
181 let bytes = tokio::fs::read(&path).await?;
182 let record = serde_json::from_slice(&bytes).map_err(|err| {
183 FlowError::Store(format!(
184 "failed to decode dead-lettered task from {}: {err}",
185 path.display()
186 ))
187 })?;
188 records.push(record);
189 }
190 Ok(records)
191 }
192
193 pub async fn requeue_inflight_older_than(&self, cutoff: DateTime<Utc>) -> Result<usize> {
194 let _guard = self.lock.lock().await;
195 let expired = self.expired_inflight_paths(cutoff).await?;
196 self.requeue_inflight_paths(expired).await
197 }
198
199 pub async fn dead_letter_inflight_older_than(
200 &self,
201 cutoff: DateTime<Utc>,
202 reason: impl Into<String>,
203 ) -> Result<usize> {
204 let _guard = self.lock.lock().await;
205 tokio::fs::create_dir_all(self.dead_letter_dir()).await?;
206 let reason = reason.into();
207 let mut count = 0usize;
208 for path in self.expired_inflight_paths(cutoff).await? {
209 let Some(lease_id) = path
210 .file_name()
211 .and_then(|name| name.to_str())
212 .map(str::to_string)
213 else {
214 continue;
215 };
216 let task = Self::read_task_file(&path).await?;
217 let record = LocalFileDeadLetteredTask {
218 lease_id,
219 task,
220 reason: reason.clone(),
221 dead_lettered_at: Utc::now(),
222 };
223 let dead_path = self.dead_letter_path(&Self::queue_file_name(
224 record.dead_lettered_at,
225 Uuid::new_v4(),
226 ));
227 self.write_json_file(&dead_path, &record).await?;
228 tokio::fs::remove_file(&path).await?;
229 count += 1;
230 }
231 Ok(count)
232 }
233}
234
235#[async_trait]
236impl FlowTaskQueue for LocalFileFlowTaskQueue {
237 async fn enqueue(&self, task: FlowTask) -> Result<()> {
238 let _guard = self.lock.lock().await;
239 tokio::fs::create_dir_all(self.pending_dir()).await?;
240
241 let id = Uuid::new_v4();
242 let file_name = Self::queue_file_name(Utc::now(), id);
243 let temp_path = self.temp_path(id);
244 let pending_path = self.pending_path(&file_name);
245
246 let mut file = File::create(&temp_path).await?;
247 file.write_all(serde_json::to_string(&task)?.as_bytes())
248 .await?;
249 file.write_all(b"\n").await?;
250 file.flush().await?;
251 file.sync_data().await?;
252 drop(file);
253
254 tokio::fs::rename(temp_path, pending_path).await?;
255 Ok(())
256 }
257
258 async fn lease(&self) -> Result<Option<FlowTaskLease>> {
259 let _guard = self.lock.lock().await;
260 tokio::fs::create_dir_all(self.inflight_dir()).await?;
261 let Some(path) = self.pending_files().await?.into_iter().next() else {
262 return Ok(None);
263 };
264 if path.file_name().and_then(|name| name.to_str()).is_none() {
265 return Err(FlowError::Store(format!(
266 "queued task path {} does not have a valid file name",
267 path.display()
268 )));
269 };
270 let lease_id = Self::queue_file_name(Utc::now(), Uuid::new_v4());
271 let inflight_path = self.inflight_path(&lease_id);
272 tokio::fs::rename(&path, &inflight_path).await?;
273
274 let task = Self::read_task_file(&inflight_path).await?;
275 Ok(Some(FlowTaskLease { lease_id, task }))
276 }
277
278 async fn heartbeat(&self, lease_id: &str) -> Result<String> {
279 let _guard = self.lock.lock().await;
280 let current_path = self.inflight_path(lease_id);
281 let renewed_lease_id = Self::queue_file_name(Utc::now(), Uuid::new_v4());
282 let renewed_path = self.inflight_path(&renewed_lease_id);
283 match tokio::fs::rename(current_path, renewed_path).await {
284 Ok(()) => Ok(renewed_lease_id),
285 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
286 Err(FlowError::LeaseLost(lease_id.to_string()))
287 }
288 Err(err) => Err(FlowError::Io(err)),
289 }
290 }
291
292 async fn ack(&self, lease_id: &str) -> Result<()> {
293 let _guard = self.lock.lock().await;
294 let path = self.inflight_path(lease_id);
295 match tokio::fs::remove_file(&path).await {
296 Ok(()) => Ok(()),
297 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
298 Err(FlowError::LeaseLost(lease_id.to_string()))
299 }
300 Err(err) => Err(FlowError::Io(err)),
301 }
302 }
303
304 async fn requeue_inflight(&self) -> Result<usize> {
305 let _guard = self.lock.lock().await;
306 let paths = self.inflight_files().await?;
307 self.requeue_inflight_paths(paths).await
308 }
309
310 async fn len(&self) -> Result<usize> {
311 let _guard = self.lock.lock().await;
312 Ok(self.pending_files().await?.len())
313 }
314}