1use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14use serde::{Deserialize, Serialize};
15
16use super::broker::BrokerClock;
17use super::types::TraverseEvent;
18
19const SEGMENT_PREFIX: &str = "segment-";
20const SEGMENT_SUFFIX: &str = ".jsonl";
21
22#[derive(Debug, Clone)]
24pub struct JournalConfig {
25 pub max_segment_bytes: u64,
27 pub max_segment_age_secs: u64,
29 pub retention_max_age_secs: Option<u64>,
31 pub retention_max_total_bytes: Option<u64>,
33}
34
35impl Default for JournalConfig {
36 fn default() -> Self {
37 Self {
38 max_segment_bytes: 64 * 1024 * 1024,
39 max_segment_age_secs: 600,
40 retention_max_age_secs: None,
41 retention_max_total_bytes: None,
42 }
43 }
44}
45
46#[derive(Debug, PartialEq, Eq)]
48pub enum JournalError {
49 Io(String),
51 Corrupt {
53 path: String,
54 line: usize,
55 message: String,
56 },
57 InvalidCursor(String),
59 CursorExpired { oldest_available_cursor: String },
61 InvalidConfig(String),
63}
64
65impl std::fmt::Display for JournalError {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::Io(msg) => write!(f, "journal io failure: {msg}"),
69 Self::Corrupt {
70 path,
71 line,
72 message,
73 } => write!(f, "journal corrupt at {path}:{line}: {message}"),
74 Self::InvalidCursor(msg) => write!(f, "invalid journal cursor: {msg}"),
75 Self::CursorExpired {
76 oldest_available_cursor,
77 } => write!(
78 f,
79 "journal cursor expired: oldest available cursor is {oldest_available_cursor}"
80 ),
81 Self::InvalidConfig(msg) => write!(f, "invalid journal config: {msg}"),
82 }
83 }
84}
85
86impl std::error::Error for JournalError {}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
92struct JournalRecordV1 {
93 seq: u64,
94 written_at_secs: u64,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 event: Option<TraverseEvent>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 revokes: Option<u64>,
99}
100
101#[derive(Debug, Clone)]
104struct SegmentMeta {
105 path: PathBuf,
106 first_seq: u64,
107 last_seq: u64,
108 created_at_secs: u64,
109 last_written_at_secs: u64,
110 bytes: u64,
111}
112
113pub struct DurableEventJournal {
115 root: PathBuf,
116 config: JournalConfig,
117 clock: Arc<dyn BrokerClock>,
118 sealed: Vec<SegmentMeta>,
119 active: Option<(SegmentMeta, fs::File)>,
120 next_seq: u64,
121}
122
123impl DurableEventJournal {
124 pub fn open(
135 root: &Path,
136 config: JournalConfig,
137 clock: Arc<dyn BrokerClock>,
138 ) -> Result<Self, JournalError> {
139 validate_config(&config)?;
140 fs::create_dir_all(root).map_err(|e| io_err("create journal root", &e))?;
141
142 let mut segment_paths = Vec::new();
143 let entries = fs::read_dir(root).map_err(|e| io_err("list journal segments", &e))?;
144 for entry in entries {
145 let entry = entry.map_err(|e| io_err("read journal segment entry", &e))?;
146 let name = entry.file_name().to_string_lossy().into_owned();
147 if name.starts_with(SEGMENT_PREFIX) && name.ends_with(SEGMENT_SUFFIX) {
148 segment_paths.push(entry.path());
149 }
150 }
151 segment_paths.sort();
152
153 let mut sealed = Vec::new();
154 let mut next_seq = 1_u64;
155 let last_index = segment_paths.len().saturating_sub(1);
156 for (index, path) in segment_paths.iter().enumerate() {
157 let allow_torn_tail = index == last_index;
158 let records = read_segment_records(path, allow_torn_tail)?;
159 let Some((first, last)) = records.first().zip(records.last()) else {
160 fs::remove_file(path).map_err(|e| io_err("remove empty journal segment", &e))?;
163 continue;
164 };
165 if first.seq < next_seq {
166 return Err(JournalError::Corrupt {
167 path: path.display().to_string(),
168 line: 1,
169 message: format!(
170 "sequence {} is not greater than prior segment sequence {}",
171 first.seq,
172 next_seq - 1
173 ),
174 });
175 }
176 let bytes = fs::metadata(path)
177 .map_err(|e| io_err("stat journal segment", &e))?
178 .len();
179 sealed.push(SegmentMeta {
180 path: path.clone(),
181 first_seq: first.seq,
182 last_seq: last.seq,
183 created_at_secs: first.written_at_secs,
184 last_written_at_secs: last.written_at_secs,
185 bytes,
186 });
187 next_seq = last.seq + 1;
188 }
189
190 Ok(Self {
191 root: root.to_path_buf(),
192 config,
193 clock,
194 sealed,
195 active: None,
196 next_seq,
197 })
198 }
199
200 pub fn append(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
210 self.append_line(Some(event), None)
211 }
212
213 pub fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
223 let revoked = revoked_cursor.parse::<u64>().map_err(|e| {
224 JournalError::InvalidCursor(format!("revoked cursor `{revoked_cursor}`: {e}"))
225 })?;
226 self.append_line(None, Some(revoked))
227 }
228
229 fn append_line(
230 &mut self,
231 event: Option<&TraverseEvent>,
232 revokes: Option<u64>,
233 ) -> Result<String, JournalError> {
234 let now_secs = self.now_secs()?;
235
236 let needs_rollover = self.active.as_ref().is_some_and(|(meta, _)| {
237 meta.bytes >= self.config.max_segment_bytes
238 || now_secs.saturating_sub(meta.created_at_secs) >= self.config.max_segment_age_secs
239 });
240 if needs_rollover && let Some((meta, file)) = self.active.take() {
241 drop(file);
242 self.sealed.push(meta);
243 }
244
245 let mut active = match self.active.take() {
246 Some(active) => active,
247 None => self.open_segment(now_secs)?,
248 };
249 let result = append_record(&mut active, self.next_seq, now_secs, event, revokes);
250 self.active = Some(active);
251 let cursor = result?;
252 self.next_seq += 1;
253 Ok(cursor)
254 }
255
256 fn open_segment(&self, now_secs: u64) -> Result<(SegmentMeta, fs::File), JournalError> {
257 let path = self.root.join(format!(
258 "{SEGMENT_PREFIX}{:020}{SEGMENT_SUFFIX}",
259 self.next_seq
260 ));
261 let file = fs::OpenOptions::new()
262 .create_new(true)
263 .append(true)
264 .open(&path)
265 .map_err(|e| io_err("create journal segment", &e))?;
266 Ok((
267 SegmentMeta {
268 path,
269 first_seq: self.next_seq,
270 last_seq: self.next_seq,
271 created_at_secs: now_secs,
272 last_written_at_secs: now_secs,
273 bytes: 0,
274 },
275 file,
276 ))
277 }
278
279 pub fn replay_from(
292 &self,
293 cursor: &str,
294 max_events: usize,
295 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
296 let after = cursor
297 .parse::<u64>()
298 .map_err(|e| JournalError::InvalidCursor(format!("cursor `{cursor}`: {e}")))?;
299
300 let oldest = self.oldest_retained_seq();
301 if let Some(oldest_seq) = oldest
302 && after + 1 < oldest_seq
303 {
304 return Err(JournalError::CursorExpired {
305 oldest_available_cursor: (oldest_seq - 1).to_string(),
306 });
307 }
308
309 let mut revoked = std::collections::HashSet::new();
312 let mut collected: Vec<(u64, TraverseEvent)> = Vec::new();
313 let last_index = self.segment_count().saturating_sub(1);
314 for (index, meta) in self.segments().enumerate() {
315 if meta.last_seq <= after {
316 continue;
317 }
318 let allow_torn_tail = index == last_index;
319 for record in read_segment_records(&meta.path, allow_torn_tail)? {
320 if let Some(revoked_seq) = record.revokes {
321 let _ = revoked.insert(revoked_seq);
322 } else if record.seq > after
323 && let Some(event) = record.event
324 {
325 collected.push((record.seq, event));
326 }
327 }
328 }
329 collected.retain(|(seq, _)| !revoked.contains(seq));
330 collected.truncate(max_events);
331 Ok(collected
332 .into_iter()
333 .map(|(seq, event)| (seq.to_string(), event))
334 .collect())
335 }
336
337 #[must_use]
340 pub fn oldest_available_cursor(&self) -> String {
341 match self.oldest_retained_seq() {
342 Some(seq) => (seq - 1).to_string(),
343 None => (self.next_seq - 1).to_string(),
344 }
345 }
346
347 #[must_use]
353 pub(crate) fn latest_cursor(&self) -> u64 {
354 self.next_seq.saturating_sub(1)
355 }
356
357 pub fn prune(&mut self) -> Result<Vec<PathBuf>, JournalError> {
368 let now_secs = self.now_secs()?;
369 let mut deleted = Vec::new();
370
371 if let Some(max_age) = self.config.retention_max_age_secs {
372 while let Some(meta) = self.sealed.first() {
373 if now_secs.saturating_sub(meta.last_written_at_secs) <= max_age {
374 break;
375 }
376 let meta = self.sealed.remove(0);
377 fs::remove_file(&meta.path)
378 .map_err(|e| io_err("remove expired journal segment", &e))?;
379 deleted.push(meta.path);
380 }
381 }
382
383 if let Some(max_total) = self.config.retention_max_total_bytes {
384 let mut total: u64 = self.segments().map(|meta| meta.bytes).sum();
385 while total > max_total && !self.sealed.is_empty() {
386 let meta = self.sealed.remove(0);
387 fs::remove_file(&meta.path)
388 .map_err(|e| io_err("remove oversized journal segment", &e))?;
389 total -= meta.bytes;
390 deleted.push(meta.path);
391 }
392 }
393
394 Ok(deleted)
395 }
396
397 fn now_secs(&self) -> Result<u64, JournalError> {
398 let now = self.clock.now();
399 let elapsed = now
400 .duration_since(std::time::UNIX_EPOCH)
401 .map_err(|e| JournalError::Io(format!("system time before epoch: {e}")))?;
402 Ok(elapsed.as_secs())
403 }
404
405 fn segments(&self) -> impl Iterator<Item = &SegmentMeta> {
406 self.sealed
407 .iter()
408 .chain(self.active.iter().map(|(meta, _)| meta))
409 }
410
411 fn segment_count(&self) -> usize {
412 self.sealed.len() + usize::from(self.active.is_some())
413 }
414
415 fn oldest_retained_seq(&self) -> Option<u64> {
416 self.segments().map(|meta| meta.first_seq).next()
417 }
418}
419
420fn validate_config(config: &JournalConfig) -> Result<(), JournalError> {
421 if config.max_segment_bytes == 0 {
422 return Err(JournalError::InvalidConfig(
423 "max_segment_bytes must be at least 1".to_string(),
424 ));
425 }
426 if config.max_segment_age_secs == 0 {
427 return Err(JournalError::InvalidConfig(
428 "max_segment_age_secs must be at least 1".to_string(),
429 ));
430 }
431 if config.retention_max_age_secs == Some(0) {
432 return Err(JournalError::InvalidConfig(
433 "retention_max_age_secs must be at least 1 when set".to_string(),
434 ));
435 }
436 if config.retention_max_total_bytes == Some(0) {
437 return Err(JournalError::InvalidConfig(
438 "retention_max_total_bytes must be at least 1 when set".to_string(),
439 ));
440 }
441 Ok(())
442}
443
444fn append_record(
447 active: &mut (SegmentMeta, fs::File),
448 seq: u64,
449 now_secs: u64,
450 event: Option<&TraverseEvent>,
451 revokes: Option<u64>,
452) -> Result<String, JournalError> {
453 let record = JournalRecordV1 {
454 seq,
455 written_at_secs: now_secs,
456 event: event.cloned(),
457 revokes,
458 };
459 let mut line = serde_json::to_vec(&record)
460 .map_err(|e| JournalError::Io(format!("serialize journal record: {e}")))?;
461 line.push(b'\n');
462
463 let (meta, file) = active;
464 write_durable(file, &line)?;
465 meta.bytes += line.len() as u64;
466 meta.last_seq = seq;
467 meta.last_written_at_secs = now_secs;
468 Ok(seq.to_string())
469}
470
471fn write_durable(file: &mut fs::File, line: &[u8]) -> Result<(), JournalError> {
474 let write_then_sync = |file: &mut fs::File| -> std::io::Result<()> {
475 file.write_all(line)?;
476 file.sync_data()
477 };
478 write_then_sync(file).map_err(|e| io_err("append journal record", &e))
479}
480
481fn read_segment_records(
486 path: &Path,
487 allow_torn_tail: bool,
488) -> Result<Vec<JournalRecordV1>, JournalError> {
489 let bytes = fs::read(path).map_err(|e| io_err("read journal segment", &e))?;
490 let ends_with_newline = bytes.last() == Some(&b'\n');
491
492 let mut records: Vec<JournalRecordV1> = Vec::new();
493 let chunks: Vec<&[u8]> = bytes
494 .split(|byte| *byte == b'\n')
495 .filter(|chunk| !chunk.is_empty())
496 .collect();
497 for (index, chunk) in chunks.iter().enumerate() {
498 let is_torn_tail = !ends_with_newline && index + 1 == chunks.len();
499 match serde_json::from_slice::<JournalRecordV1>(chunk) {
500 Ok(record) => {
501 if is_torn_tail {
502 if allow_torn_tail {
506 break;
507 }
508 return Err(corrupt(path, index, "unterminated record"));
509 }
510 if let Some(previous) = records.last()
511 && record.seq <= previous.seq
512 {
513 return Err(corrupt(
514 path,
515 index,
516 &format!(
517 "sequence {} is not greater than prior sequence {}",
518 record.seq, previous.seq
519 ),
520 ));
521 }
522 records.push(record);
523 }
524 Err(error) => {
525 if is_torn_tail && allow_torn_tail {
526 break;
527 }
528 return Err(corrupt(path, index, &format!("malformed record: {error}")));
529 }
530 }
531 }
532 Ok(records)
533}
534
535fn corrupt(path: &Path, index: usize, message: &str) -> JournalError {
536 JournalError::Corrupt {
537 path: path.display().to_string(),
538 line: index + 1,
539 message: message.to_string(),
540 }
541}
542
543fn io_err(action: &str, error: &std::io::Error) -> JournalError {
544 JournalError::Io(format!("{action}: {error}"))
545}
546
547#[cfg(test)]
548#[allow(clippy::expect_used)]
549mod tests {
550 use super::*;
551 use crate::events::types::LifecycleStatus;
552 use std::sync::Mutex;
553 use std::time::{Duration, SystemTime, UNIX_EPOCH};
554 use uuid::Uuid;
555
556 struct TestClock {
557 now: Mutex<SystemTime>,
558 }
559
560 impl TestClock {
561 fn at_secs(secs: u64) -> Arc<Self> {
562 Arc::new(Self {
563 now: Mutex::new(UNIX_EPOCH + Duration::from_secs(secs)),
564 })
565 }
566
567 fn before_epoch() -> Arc<Self> {
568 Arc::new(Self {
569 now: Mutex::new(UNIX_EPOCH - Duration::from_secs(1)),
570 })
571 }
572
573 fn advance(&self, secs: u64) {
574 let mut now = self.now.lock().expect("test clock lock must not poison");
575 *now += Duration::from_secs(secs);
576 }
577 }
578
579 impl BrokerClock for TestClock {
580 fn now(&self) -> SystemTime {
581 *self.now.lock().expect("test clock lock must not poison")
582 }
583 }
584
585 fn test_root(name: &str) -> PathBuf {
586 std::env::temp_dir().join(format!("traverse-journal-{name}-{}", Uuid::new_v4()))
587 }
588
589 fn test_event(marker: &str) -> TraverseEvent {
590 TraverseEvent {
591 id: Uuid::new_v4().to_string(),
592 source: "traverse-runtime/test.capability".to_string(),
593 event_type: "dev.traverse.test.journaled".to_string(),
594 datacontenttype: "application/json".to_string(),
595 time: "2026-07-13T00:00:00Z".to_string(),
596 data: serde_json::json!({ "marker": marker }),
597 owner: "test.capability".to_string(),
598 version: "1.0.0".to_string(),
599 lifecycle_status: LifecycleStatus::Active,
600 subject_id: None,
601 actor_id: None,
602 }
603 }
604
605 fn open_journal(
606 root: &Path,
607 config: JournalConfig,
608 clock: Arc<TestClock>,
609 ) -> DurableEventJournal {
610 DurableEventJournal::open(root, config, clock).expect("journal must open")
611 }
612
613 #[test]
614 fn config_limits_are_validated() {
615 let clock = TestClock::at_secs(1_000);
616 let cases = [
617 JournalConfig {
618 max_segment_bytes: 0,
619 ..JournalConfig::default()
620 },
621 JournalConfig {
622 max_segment_age_secs: 0,
623 ..JournalConfig::default()
624 },
625 JournalConfig {
626 retention_max_age_secs: Some(0),
627 ..JournalConfig::default()
628 },
629 JournalConfig {
630 retention_max_total_bytes: Some(0),
631 ..JournalConfig::default()
632 },
633 ];
634 for config in cases {
635 let err = DurableEventJournal::open(&test_root("bad-config"), config, clock.clone())
636 .map(|_| ())
637 .expect_err("zero limits must be rejected");
638 assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
639 }
640 }
641
642 #[test]
643 fn append_and_replay_round_trip() {
644 let root = test_root("round-trip");
645 let clock = TestClock::at_secs(1_000);
646 let mut journal = open_journal(&root, JournalConfig::default(), clock);
647
648 assert_eq!(journal.oldest_available_cursor(), "0");
649 assert!(
650 journal
651 .replay_from("0", 10)
652 .expect("empty journal must replay nothing")
653 .is_empty()
654 );
655
656 let first = journal
657 .append(&test_event("a"))
658 .expect("append must succeed");
659 let second = journal
660 .append(&test_event("b"))
661 .expect("append must succeed");
662 assert_eq!(first, "1");
663 assert_eq!(second, "2");
664
665 let all = journal.replay_from("0", 10).expect("replay must succeed");
666 assert_eq!(all.len(), 2);
667 assert_eq!(all[0].0, "1");
668 assert_eq!(all[0].1.data["marker"], "a");
669
670 let tail = journal.replay_from("1", 10).expect("replay must succeed");
671 assert_eq!(tail.len(), 1);
672 assert_eq!(tail[0].0, "2");
673
674 let head = journal.replay_from("2", 10).expect("replay must succeed");
675 assert!(head.is_empty());
676
677 let capped = journal.replay_from("0", 1).expect("replay must succeed");
678 assert_eq!(capped.len(), 1, "max_events must bound the replay");
679 }
680
681 #[test]
682 fn segments_roll_over_by_size_and_age() {
683 let root = test_root("rollover");
684 let clock = TestClock::at_secs(1_000);
685 let config = JournalConfig {
686 max_segment_bytes: 1,
687 ..JournalConfig::default()
688 };
689 let mut journal = open_journal(&root, config, clock.clone());
690 journal
691 .append(&test_event("a"))
692 .expect("append must succeed");
693 journal
694 .append(&test_event("b"))
695 .expect("append must succeed");
696 journal
697 .append(&test_event("c"))
698 .expect("append must succeed");
699 assert_eq!(journal.sealed.len(), 2, "size bound must seal segments");
700
701 let across = journal.replay_from("0", 10).expect("replay must succeed");
702 assert_eq!(across.len(), 3, "replay must cross segment boundaries");
703 let capped = journal.replay_from("0", 2).expect("replay must succeed");
704 assert_eq!(capped.len(), 2, "max_events must stop mid-journal");
705
706 let age_root = test_root("rollover-age");
707 let mut aged = open_journal(&age_root, JournalConfig::default(), clock.clone());
708 aged.append(&test_event("a")).expect("append must succeed");
709 clock.advance(601);
710 aged.append(&test_event("b")).expect("append must succeed");
711 assert_eq!(aged.sealed.len(), 1, "age bound must seal segments");
712 }
713
714 #[test]
715 fn reopen_recovers_segments_and_continues_sequences() {
716 let root = test_root("reopen");
717 let clock = TestClock::at_secs(1_000);
718 let config = JournalConfig {
719 max_segment_bytes: 1,
720 ..JournalConfig::default()
721 };
722 {
723 let mut journal = open_journal(&root, config.clone(), clock.clone());
724 journal
725 .append(&test_event("a"))
726 .expect("append must succeed");
727 journal
728 .append(&test_event("b"))
729 .expect("append must succeed");
730 }
731
732 let mut reopened = open_journal(&root, config, clock);
733 assert_eq!(reopened.oldest_available_cursor(), "0");
734 let cursor = reopened
735 .append(&test_event("c"))
736 .expect("append must succeed");
737 assert_eq!(cursor, "3", "sequence must continue across restart");
738 let all = reopened.replay_from("0", 10).expect("replay must succeed");
739 assert_eq!(all.len(), 3);
740 assert_eq!(all[2].1.data["marker"], "c");
741 }
742
743 #[test]
744 fn recovery_tolerates_only_an_incomplete_final_record() {
745 let root = test_root("torn-tail");
746 let clock = TestClock::at_secs(1_000);
747 {
748 let mut journal = open_journal(&root, JournalConfig::default(), clock.clone());
749 journal
750 .append(&test_event("a"))
751 .expect("append must succeed");
752 }
753 let segment = fs::read_dir(&root)
754 .expect("root must list")
755 .next()
756 .expect("segment must exist")
757 .expect("entry must read")
758 .path();
759
760 let original = fs::read(&segment).expect("segment must read");
761 let mut torn = original.clone();
762 torn.extend_from_slice(b"{\"seq\":2,\"truncated");
763 fs::write(&segment, &torn).expect("torn tail must write");
764 let journal = open_journal(&root, JournalConfig::default(), clock.clone());
765 let recovered = journal.replay_from("0", 10).expect("replay must succeed");
766 assert_eq!(recovered.len(), 1, "unparseable torn tail must be ignored");
767
768 let newline = original
769 .iter()
770 .position(|byte| *byte == b'\n')
771 .expect("newline");
772 let mut unterminated = original.clone();
773 unterminated.extend_from_slice(&original[..newline]);
774 fs::write(&segment, &unterminated).expect("unterminated record must write");
775 let journal = open_journal(&root, JournalConfig::default(), clock);
776 let recovered = journal.replay_from("0", 10).expect("replay must succeed");
777 assert_eq!(
778 recovered.len(),
779 1,
780 "a parseable but unterminated tail was never acknowledged and must be ignored"
781 );
782 }
783
784 #[test]
785 fn recovery_fails_loudly_on_malformed_completed_records() {
786 let clock = TestClock::at_secs(1_000);
787
788 let corrupt_root = test_root("corrupt-interior");
789 fs::create_dir_all(&corrupt_root).expect("root must be creatable");
790 fs::write(
791 corrupt_root.join("segment-00000000000000000001.jsonl"),
792 b"not-json\n",
793 )
794 .expect("corrupt segment must write");
795 let err = DurableEventJournal::open(&corrupt_root, JournalConfig::default(), clock.clone())
796 .map(|_| ())
797 .expect_err("malformed completed record must fail");
798 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
799
800 let torn_old_root = test_root("torn-old-segment");
801 {
802 let config = JournalConfig {
803 max_segment_bytes: 1,
804 ..JournalConfig::default()
805 };
806 let mut journal = open_journal(&torn_old_root, config, clock.clone());
807 journal
808 .append(&test_event("a"))
809 .expect("append must succeed");
810 journal
811 .append(&test_event("b"))
812 .expect("append must succeed");
813 }
814 let oldest = fs::read_dir(&torn_old_root)
815 .expect("root must list")
816 .filter_map(Result::ok)
817 .map(|entry| entry.path())
818 .min()
819 .expect("oldest segment must exist");
820 let mut torn = fs::read(&oldest).expect("segment must read");
821 torn.extend_from_slice(b"{\"seq\":9,\"truncated");
822 fs::write(&oldest, &torn).expect("torn tail must write");
823 let err =
824 DurableEventJournal::open(&torn_old_root, JournalConfig::default(), clock.clone())
825 .map(|_| ())
826 .expect_err("a torn tail in an older segment must fail");
827 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
828
829 let original = fs::read(&oldest).expect("segment must read");
830 let unterminated_end = original
831 .iter()
832 .position(|byte| *byte == b'\n')
833 .expect("newline");
834 fs::write(&oldest, &original[..unterminated_end])
835 .expect("unterminated valid record must write");
836 let err =
837 DurableEventJournal::open(&torn_old_root, JournalConfig::default(), clock.clone())
838 .map(|_| ())
839 .expect_err("a parseable unterminated record in an older segment must fail");
840 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
841 }
842
843 #[test]
844 fn recovery_rejects_non_monotonic_sequences() {
845 let clock = TestClock::at_secs(1_000);
846 let record = |seq: u64| {
847 let mut line = serde_json::to_vec(&JournalRecordV1 {
848 seq,
849 written_at_secs: 1_000,
850 event: Some(test_event("x")),
851 revokes: None,
852 })
853 .expect("record must serialize");
854 line.push(b'\n');
855 line
856 };
857
858 let within_root = test_root("non-monotonic-within");
859 fs::create_dir_all(&within_root).expect("root must be creatable");
860 let mut lines = record(2);
861 lines.extend_from_slice(&record(2));
862 fs::write(
863 within_root.join("segment-00000000000000000002.jsonl"),
864 &lines,
865 )
866 .expect("segment must write");
867 let err = DurableEventJournal::open(&within_root, JournalConfig::default(), clock.clone())
868 .map(|_| ())
869 .expect_err("non-monotonic records within a segment must fail");
870 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
871
872 let across_root = test_root("non-monotonic-across");
873 fs::create_dir_all(&across_root).expect("root must be creatable");
874 fs::write(
875 across_root.join("segment-00000000000000000001.jsonl"),
876 record(5),
877 )
878 .expect("segment must write");
879 fs::write(
880 across_root.join("segment-00000000000000000002.jsonl"),
881 record(3),
882 )
883 .expect("segment must write");
884 let err = DurableEventJournal::open(&across_root, JournalConfig::default(), clock)
885 .map(|_| ())
886 .expect_err("non-monotonic records across segments must fail");
887 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
888 }
889
890 #[test]
891 fn recovery_drops_segments_with_no_acknowledged_records() {
892 let clock = TestClock::at_secs(1_000);
893 let root = test_root("empty-segment");
894 fs::create_dir_all(&root).expect("root must be creatable");
895 let empty = root.join("segment-00000000000000000001.jsonl");
896 fs::write(&empty, b"").expect("empty segment must write");
897 fs::write(root.join("ignored.txt"), b"not a segment").expect("stray file must write");
898 let journal = open_journal(&root, JournalConfig::default(), clock);
899 assert!(!empty.exists(), "unacknowledged segment must be removed");
900 assert_eq!(journal.oldest_available_cursor(), "0");
901 }
902
903 #[test]
904 fn filesystem_failures_surface_as_io_errors() {
905 let clock = TestClock::at_secs(1_000);
906
907 let blocked_parent = test_root("blocked-parent");
908 fs::create_dir_all(&blocked_parent).expect("parent must be creatable");
909 fs::write(blocked_parent.join("root"), b"file").expect("squatting file must write");
910 let err = DurableEventJournal::open(
911 &blocked_parent.join("root"),
912 JournalConfig::default(),
913 clock.clone(),
914 )
915 .map(|_| ())
916 .expect_err("root creation over a file must fail");
917 assert!(matches!(err, JournalError::Io(_)), "{err}");
918
919 let dir_segment_root = test_root("dir-segment");
920 fs::create_dir_all(dir_segment_root.join("segment-00000000000000000001.jsonl"))
921 .expect("directory squatting on a segment must be creatable");
922 let err =
923 DurableEventJournal::open(&dir_segment_root, JournalConfig::default(), clock.clone())
924 .map(|_| ())
925 .expect_err("reading a directory as a segment must fail");
926 assert!(matches!(err, JournalError::Io(_)), "{err}");
927
928 let squat_root = test_root("squat-next-segment");
929 fs::create_dir_all(squat_root.join("segment-00000000000000000001.jsonl"))
930 .expect("squatting directory must be creatable");
931 let mut journal =
932 open_journal(&test_root("fresh"), JournalConfig::default(), clock.clone());
933 journal.root = squat_root;
934 let err = journal
935 .append(&test_event("a"))
936 .expect_err("creating a segment over a directory must fail");
937 assert!(matches!(err, JournalError::Io(_)), "{err}");
938
939 let read_only_root = test_root("read-only-file");
940 fs::create_dir_all(&read_only_root).expect("root must be creatable");
941 let path = read_only_root.join("segment.jsonl");
942 fs::write(&path, b"").expect("file must write");
943 let mut file = fs::OpenOptions::new()
944 .read(true)
945 .open(&path)
946 .expect("file must open read-only");
947 let err = write_durable(&mut file, b"line\n")
948 .expect_err("writing through a read-only handle must fail");
949 assert!(matches!(err, JournalError::Io(_)), "{err}");
950 }
951
952 #[test]
953 fn pre_epoch_clock_fails_closed() {
954 let root = test_root("pre-epoch");
955 let clock = TestClock::before_epoch();
956 let mut journal = open_journal(&root, JournalConfig::default(), clock);
957 let append_err = journal
958 .append(&test_event("a"))
959 .expect_err("append with a pre-epoch clock must fail");
960 assert!(matches!(append_err, JournalError::Io(_)), "{append_err}");
961 let prune_err = journal
962 .prune()
963 .expect_err("prune with a pre-epoch clock must fail");
964 assert!(matches!(prune_err, JournalError::Io(_)), "{prune_err}");
965 }
966
967 #[test]
968 fn cursors_are_validated_and_expire_after_pruning() {
969 let root = test_root("cursor-expiry");
970 let clock = TestClock::at_secs(1_000);
971 let config = JournalConfig {
972 max_segment_bytes: 1,
973 retention_max_age_secs: Some(10),
974 ..JournalConfig::default()
975 };
976 let mut journal = open_journal(&root, config, clock.clone());
977
978 let invalid = journal
979 .replay_from("not-a-cursor", 10)
980 .expect_err("malformed cursor must be rejected");
981 assert!(
982 matches!(invalid, JournalError::InvalidCursor(_)),
983 "{invalid}"
984 );
985
986 journal
987 .append(&test_event("a"))
988 .expect("append must succeed");
989 journal
990 .append(&test_event("b"))
991 .expect("append must succeed");
992 journal
993 .append(&test_event("c"))
994 .expect("append must succeed");
995
996 clock.advance(100);
997 let deleted = journal.prune().expect("prune must succeed");
998 assert_eq!(deleted.len(), 2, "expired sealed segments must be deleted");
999 for path in &deleted {
1000 assert!(!path.exists(), "pruned segment file must be removed");
1001 }
1002
1003 let expired = journal
1004 .replay_from("0", 10)
1005 .expect_err("cursor before retained history must expire");
1006 assert_eq!(
1007 expired,
1008 JournalError::CursorExpired {
1009 oldest_available_cursor: "2".to_string(),
1010 }
1011 );
1012 assert_eq!(journal.oldest_available_cursor(), "2");
1013
1014 let resumed = journal
1015 .replay_from("2", 10)
1016 .expect("oldest available cursor must replay");
1017 assert_eq!(resumed.len(), 1);
1018 assert_eq!(resumed[0].1.data["marker"], "c");
1019 }
1020
1021 #[test]
1022 fn prune_reclaims_whole_segments_only_and_spares_the_active_one() {
1023 let root = test_root("prune-rules");
1024 let clock = TestClock::at_secs(1_000);
1025 let config = JournalConfig {
1026 max_segment_bytes: 1,
1027 retention_max_age_secs: Some(1_000_000),
1028 retention_max_total_bytes: Some(1),
1029 ..JournalConfig::default()
1030 };
1031 let mut journal = open_journal(&root, config, clock.clone());
1032 journal
1033 .append(&test_event("a"))
1034 .expect("append must succeed");
1035 journal
1036 .append(&test_event("b"))
1037 .expect("append must succeed");
1038
1039 let deleted = journal.prune().expect("prune must succeed");
1040 assert_eq!(
1041 deleted.len(),
1042 1,
1043 "size retention must delete oldest sealed segments only"
1044 );
1045 assert!(
1046 journal.active.is_some(),
1047 "the active segment must never be pruned"
1048 );
1049 let survivors = journal.replay_from(&journal.oldest_available_cursor(), 10);
1050 assert_eq!(survivors.expect("replay must succeed").len(), 1);
1051
1052 let unlimited_root = test_root("prune-unlimited");
1053 let mut unlimited = open_journal(&unlimited_root, JournalConfig::default(), clock.clone());
1054 unlimited
1055 .append(&test_event("a"))
1056 .expect("append must succeed");
1057 assert!(
1058 unlimited
1059 .prune()
1060 .expect("prune without retention must succeed")
1061 .is_empty(),
1062 "no retention configured means nothing is reclaimed"
1063 );
1064
1065 let missing_root = test_root("prune-missing-file");
1066 let config = JournalConfig {
1067 max_segment_bytes: 1,
1068 retention_max_age_secs: Some(1),
1069 ..JournalConfig::default()
1070 };
1071 let mut missing = open_journal(&missing_root, config, clock.clone());
1072 missing
1073 .append(&test_event("a"))
1074 .expect("append must succeed");
1075 missing
1076 .append(&test_event("b"))
1077 .expect("append must succeed");
1078 let sealed_path = missing.sealed[0].path.clone();
1079 fs::remove_file(&sealed_path).expect("sealed segment must be removable");
1080 clock.advance(100);
1081 let err = missing
1082 .prune()
1083 .expect_err("pruning an already-missing segment must surface an io error");
1084 assert!(matches!(err, JournalError::Io(_)), "{err}");
1085 }
1086
1087 #[test]
1088 fn prune_size_rule_surfaces_removal_failures() {
1089 let root = test_root("prune-size-missing");
1090 let clock = TestClock::at_secs(1_000);
1091 let config = JournalConfig {
1092 max_segment_bytes: 1,
1093 retention_max_total_bytes: Some(1),
1094 ..JournalConfig::default()
1095 };
1096 let mut journal = open_journal(&root, config, clock);
1097 journal
1098 .append(&test_event("a"))
1099 .expect("append must succeed");
1100 journal
1101 .append(&test_event("b"))
1102 .expect("append must succeed");
1103 let sealed_path = journal.sealed[0].path.clone();
1104 fs::remove_file(&sealed_path).expect("sealed segment must be removable");
1105 let err = journal
1106 .prune()
1107 .expect_err("size pruning an already-missing segment must surface an io error");
1108 assert!(matches!(err, JournalError::Io(_)), "{err}");
1109 }
1110
1111 #[test]
1112 fn revocations_suppress_events_from_replay() {
1113 let root = test_root("revocation");
1114 let clock = TestClock::at_secs(1_000);
1115 let mut journal = open_journal(&root, JournalConfig::default(), clock.clone());
1116 journal
1117 .append(&test_event("a"))
1118 .expect("append must succeed");
1119 let second = journal
1120 .append(&test_event("b"))
1121 .expect("append must succeed");
1122 journal
1123 .append(&test_event("c"))
1124 .expect("append must succeed");
1125
1126 journal
1127 .append_revocation(&second)
1128 .expect("revocation must be durable");
1129
1130 let replayed = journal.replay_from("0", 10).expect("replay must succeed");
1131 let markers: Vec<_> = replayed
1132 .iter()
1133 .map(|(_, event)| event.data["marker"].clone())
1134 .collect();
1135 assert_eq!(
1136 markers,
1137 vec![serde_json::json!("a"), serde_json::json!("c")],
1138 "the revoked event must not be delivered and the revocation record itself must not appear"
1139 );
1140
1141 let capped = journal.replay_from("0", 2).expect("replay must succeed");
1142 assert_eq!(
1143 capped.len(),
1144 2,
1145 "max_events must apply after revocation filtering"
1146 );
1147
1148 let reopened = open_journal(&root, JournalConfig::default(), clock);
1149 let recovered = reopened.replay_from("0", 10).expect("replay must succeed");
1150 assert_eq!(
1151 recovered.len(),
1152 2,
1153 "revocations must keep suppressing events across restart"
1154 );
1155
1156 let mut invalid = reopened;
1157 let err = invalid
1158 .append_revocation("not-a-cursor")
1159 .expect_err("unparseable revoked cursor must be rejected");
1160 assert!(matches!(err, JournalError::InvalidCursor(_)), "{err}");
1161 }
1162
1163 #[test]
1164 fn errors_render_stable_messages() {
1165 let cases: Vec<(JournalError, &str)> = vec![
1166 (JournalError::Io("boom".to_string()), "journal io failure"),
1167 (
1168 JournalError::Corrupt {
1169 path: "p".to_string(),
1170 line: 3,
1171 message: "bad".to_string(),
1172 },
1173 "journal corrupt at p:3",
1174 ),
1175 (
1176 JournalError::InvalidCursor("bad".to_string()),
1177 "invalid journal cursor",
1178 ),
1179 (
1180 JournalError::CursorExpired {
1181 oldest_available_cursor: "7".to_string(),
1182 },
1183 "oldest available cursor is 7",
1184 ),
1185 (
1186 JournalError::InvalidConfig("bad".to_string()),
1187 "invalid journal config",
1188 ),
1189 ];
1190 for (error, expected) in cases {
1191 assert!(
1192 error.to_string().contains(expected),
1193 "{error} must mention {expected}"
1194 );
1195 }
1196 }
1197}