Skip to main content

bamboo_engine/workflow_run/
repository.rs

1use 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
29/// One directory per run: a compact atomic snapshot plus an append-only JSONL
30/// event journal. Each event is synced before the corresponding snapshot is
31/// replaced, and callers publish only after `commit` returns.
32pub 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        // Serialize both records before mutating durable state. The staged
112        // snapshot is fsynced first but remains invisible until the matching
113        // journal record is durable.
114        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            // A staged snapshot without a journal record was never committed.
165            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        // A process crash may leave only the final, unterminated JSON fragment.
285        // Discard that tail; every newline-terminated record is committed and
286        // therefore must parse and form one contiguous run-local sequence.
287        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}