Skip to main content

cairn_runtime/
journal.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use chrono::{DateTime, Utc};
6
7use async_trait::async_trait;
8use tokio::io::AsyncWriteExt;
9use tokio::sync::Mutex;
10
11use crate::event::Event;
12
13#[derive(Debug, thiserror::Error)]
14pub enum JournalError {
15    #[error("sequence conflict for run {run_id}: expected {expected}, got {got}")]
16    Conflict { run_id: String, expected: u64, got: u64 },
17    #[error("io error: {0}")]
18    Io(#[from] std::io::Error),
19    #[error("corrupt journal entry: {0}")]
20    Corrupt(String),
21    #[error("journal backend error: {0}")]
22    Backend(String),
23}
24
25/// Who currently holds a run's lease.
26#[derive(Debug, Clone, PartialEq)]
27pub struct LeaseHolder {
28    pub owner: String,
29    pub expires_at: DateTime<Utc>,
30}
31
32/// Append-only, per-run event storage.
33///
34/// Journals may also implement run leases so that only one worker drives a
35/// run at a time. The defaults grant every lease (no coordination), which is
36/// correct for single-worker setups; sequence checks still prevent corrupt
37/// histories either way.
38///
39/// Implementations must reject an append whose `seq` is not exactly the next
40/// position; this is what makes concurrent resumers safe.
41#[async_trait]
42pub trait Journal: Send + Sync {
43    async fn append(&self, event: &Event) -> Result<(), JournalError>;
44    async fn load(&self, run_id: &str) -> Result<Vec<Event>, JournalError>;
45
46    /// Take (or re-take) the lease on `run_id` for `ttl`. Returns the current
47    /// holder if another live owner has it.
48    async fn acquire_lease(
49        &self,
50        _run_id: &str,
51        _owner: &str,
52        _ttl: Duration,
53    ) -> Result<Result<(), LeaseHolder>, JournalError> {
54        Ok(Ok(()))
55    }
56
57    /// Extend a lease this owner holds. `false` means it was lost.
58    async fn renew_lease(&self, _run_id: &str, _owner: &str, _ttl: Duration) -> Result<bool, JournalError> {
59        Ok(true)
60    }
61
62    async fn release_lease(&self, _run_id: &str, _owner: &str) -> Result<(), JournalError> {
63        Ok(())
64    }
65}
66
67/// In-process lease table shared by the built-in journals.
68#[derive(Default)]
69struct Leases(std::sync::Mutex<HashMap<String, (String, Instant)>>);
70
71impl Leases {
72    fn acquire(&self, run_id: &str, owner: &str, ttl: Duration) -> Result<(), LeaseHolder> {
73        let mut map = self.0.lock().unwrap();
74        let now = Instant::now();
75        if let Some((holder, until)) = map.get(run_id) {
76            if holder != owner && *until > now {
77                let left = chrono::Duration::from_std(*until - now).unwrap_or_default();
78                return Err(LeaseHolder { owner: holder.clone(), expires_at: Utc::now() + left });
79            }
80        }
81        map.insert(run_id.to_owned(), (owner.to_owned(), now + ttl));
82        Ok(())
83    }
84
85    fn renew(&self, run_id: &str, owner: &str, ttl: Duration) -> bool {
86        let mut map = self.0.lock().unwrap();
87        match map.get_mut(run_id) {
88            Some((holder, until)) if holder == owner => {
89                *until = Instant::now() + ttl;
90                true
91            }
92            _ => false,
93        }
94    }
95
96    fn release(&self, run_id: &str, owner: &str) {
97        let mut map = self.0.lock().unwrap();
98        if map.get(run_id).is_some_and(|(holder, _)| holder == owner) {
99            map.remove(run_id);
100        }
101    }
102}
103
104#[derive(Default)]
105pub struct InMemoryJournal {
106    runs: Mutex<HashMap<String, Vec<Event>>>,
107    leases: Leases,
108}
109
110impl InMemoryJournal {
111    pub fn new() -> Self {
112        Self::default()
113    }
114}
115
116#[async_trait]
117impl Journal for InMemoryJournal {
118    async fn append(&self, event: &Event) -> Result<(), JournalError> {
119        let mut runs = self.runs.lock().await;
120        let events = runs.entry(event.run_id.clone()).or_default();
121        let expected = events.len() as u64;
122        if event.seq != expected {
123            return Err(JournalError::Conflict { run_id: event.run_id.clone(), expected, got: event.seq });
124        }
125        events.push(event.clone());
126        Ok(())
127    }
128
129    async fn load(&self, run_id: &str) -> Result<Vec<Event>, JournalError> {
130        Ok(self.runs.lock().await.get(run_id).cloned().unwrap_or_default())
131    }
132
133    async fn acquire_lease(
134        &self,
135        run_id: &str,
136        owner: &str,
137        ttl: Duration,
138    ) -> Result<Result<(), LeaseHolder>, JournalError> {
139        Ok(self.leases.acquire(run_id, owner, ttl))
140    }
141
142    async fn renew_lease(&self, run_id: &str, owner: &str, ttl: Duration) -> Result<bool, JournalError> {
143        Ok(self.leases.renew(run_id, owner, ttl))
144    }
145
146    async fn release_lease(&self, run_id: &str, owner: &str) -> Result<(), JournalError> {
147        self.leases.release(run_id, owner);
148        Ok(())
149    }
150}
151
152/// One JSON-lines file per run, fsynced on every append.
153///
154/// Sequence checks are enforced within this process; use a database-backed
155/// journal when several processes may resume the same run.
156pub struct JsonlJournal {
157    dir: PathBuf,
158    next_seq: Mutex<HashMap<String, u64>>,
159    /// Leases are in-process only, like the sequence checks.
160    leases: Leases,
161}
162
163impl JsonlJournal {
164    pub fn new(dir: impl Into<PathBuf>) -> Result<Self, JournalError> {
165        let dir = dir.into();
166        std::fs::create_dir_all(&dir)?;
167        Ok(Self { dir, next_seq: Mutex::new(HashMap::new()), leases: Leases::default() })
168    }
169
170    pub fn path_for(&self, run_id: &str) -> PathBuf {
171        self.dir.join(format!("{run_id}.jsonl"))
172    }
173
174    /// Parse a journal file directly (used by tooling).
175    pub fn read_file(path: &std::path::Path) -> Result<Vec<Event>, JournalError> {
176        let text = std::fs::read_to_string(path)?;
177        parse_lines(&text)
178    }
179}
180
181fn parse_lines(text: &str) -> Result<Vec<Event>, JournalError> {
182    text.lines()
183        .filter(|l| !l.trim().is_empty())
184        .map(|l| serde_json::from_str(l).map_err(|e| JournalError::Corrupt(e.to_string())))
185        .collect()
186}
187
188#[async_trait]
189impl Journal for JsonlJournal {
190    async fn append(&self, event: &Event) -> Result<(), JournalError> {
191        let mut seqs = self.next_seq.lock().await;
192        let expected = match seqs.get(&event.run_id) {
193            Some(n) => *n,
194            None => self.load(&event.run_id).await?.len() as u64,
195        };
196        if event.seq != expected {
197            return Err(JournalError::Conflict { run_id: event.run_id.clone(), expected, got: event.seq });
198        }
199
200        let mut line = serde_json::to_vec(event).map_err(|e| JournalError::Corrupt(e.to_string()))?;
201        line.push(b'\n');
202        let mut file =
203            tokio::fs::OpenOptions::new().create(true).append(true).open(self.path_for(&event.run_id)).await?;
204        file.write_all(&line).await?;
205        file.sync_data().await?;
206
207        seqs.insert(event.run_id.clone(), expected + 1);
208        Ok(())
209    }
210
211    async fn load(&self, run_id: &str) -> Result<Vec<Event>, JournalError> {
212        match tokio::fs::read_to_string(self.path_for(run_id)).await {
213            Ok(text) => parse_lines(&text),
214            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
215            Err(e) => Err(e.into()),
216        }
217    }
218
219    async fn acquire_lease(
220        &self,
221        run_id: &str,
222        owner: &str,
223        ttl: Duration,
224    ) -> Result<Result<(), LeaseHolder>, JournalError> {
225        Ok(self.leases.acquire(run_id, owner, ttl))
226    }
227
228    async fn renew_lease(&self, run_id: &str, owner: &str, ttl: Duration) -> Result<bool, JournalError> {
229        Ok(self.leases.renew(run_id, owner, ttl))
230    }
231
232    async fn release_lease(&self, run_id: &str, owner: &str) -> Result<(), JournalError> {
233        self.leases.release(run_id, owner);
234        Ok(())
235    }
236}