Skip to main content

a3s_flow/store/
local_file.rs

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