Skip to main content

a3s_flow/store/
local_file.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use tokio::fs::{File, OpenOptions};
6use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
7use tokio::sync::Mutex;
8use uuid::Uuid;
9
10use crate::error::{FlowError, Result};
11use crate::model::{project_run, FlowEvent, FlowEventEnvelope};
12
13use super::FlowEventStore;
14
15/// JSONL-backed event store for local durable runs.
16///
17/// Each workflow run is stored as `<root>/<run_id>.jsonl`; every line is a full
18/// [`FlowEventEnvelope`]. The store serializes appends inside this process, but
19/// it does not provide cross-process locking. Use it for local development,
20/// embedded Rust hosts, and crash/restart durability; use a database-backed
21/// store for multi-writer deployments.
22#[derive(Debug, Clone)]
23pub struct LocalFileEventStore {
24    root: PathBuf,
25    lock: Arc<Mutex<()>>,
26}
27
28impl LocalFileEventStore {
29    pub fn new(root: impl Into<PathBuf>) -> Self {
30        Self {
31            root: root.into(),
32            lock: Arc::new(Mutex::new(())),
33        }
34    }
35
36    pub fn root(&self) -> &Path {
37        &self.root
38    }
39
40    fn run_path(&self, run_id: &str) -> Result<PathBuf> {
41        if !is_safe_run_id(run_id) {
42            return Err(FlowError::Store(format!(
43                "run id {run_id:?} is not safe for local file storage"
44            )));
45        }
46        Ok(self.root.join(format!("{run_id}.jsonl")))
47    }
48
49    async fn list_inner(
50        &self,
51        run_id: &str,
52        missing_is_empty: bool,
53    ) -> Result<Vec<FlowEventEnvelope>> {
54        let path = self.run_path(run_id)?;
55        let file = match File::open(&path).await {
56            Ok(file) => file,
57            Err(err) if err.kind() == std::io::ErrorKind::NotFound && missing_is_empty => {
58                return Ok(Vec::new());
59            }
60            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
61                return Err(FlowError::RunNotFound(run_id.to_string()));
62            }
63            Err(err) => return Err(FlowError::Io(err)),
64        };
65
66        let mut lines = BufReader::new(file).lines();
67        let mut events = Vec::new();
68        let mut line_no = 0usize;
69        while let Some(line) = lines.next_line().await? {
70            line_no += 1;
71            if line.trim().is_empty() {
72                continue;
73            }
74            let envelope: FlowEventEnvelope = serde_json::from_str(&line).map_err(|err| {
75                FlowError::Store(format!(
76                    "failed to decode event line {line_no} from {}: {err}",
77                    path.display()
78                ))
79            })?;
80            if envelope.run_id != run_id {
81                return Err(FlowError::Store(format!(
82                    "event line {line_no} in {} belongs to run {}, not {run_id}",
83                    path.display(),
84                    envelope.run_id
85                )));
86            }
87            events.push(envelope);
88        }
89
90        Ok(events)
91    }
92
93    fn validate_existing_log(&self, run_id: &str, events: &[FlowEventEnvelope]) -> Result<()> {
94        if events.is_empty() {
95            return Ok(());
96        }
97        project_run(run_id, events)?;
98        Ok(())
99    }
100
101    async fn append_inner(&self, run_id: &str, event: FlowEvent) -> Result<FlowEventEnvelope> {
102        tokio::fs::create_dir_all(&self.root).await?;
103
104        let events = self.list_inner(run_id, true).await?;
105        self.validate_existing_log(run_id, &events)?;
106        let envelope = FlowEventEnvelope {
107            run_id: run_id.to_string(),
108            sequence: events.last().map_or(1, |event| event.sequence + 1),
109            event_id: Uuid::new_v4(),
110            timestamp: Utc::now(),
111            event,
112        };
113
114        self.write_envelope(&envelope).await?;
115        Ok(envelope)
116    }
117
118    async fn append_if_sequence_inner(
119        &self,
120        run_id: &str,
121        expected_sequence: u64,
122        event: FlowEvent,
123    ) -> Result<FlowEventEnvelope> {
124        tokio::fs::create_dir_all(&self.root).await?;
125
126        let events = self.list_inner(run_id, true).await?;
127        self.validate_existing_log(run_id, &events)?;
128        let actual_sequence = events.last().map_or(0, |event| event.sequence);
129        if actual_sequence != expected_sequence {
130            return Err(FlowError::EventConflict {
131                run_id: run_id.to_string(),
132                expected_sequence,
133                actual_sequence,
134            });
135        }
136
137        let envelope = FlowEventEnvelope {
138            run_id: run_id.to_string(),
139            sequence: actual_sequence + 1,
140            event_id: Uuid::new_v4(),
141            timestamp: Utc::now(),
142            event,
143        };
144
145        self.write_envelope(&envelope).await?;
146        Ok(envelope)
147    }
148
149    async fn write_envelope(&self, envelope: &FlowEventEnvelope) -> Result<()> {
150        let path = self.run_path(&envelope.run_id)?;
151        let mut file = OpenOptions::new()
152            .create(true)
153            .append(true)
154            .open(path)
155            .await?;
156        file.write_all(serde_json::to_string(envelope)?.as_bytes())
157            .await?;
158        file.write_all(b"\n").await?;
159        file.flush().await?;
160        file.sync_data().await?;
161        Ok(())
162    }
163
164    async fn list_run_ids_inner(&self) -> Result<Vec<String>> {
165        let mut ids = Vec::new();
166
167        let mut dir = match tokio::fs::read_dir(&self.root).await {
168            Ok(dir) => dir,
169            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(ids),
170            Err(err) => return Err(FlowError::Io(err)),
171        };
172
173        while let Some(entry) = dir.next_entry().await? {
174            let path = entry.path();
175            if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
176                continue;
177            }
178            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
179                continue;
180            };
181            if is_safe_run_id(stem) {
182                ids.push(stem.to_string());
183            }
184        }
185
186        ids.sort();
187        Ok(ids)
188    }
189
190    /// Remove completed, failed, or cancelled local run histories whose terminal
191    /// event timestamp is strictly before `terminal_before`.
192    ///
193    /// Suspended and running runs are never removed by this helper. Corrupt
194    /// histories are returned as errors rather than deleted, so operators can
195    /// inspect them before cleanup.
196    pub async fn prune_terminal_runs_older_than(
197        &self,
198        terminal_before: DateTime<Utc>,
199    ) -> Result<Vec<String>> {
200        let _guard = self.lock.lock().await;
201        let mut removed = Vec::new();
202
203        for run_id in self.list_run_ids_inner().await? {
204            let events = self.list_inner(&run_id, false).await?;
205            self.validate_existing_log(&run_id, &events)?;
206            let Some(terminal_at) = terminal_event_timestamp(&events) else {
207                continue;
208            };
209            if terminal_at >= terminal_before {
210                continue;
211            }
212
213            let path = self.run_path(&run_id)?;
214            match tokio::fs::remove_file(&path).await {
215                Ok(()) => removed.push(run_id),
216                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
217                Err(err) => return Err(FlowError::Io(err)),
218            }
219        }
220
221        removed.sort();
222        Ok(removed)
223    }
224}
225
226#[async_trait]
227impl FlowEventStore for LocalFileEventStore {
228    async fn append(&self, run_id: &str, event: FlowEvent) -> Result<FlowEventEnvelope> {
229        let _guard = self.lock.lock().await;
230        self.append_inner(run_id, event).await
231    }
232
233    async fn append_if_sequence(
234        &self,
235        run_id: &str,
236        expected_sequence: u64,
237        event: FlowEvent,
238    ) -> Result<FlowEventEnvelope> {
239        let _guard = self.lock.lock().await;
240        self.append_if_sequence_inner(run_id, expected_sequence, event)
241            .await
242    }
243
244    async fn list(&self, run_id: &str) -> Result<Vec<FlowEventEnvelope>> {
245        let _guard = self.lock.lock().await;
246        self.list_inner(run_id, false).await
247    }
248
249    async fn list_run_ids(&self) -> Result<Vec<String>> {
250        let _guard = self.lock.lock().await;
251        self.list_run_ids_inner().await
252    }
253}
254
255fn terminal_event_timestamp(events: &[FlowEventEnvelope]) -> Option<DateTime<Utc>> {
256    events
257        .iter()
258        .rev()
259        .find_map(|envelope| match envelope.event {
260            FlowEvent::RunCompleted { .. }
261            | FlowEvent::RunFailed { .. }
262            | FlowEvent::RunCancelled { .. } => Some(envelope.timestamp),
263            _ => None,
264        })
265}
266
267fn is_safe_run_id(run_id: &str) -> bool {
268    !run_id.is_empty()
269        && run_id
270            .chars()
271            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
272}