a3s_flow/store/
local_file.rs1use 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#[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 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}