Skip to main content

bamboo_engine/events/
journal.rs

1//! Durable JSONL journal for the account change feed.
2//!
3//! Each [`ChangeEvent`] is appended as one JSON line to a rotating set of files
4//! under `<bamboo_dir>/events/`, named `events-<20-digit-start-seq>.jsonl`. The
5//! zero-padded start-seq in the filename makes the lexical file order match the
6//! numeric seq order, so seeking and finding the oldest/newest file are cheap.
7//!
8//! ## Ownership model
9//!
10//! Each sink owns one writer task and never shares an [`EventJournal`] value.
11//! Rolling processes serialize reopen/sequence/append through the fixed file
12//! claim in [`super::account_sink`]. Reads ([`read_since`], [`oldest_seq`]) are
13//! stateless free functions that open files read-only.
14//!
15//! ## Rotation & recovery
16//!
17//! A new file is opened lazily on the first append, named by that event's seq.
18//! When the current file passes [`ROTATE_THRESHOLD_BYTES`] it is closed and the
19//! next append opens a fresh file. A cross-process claimed append reopens the
20//! newest underfilled file and truncates any partial crash tail first. On boot,
21//! [`EventJournal::open`] recovers the max seq from the last complete event near
22//! the tail rather than scanning an entire file.
23
24use std::fs::{File, OpenOptions};
25use std::io::{self, BufWriter, Read, Seek, SeekFrom, Write};
26use std::path::{Path, PathBuf};
27
28use super::change_feed::ChangeEvent;
29
30/// Roll to a new journal file once the current one passes this size.
31pub const ROTATE_THRESHOLD_BYTES: u64 = 8 * 1024 * 1024;
32
33const FILE_PREFIX: &str = "events-";
34const FILE_SUFFIX: &str = ".jsonl";
35const SEQ_PAD_WIDTH: usize = 20;
36
37fn file_name_for(start_seq: u64) -> String {
38    format!("{FILE_PREFIX}{start_seq:0SEQ_PAD_WIDTH$}{FILE_SUFFIX}")
39}
40
41/// Parse the start-seq encoded in a journal filename, if it matches the scheme.
42fn start_seq_from_name(name: &str) -> Option<u64> {
43    name.strip_prefix(FILE_PREFIX)?
44        .strip_suffix(FILE_SUFFIX)?
45        .parse::<u64>()
46        .ok()
47}
48
49/// List journal files in ascending seq order (by encoded start-seq).
50fn list_journal_files(dir: &Path) -> io::Result<Vec<(u64, PathBuf)>> {
51    let mut files: Vec<(u64, PathBuf)> = Vec::new();
52    let read_dir = match std::fs::read_dir(dir) {
53        Ok(rd) => rd,
54        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(files),
55        Err(e) => return Err(e),
56    };
57    for entry in read_dir {
58        let entry = entry?;
59        let name = entry.file_name();
60        let name = name.to_string_lossy();
61        if let Some(start) = start_seq_from_name(&name) {
62            files.push((start, entry.path()));
63        }
64    }
65    files.sort_by_key(|(start, _)| *start);
66    Ok(files)
67}
68
69/// The greatest `seq` present across all journal files, or 0 if empty.
70///
71/// Reads only tail windows of the last file to find the last complete valid
72/// line, tolerating a torn trailing line from a crash mid-write.
73fn recover_max_seq(dir: &Path) -> io::Result<u64> {
74    let files = list_journal_files(dir)?;
75    let Some((_, path)) = files.last() else {
76        return Ok(0);
77    };
78    recover_last_complete_event_seq(path)
79}
80
81fn last_complete_line_end(path: &Path) -> io::Result<u64> {
82    const TAIL_CHUNK: usize = 8 * 1024;
83
84    let mut file = File::open(path)?;
85    let len = file.metadata()?.len();
86    if len == 0 {
87        return Ok(0);
88    }
89    file.seek(SeekFrom::End(-1))?;
90    let mut last = [0u8; 1];
91    file.read_exact(&mut last)?;
92    if last[0] == b'\n' {
93        return Ok(len);
94    }
95
96    let mut cursor = len;
97    let mut buffer = vec![0u8; TAIL_CHUNK];
98    while cursor > 0 {
99        let read_len = usize::try_from(cursor.min(TAIL_CHUNK as u64)).unwrap_or(TAIL_CHUNK);
100        cursor -= read_len as u64;
101        file.seek(SeekFrom::Start(cursor))?;
102        file.read_exact(&mut buffer[..read_len])?;
103        if let Some(position) = buffer[..read_len].iter().rposition(|byte| *byte == b'\n') {
104            return Ok(cursor + position as u64 + 1);
105        }
106    }
107    Ok(0)
108}
109
110fn recover_last_complete_event_seq(path: &Path) -> io::Result<u64> {
111    const INITIAL_TAIL_WINDOW: u64 = 8 * 1024;
112
113    let complete_end = last_complete_line_end(path)?;
114    if complete_end == 0 {
115        return Ok(0);
116    }
117    let mut file = File::open(path)?;
118    let mut window = INITIAL_TAIL_WINDOW.min(complete_end);
119    loop {
120        let start = complete_end - window;
121        file.seek(SeekFrom::Start(start))?;
122        let mut bytes = vec![0u8; window as usize];
123        file.read_exact(&mut bytes)?;
124        let lines = bytes.split(|byte| *byte == b'\n').collect::<Vec<_>>();
125        for (index, line) in lines.iter().enumerate().rev() {
126            // A non-zero window start may bisect a JSON line.
127            if start > 0 && index == 0 {
128                continue;
129            }
130            let line = *line;
131            let line = line.strip_suffix(b"\r").unwrap_or(line);
132            if line.is_empty() {
133                continue;
134            }
135            if let Ok(event) = serde_json::from_slice::<ChangeEvent>(line) {
136                return Ok(event.seq);
137            }
138        }
139        if start == 0 {
140            return Ok(0);
141        }
142        window = window.saturating_mul(2).min(complete_end);
143    }
144}
145
146/// Append-only writer for the change-feed journal.
147pub struct EventJournal {
148    dir: PathBuf,
149    current: Option<BufWriter<File>>,
150    bytes_written: u64,
151    rotate_threshold: u64,
152}
153
154impl EventJournal {
155    /// Open (or create) the journal directory and recover the max seq seen.
156    ///
157    /// Returns the writer plus the recovered max seq (0 for a fresh journal),
158    /// which the caller uses to seed the sequence counter.
159    pub fn open(dir: PathBuf) -> io::Result<(Self, u64)> {
160        Self::open_with_threshold(dir, ROTATE_THRESHOLD_BYTES)
161    }
162
163    /// Like [`Self::open`] but with a custom rotation threshold (bytes). A small
164    /// threshold forces one file per event, which retention/resume tests use to
165    /// build a multi-file journal deterministically.
166    pub fn open_with_threshold(dir: PathBuf, rotate_threshold: u64) -> io::Result<(Self, u64)> {
167        std::fs::create_dir_all(&dir)?;
168        let max_seq = recover_max_seq(&dir)?;
169        Ok((
170            Self {
171                dir,
172                current: None,
173                bytes_written: 0,
174                rotate_threshold,
175            },
176            max_seq,
177        ))
178    }
179
180    /// Open under the account-wide journal claim and resume the newest file
181    /// when it is below the rotation threshold. A partial crash tail is
182    /// truncated to the last complete newline before append, so reopening does
183    /// not concatenate a valid event onto an invalid JSON fragment.
184    pub(crate) fn open_for_locked_append(dir: PathBuf) -> io::Result<(Self, u64)> {
185        std::fs::create_dir_all(&dir)?;
186        let files = list_journal_files(&dir)?;
187        let mut current = None;
188        let mut bytes_written = 0;
189        if let Some((_, path)) = files.last() {
190            let file_len = std::fs::metadata(path)?.len();
191            let complete_len = last_complete_line_end(path)?;
192            if complete_len != file_len {
193                let file = OpenOptions::new().write(true).open(path)?;
194                file.set_len(complete_len)?;
195                file.sync_data()?;
196            }
197            if complete_len == 0 {
198                std::fs::remove_file(path)?;
199                sync_directory(&dir)?;
200                return Self::open_for_locked_append(dir);
201            }
202            if complete_len < ROTATE_THRESHOLD_BYTES {
203                current = Some(BufWriter::new(OpenOptions::new().append(true).open(path)?));
204                bytes_written = complete_len;
205            }
206        }
207        let max_seq = recover_max_seq(&dir)?;
208        Ok((
209            Self {
210                dir,
211                current,
212                bytes_written,
213                rotate_threshold: ROTATE_THRESHOLD_BYTES,
214            },
215            max_seq,
216        ))
217    }
218
219    /// Append one change event as a JSON line, rotating first if needed.
220    ///
221    /// The line is flushed immediately: change events are low-volume, and
222    /// flushing keeps concurrent `/stream` replays able to read the latest
223    /// line without waiting on a timer.
224    pub fn append(&mut self, ce: &ChangeEvent) -> io::Result<()> {
225        self.append_inner(ce, false)
226    }
227
228    /// Append, flush, and sync the journal file before returning. Confirmed
229    /// cross-process publications use this while holding the global journal
230    /// claim so acknowledgement means the dedupe truth is durable.
231    pub(crate) fn append_synced(&mut self, ce: &ChangeEvent) -> io::Result<()> {
232        self.append_inner(ce, true)
233    }
234
235    fn append_inner(&mut self, ce: &ChangeEvent, sync: bool) -> io::Result<()> {
236        let created_file = self.current.is_none();
237        if created_file {
238            let path = self.dir.join(file_name_for(ce.seq));
239            let file = OpenOptions::new().create(true).append(true).open(&path)?;
240            self.current = Some(BufWriter::new(file));
241            self.bytes_written = 0;
242        }
243
244        let mut line = serde_json::to_string(ce).map_err(io::Error::other)?;
245        line.push('\n');
246
247        let writer = self
248            .current
249            .as_mut()
250            .expect("current writer set above when None");
251        writer.write_all(line.as_bytes())?;
252        writer.flush()?;
253        if sync {
254            writer.get_ref().sync_data()?;
255            if created_file {
256                sync_directory(&self.dir)?;
257            }
258        }
259        self.bytes_written += line.len() as u64;
260
261        if self.bytes_written >= self.rotate_threshold {
262            // Drop the current writer; the next append opens a fresh file named
263            // by that event's seq.
264            self.current = None;
265        }
266        Ok(())
267    }
268}
269
270fn sync_directory(dir: &Path) -> io::Result<()> {
271    #[cfg(unix)]
272    {
273        File::open(dir)?.sync_all()
274    }
275    #[cfg(not(unix))]
276    {
277        let _ = dir;
278        Ok(())
279    }
280}
281
282/// Read all change events with `seq > since`, in ascending seq order.
283///
284/// Scans the journal files whose range may contain matching events. Used by the
285/// `/stream` resume path and by delta `/history?since`.
286pub fn read_since(dir: &Path, since: u64) -> io::Result<Vec<ChangeEvent>> {
287    let files = list_journal_files(dir)?;
288    let mut out: Vec<ChangeEvent> = Vec::new();
289    for (idx, (_start, path)) in files.iter().enumerate() {
290        // Skip a file only when the *next* file's start-seq proves this whole
291        // file precedes `since` (i.e. every event here is <= since).
292        if let Some((next_start, _)) = files.get(idx + 1) {
293            if next_start.saturating_sub(1) <= since {
294                continue;
295            }
296        }
297        let contents = std::fs::read(path)?;
298        for line in contents.split(|byte| *byte == b'\n') {
299            let line = line.strip_suffix(b"\r").unwrap_or(line);
300            if line.is_empty() {
301                continue;
302            }
303            if let Ok(ce) = serde_json::from_slice::<ChangeEvent>(line) {
304                if ce.seq > since {
305                    out.push(ce);
306                }
307            }
308        }
309    }
310    Ok(out)
311}
312
313/// The smallest `seq` still retained in the journal, or `None` if empty.
314///
315/// Derived from the lexically-smallest filename's encoded start-seq. Used to
316/// decide whether a resuming client's cursor predates the retained window
317/// (triggering a `feed_reset`).
318pub fn oldest_seq(dir: &Path) -> io::Result<Option<u64>> {
319    let files = list_journal_files(dir)?;
320    Ok(files.first().map(|(start, _)| *start))
321}
322
323/// Delete the oldest journal files, keeping at most `max_files` newest.
324///
325/// Called at boot to bound disk usage. Change events are low-volume, so a
326/// file-count cap (each file ~[`ROTATE_THRESHOLD_BYTES`]) is a predictable
327/// proxy for a size budget. A client whose cursor falls below the new oldest
328/// retained seq is told to full-resync via a `feed_reset` on `/stream`.
329///
330/// Returns the number of files deleted.
331pub fn prune(dir: &Path, max_files: usize) -> io::Result<usize> {
332    let files = list_journal_files(dir)?;
333    if files.len() <= max_files {
334        return Ok(0);
335    }
336    let to_delete = files.len() - max_files;
337    let mut deleted = 0;
338    for (_, path) in files.iter().take(to_delete) {
339        match std::fs::remove_file(path) {
340            Ok(()) => deleted += 1,
341            Err(e) => tracing::warn!("failed to prune journal file {}: {e}", path.display()),
342        }
343    }
344    Ok(deleted)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use bamboo_agent_core::AgentEvent;
351    use chrono::Utc;
352
353    fn ev(seq: u64) -> ChangeEvent {
354        ChangeEvent {
355            seq,
356            ts: Utc::now(),
357            session_id: Some(format!("s{seq}")),
358            event: AgentEvent::SessionDeleted {
359                session_id: format!("s{seq}"),
360            },
361        }
362    }
363
364    #[test]
365    fn round_trips_and_reads_since() {
366        let dir = tempfile::tempdir().unwrap();
367        let (mut j, max) = EventJournal::open(dir.path().to_path_buf()).unwrap();
368        assert_eq!(max, 0);
369        for seq in 1..=5 {
370            j.append(&ev(seq)).unwrap();
371        }
372        let got = read_since(dir.path(), 0).unwrap();
373        assert_eq!(
374            got.iter().map(|e| e.seq).collect::<Vec<_>>(),
375            vec![1, 2, 3, 4, 5]
376        );
377
378        let tail = read_since(dir.path(), 3).unwrap();
379        assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![4, 5]);
380
381        let none = read_since(dir.path(), 5).unwrap();
382        assert!(none.is_empty());
383    }
384
385    #[test]
386    fn recovers_max_seq_across_reopen() {
387        let dir = tempfile::tempdir().unwrap();
388        {
389            let (mut j, _) = EventJournal::open(dir.path().to_path_buf()).unwrap();
390            for seq in 1..=3 {
391                j.append(&ev(seq)).unwrap();
392            }
393        }
394        let (_j, max) = EventJournal::open(dir.path().to_path_buf()).unwrap();
395        assert_eq!(max, 3);
396    }
397
398    #[test]
399    fn rotates_by_size_and_reads_across_files() {
400        let dir = tempfile::tempdir().unwrap();
401        // Tiny threshold so each append rotates.
402        let (mut j, _) = EventJournal::open_with_threshold(dir.path().to_path_buf(), 1).unwrap();
403        for seq in 1..=4 {
404            j.append(&ev(seq)).unwrap();
405        }
406        let files = list_journal_files(dir.path()).unwrap();
407        assert!(files.len() >= 2, "expected rotation into multiple files");
408        let got = read_since(dir.path(), 0).unwrap();
409        assert_eq!(
410            got.iter().map(|e| e.seq).collect::<Vec<_>>(),
411            vec![1, 2, 3, 4]
412        );
413        assert_eq!(oldest_seq(dir.path()).unwrap(), Some(1));
414    }
415
416    #[test]
417    fn prune_keeps_newest_files_and_advances_oldest() {
418        let dir = tempfile::tempdir().unwrap();
419        // One file per event.
420        let (mut j, _) = EventJournal::open_with_threshold(dir.path().to_path_buf(), 1).unwrap();
421        for seq in 1..=6 {
422            j.append(&ev(seq)).unwrap();
423        }
424        assert_eq!(list_journal_files(dir.path()).unwrap().len(), 6);
425
426        let deleted = prune(dir.path(), 2).unwrap();
427        assert_eq!(deleted, 4);
428        // Only the two newest files (seq 5, 6) remain.
429        assert_eq!(oldest_seq(dir.path()).unwrap(), Some(5));
430        let remaining = read_since(dir.path(), 0).unwrap();
431        assert_eq!(
432            remaining.iter().map(|e| e.seq).collect::<Vec<_>>(),
433            vec![5, 6]
434        );
435
436        // Pruning below the file count is a no-op.
437        assert_eq!(prune(dir.path(), 10).unwrap(), 0);
438    }
439
440    #[test]
441    fn tolerates_torn_final_line_on_recovery() {
442        let dir = tempfile::tempdir().unwrap();
443        {
444            let (mut j, _) = EventJournal::open(dir.path().to_path_buf()).unwrap();
445            for seq in 1..=3 {
446                j.append(&ev(seq)).unwrap();
447            }
448        }
449        // Append a partial (torn) line directly to the newest file.
450        let files = list_journal_files(dir.path()).unwrap();
451        let (_, path) = files.last().unwrap();
452        let mut f = OpenOptions::new().append(true).open(path).unwrap();
453        f.write_all(b"{\"seq\":4,\"ts\":\"broke").unwrap();
454        drop(f);
455
456        let (_j, max) = EventJournal::open(dir.path().to_path_buf()).unwrap();
457        assert_eq!(max, 3, "torn line must be ignored");
458    }
459}