1use 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
30pub 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
41fn 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
49fn 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
69fn 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 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
146pub struct EventJournal {
148 dir: PathBuf,
149 current: Option<BufWriter<File>>,
150 bytes_written: u64,
151 rotate_threshold: u64,
152}
153
154impl EventJournal {
155 pub fn open(dir: PathBuf) -> io::Result<(Self, u64)> {
160 Self::open_with_threshold(dir, ROTATE_THRESHOLD_BYTES)
161 }
162
163 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 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 pub fn append(&mut self, ce: &ChangeEvent) -> io::Result<()> {
225 self.append_inner(ce, false)
226 }
227
228 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 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
282pub 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 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
313pub 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
323pub 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 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 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 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 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 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}