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::{timestamp_nanos_saturating, 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 {
33 Self {
34 root: root.into(),
35 lock: Arc::new(Mutex::new(())),
36 }
37 }
38
39 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 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 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 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 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 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}