bamboo_engine/workflow_run/
repository.rs1use std::io;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Weak};
4
5use async_trait::async_trait;
6use bamboo_domain::{WorkflowRunEvent, WorkflowRunSnapshot};
7use dashmap::mapref::entry::Entry;
8use dashmap::DashMap;
9use tokio::io::AsyncWriteExt;
10use tokio::sync::Mutex;
11
12#[async_trait]
13pub trait WorkflowRunRepository: Send + Sync {
14 async fn create(
15 &self,
16 snapshot: &WorkflowRunSnapshot,
17 event: &WorkflowRunEvent,
18 ) -> io::Result<()>;
19 async fn commit(
20 &self,
21 snapshot: &WorkflowRunSnapshot,
22 event: &WorkflowRunEvent,
23 ) -> io::Result<()>;
24 async fn load(&self, run_id: &str) -> io::Result<Option<WorkflowRunSnapshot>>;
25 async fn events_since(&self, run_id: &str, sequence: u64) -> io::Result<Vec<WorkflowRunEvent>>;
26 async fn list_run_ids(&self) -> io::Result<Vec<String>>;
27}
28
29pub struct FileWorkflowRunRepository {
33 root: PathBuf,
34 locks: DashMap<String, Weak<Mutex<()>>>,
35}
36
37impl FileWorkflowRunRepository {
38 pub fn new(root: PathBuf) -> io::Result<Self> {
39 std::fs::create_dir_all(&root)?;
40 Ok(Self {
41 root,
42 locks: DashMap::new(),
43 })
44 }
45
46 fn validate_id(run_id: &str) -> io::Result<()> {
47 if run_id.is_empty()
48 || run_id.len() > 128
49 || !run_id
50 .bytes()
51 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
52 {
53 return Err(io::Error::new(
54 io::ErrorKind::InvalidInput,
55 "invalid run id",
56 ));
57 }
58 Ok(())
59 }
60
61 fn run_dir(&self, run_id: &str) -> io::Result<PathBuf> {
62 Self::validate_id(run_id)?;
63 Ok(self.root.join(run_id))
64 }
65
66 fn lock(&self, run_id: &str) -> Arc<Mutex<()>> {
67 self.locks.retain(|_, lock| lock.strong_count() > 0);
68 match self.locks.entry(run_id.to_owned()) {
69 Entry::Occupied(mut entry) => {
70 if let Some(lock) = entry.get().upgrade() {
71 lock
72 } else {
73 let lock = Arc::new(Mutex::new(()));
74 entry.insert(Arc::downgrade(&lock));
75 lock
76 }
77 }
78 Entry::Vacant(entry) => {
79 let lock = Arc::new(Mutex::new(()));
80 entry.insert(Arc::downgrade(&lock));
81 lock
82 }
83 }
84 }
85
86 async fn commit_locked(
87 &self,
88 snapshot: &WorkflowRunSnapshot,
89 event: &WorkflowRunEvent,
90 create: bool,
91 ) -> io::Result<()> {
92 if snapshot.run_id != event.run_id || snapshot.last_sequence != event.sequence {
93 return Err(io::Error::new(
94 io::ErrorKind::InvalidInput,
95 "snapshot/event sequence mismatch",
96 ));
97 }
98 let dir = self.run_dir(&snapshot.run_id)?;
99 if create {
100 match tokio::fs::create_dir(&dir).await {
101 Ok(()) => {}
102 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
103 return Err(io::Error::new(io::ErrorKind::AlreadyExists, "run exists"));
104 }
105 Err(error) => return Err(error),
106 }
107 } else if !tokio::fs::try_exists(&dir).await? {
108 return Err(io::Error::new(io::ErrorKind::NotFound, "run missing"));
109 }
110
111 let event_bytes = serde_json::to_vec(event).map_err(io::Error::other)?;
115 let snapshot_bytes = serde_json::to_vec(snapshot).map_err(io::Error::other)?;
116 let temporary = dir.join(format!(".snapshot-{}.tmp", event.sequence));
117 let mut file = tokio::fs::OpenOptions::new()
118 .create_new(true)
119 .write(true)
120 .open(&temporary)
121 .await?;
122 if let Err(error) = async {
123 file.write_all(&snapshot_bytes).await?;
124 file.sync_all().await
125 }
126 .await
127 {
128 let _ = tokio::fs::remove_file(&temporary).await;
129 return Err(error);
130 }
131 drop(file);
132
133 let journal_path = dir.join("journal.jsonl");
134 let mut journal = tokio::fs::OpenOptions::new()
135 .create(true)
136 .append(true)
137 .open(&journal_path)
138 .await?;
139 journal.write_all(&event_bytes).await?;
140 journal.write_all(b"\n").await?;
141 journal.sync_all().await?;
142 atomic_replace(&temporary, &dir.join("snapshot.json")).await?;
143 sync_directory(&dir).await?;
144 Ok(())
145 }
146
147 async fn recover_snapshot(&self, run_id: &str) -> io::Result<Option<WorkflowRunSnapshot>> {
148 let dir = self.run_dir(run_id)?;
149 repair_journal_tail(&dir.join("journal.jsonl")).await?;
150 let snapshot_path = dir.join("snapshot.json");
151 let current = match tokio::fs::read(&snapshot_path).await {
152 Ok(bytes) => Some(
153 serde_json::from_slice::<WorkflowRunSnapshot>(&bytes).map_err(io::Error::other)?,
154 ),
155 Err(error) if error.kind() == io::ErrorKind::NotFound => None,
156 Err(error) => return Err(error),
157 };
158 let journal = self.events_since(run_id, 0).await?;
159 let journal_sequence = journal.last().map_or(0, |event| event.sequence);
160 let snapshot_sequence = current
161 .as_ref()
162 .map_or(0, |snapshot| snapshot.last_sequence);
163 if journal_sequence == snapshot_sequence {
164 let stale = dir.join(format!(".snapshot-{}.tmp", journal_sequence + 1));
166 let _ = tokio::fs::remove_file(stale).await;
167 return Ok(current);
168 }
169 if journal_sequence < snapshot_sequence || journal_sequence != snapshot_sequence + 1 {
170 return Err(io::Error::new(
171 io::ErrorKind::InvalidData,
172 "workflow journal/snapshot sequence divergence",
173 ));
174 }
175 let temporary = dir.join(format!(".snapshot-{journal_sequence}.tmp"));
176 let bytes = tokio::fs::read(&temporary).await.map_err(|error| {
177 io::Error::new(
178 io::ErrorKind::InvalidData,
179 format!("journal is ahead but staged snapshot is missing: {error}"),
180 )
181 })?;
182 let recovered: WorkflowRunSnapshot =
183 serde_json::from_slice(&bytes).map_err(io::Error::other)?;
184 if recovered.run_id != run_id || recovered.last_sequence != journal_sequence {
185 return Err(io::Error::new(
186 io::ErrorKind::InvalidData,
187 "staged workflow snapshot does not match journal",
188 ));
189 }
190 atomic_replace(&temporary, &snapshot_path).await?;
191 sync_directory(&dir).await?;
192 Ok(Some(recovered))
193 }
194}
195
196#[async_trait]
197impl WorkflowRunRepository for FileWorkflowRunRepository {
198 async fn create(
199 &self,
200 snapshot: &WorkflowRunSnapshot,
201 event: &WorkflowRunEvent,
202 ) -> io::Result<()> {
203 let lock = self.lock(&snapshot.run_id);
204 let _guard = lock.lock().await;
205 match self.commit_locked(snapshot, event, true).await {
206 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
207 let current = self.recover_snapshot(&snapshot.run_id).await?;
208 if current.as_ref() == Some(snapshot) {
209 Ok(())
210 } else if current.is_none() {
211 let dir = self.run_dir(&snapshot.run_id)?;
212 let mut entries = tokio::fs::read_dir(&dir).await?;
213 let mut removable = true;
214 let mut paths = Vec::new();
215 while let Some(entry) = entries.next_entry().await? {
216 let path = entry.path();
217 if entry.file_name() != "journal.jsonl"
218 || entry.metadata().await?.len() != 0
219 {
220 removable = false;
221 break;
222 }
223 paths.push(path);
224 }
225 if removable {
226 for path in paths {
227 tokio::fs::remove_file(path).await?;
228 }
229 tokio::fs::remove_dir(&dir).await?;
230 self.commit_locked(snapshot, event, true).await
231 } else {
232 Err(error)
233 }
234 } else {
235 Err(error)
236 }
237 }
238 result => result,
239 }
240 }
241
242 async fn commit(
243 &self,
244 snapshot: &WorkflowRunSnapshot,
245 event: &WorkflowRunEvent,
246 ) -> io::Result<()> {
247 let lock = self.lock(&snapshot.run_id);
248 let _guard = lock.lock().await;
249 let current = self
250 .recover_snapshot(&snapshot.run_id)
251 .await?
252 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "run snapshot missing"))?;
253 if current.last_sequence == event.sequence && current == *snapshot {
254 return Ok(());
255 }
256 if current.status.is_terminal() {
257 return Err(io::Error::new(
258 io::ErrorKind::InvalidInput,
259 "terminal workflow run is immutable",
260 ));
261 }
262 if event.sequence != current.last_sequence.saturating_add(1) {
263 return Err(io::Error::new(
264 io::ErrorKind::InvalidInput,
265 "workflow event sequence is not monotonic",
266 ));
267 }
268 self.commit_locked(snapshot, event, false).await
269 }
270
271 async fn load(&self, run_id: &str) -> io::Result<Option<WorkflowRunSnapshot>> {
272 let lock = self.lock(run_id);
273 let _guard = lock.lock().await;
274 self.recover_snapshot(run_id).await
275 }
276
277 async fn events_since(&self, run_id: &str, sequence: u64) -> io::Result<Vec<WorkflowRunEvent>> {
278 let path = self.run_dir(run_id)?.join("journal.jsonl");
279 let bytes = match tokio::fs::read(path).await {
280 Ok(bytes) => bytes,
281 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
282 Err(error) => return Err(error),
283 };
284 let committed = match bytes.iter().rposition(|byte| *byte == b'\n') {
288 Some(end) => &bytes[..=end],
289 None => &[][..],
290 };
291 let text = std::str::from_utf8(committed)
292 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
293 let mut result = Vec::new();
294 for (expected, line) in (1_u64..).zip(text.lines().filter(|line| !line.trim().is_empty())) {
295 let event: WorkflowRunEvent = serde_json::from_str(line)
296 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
297 if event.run_id != run_id || event.sequence != expected {
298 return Err(io::Error::new(
299 io::ErrorKind::InvalidData,
300 "workflow journal identity or sequence divergence",
301 ));
302 }
303 if event.sequence > sequence {
304 result.push(event);
305 }
306 }
307 Ok(result)
308 }
309
310 async fn list_run_ids(&self) -> io::Result<Vec<String>> {
311 let mut entries = tokio::fs::read_dir(&self.root).await?;
312 let mut ids = Vec::new();
313 while let Some(entry) = entries.next_entry().await? {
314 if entry.file_type().await?.is_dir() {
315 if let Some(id) = entry.file_name().to_str() {
316 if Self::validate_id(id).is_ok() {
317 ids.push(id.to_owned());
318 }
319 }
320 }
321 }
322 ids.sort();
323 Ok(ids)
324 }
325}
326
327#[cfg(unix)]
328async fn sync_directory(path: &Path) -> io::Result<()> {
329 let file = tokio::fs::File::open(path).await?;
330 file.sync_all().await
331}
332
333#[cfg(not(unix))]
334async fn sync_directory(_path: &Path) -> io::Result<()> {
335 Ok(())
336}
337
338async fn atomic_replace(from: &Path, to: &Path) -> io::Result<()> {
339 match tokio::fs::rename(from, to).await {
340 Ok(()) => Ok(()),
341 Err(_error) if cfg!(windows) && tokio::fs::try_exists(to).await? => {
342 tokio::fs::remove_file(to).await?;
343 tokio::fs::rename(from, to).await
344 }
345 Err(error) => Err(error),
346 }
347}
348
349async fn repair_journal_tail(path: &Path) -> io::Result<()> {
350 let bytes = match tokio::fs::read(path).await {
351 Ok(bytes) => bytes,
352 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
353 Err(error) => return Err(error),
354 };
355 let committed_len = bytes
356 .iter()
357 .rposition(|byte| *byte == b'\n')
358 .map_or(0, |index| index + 1);
359 if committed_len < bytes.len() {
360 let file = tokio::fs::OpenOptions::new().write(true).open(path).await?;
361 file.set_len(committed_len as u64).await?;
362 file.sync_all().await?;
363 }
364 Ok(())
365}