1use std::collections::HashMap;
10use std::fs;
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, PoisonError};
14
15use serde::{Deserialize, Serialize};
16
17use super::broker::BrokerClock;
18use super::types::TraverseEvent;
19
20const SEGMENT_PREFIX: &str = "segment-";
21const SEGMENT_SUFFIX: &str = ".jsonl";
22
23#[derive(Debug, Clone)]
25pub struct JournalConfig {
26 pub max_segment_bytes: u64,
28 pub max_segment_age_secs: u64,
30 pub retention_max_age_secs: Option<u64>,
32 pub retention_max_total_bytes: Option<u64>,
34}
35
36impl Default for JournalConfig {
37 fn default() -> Self {
38 Self {
39 max_segment_bytes: 64 * 1024 * 1024,
40 max_segment_age_secs: 600,
41 retention_max_age_secs: None,
42 retention_max_total_bytes: None,
43 }
44 }
45}
46
47#[derive(Debug, PartialEq, Eq)]
49pub enum JournalError {
50 Io(String),
52 Corrupt {
54 path: String,
55 line: usize,
56 message: String,
57 },
58 InvalidCursor(String),
60 CursorExpired { oldest_available_cursor: String },
62 InvalidConfig(String),
64}
65
66impl std::fmt::Display for JournalError {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 match self {
69 Self::Io(msg) => write!(f, "journal io failure: {msg}"),
70 Self::Corrupt {
71 path,
72 line,
73 message,
74 } => write!(f, "journal corrupt at {path}:{line}: {message}"),
75 Self::InvalidCursor(msg) => write!(f, "invalid journal cursor: {msg}"),
76 Self::CursorExpired {
77 oldest_available_cursor,
78 } => write!(
79 f,
80 "journal cursor expired: oldest available cursor is {oldest_available_cursor}"
81 ),
82 Self::InvalidConfig(msg) => write!(f, "invalid journal config: {msg}"),
83 }
84 }
85}
86
87impl std::error::Error for JournalError {}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
93struct JournalRecordV1 {
94 seq: u64,
95 written_at_secs: u64,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 event: Option<TraverseEvent>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 revokes: Option<u64>,
100}
101
102#[derive(Debug, Clone)]
105struct SegmentMeta {
106 path: PathBuf,
107 first_seq: u64,
108 last_seq: u64,
109 created_at_secs: u64,
110 last_written_at_secs: u64,
111 bytes: u64,
112}
113
114#[derive(Debug, Default)]
121struct SegmentReadCache {
122 consumed_bytes: u64,
123 consumed_lines: usize,
124 records: Vec<JournalRecordV1>,
125}
126
127pub struct DurableEventJournal {
129 root: PathBuf,
130 config: JournalConfig,
131 clock: Arc<dyn BrokerClock>,
132 sealed: Vec<SegmentMeta>,
133 active: Option<(SegmentMeta, fs::File)>,
134 next_seq: u64,
135 read_cache: Mutex<HashMap<PathBuf, SegmentReadCache>>,
136}
137
138impl DurableEventJournal {
139 pub fn open(
150 root: &Path,
151 config: JournalConfig,
152 clock: Arc<dyn BrokerClock>,
153 ) -> Result<Self, JournalError> {
154 validate_config(&config)?;
155 fs::create_dir_all(root).map_err(|e| io_err("create journal root", &e))?;
156
157 let mut segment_paths = Vec::new();
158 let entries = fs::read_dir(root).map_err(|e| io_err("list journal segments", &e))?;
159 for entry in entries {
160 let entry = entry.map_err(|e| io_err("read journal segment entry", &e))?;
161 let name = entry.file_name().to_string_lossy().into_owned();
162 if name.starts_with(SEGMENT_PREFIX) && name.ends_with(SEGMENT_SUFFIX) {
163 segment_paths.push(entry.path());
164 }
165 }
166 segment_paths.sort();
167
168 let mut sealed = Vec::new();
169 let mut next_seq = 1_u64;
170 let last_index = segment_paths.len().saturating_sub(1);
171 for (index, path) in segment_paths.iter().enumerate() {
172 let allow_torn_tail = index == last_index;
173 let records = read_segment_records(path, allow_torn_tail)?;
174 let Some((first, last)) = records.first().zip(records.last()) else {
175 fs::remove_file(path).map_err(|e| io_err("remove empty journal segment", &e))?;
178 continue;
179 };
180 if first.seq < next_seq {
181 return Err(JournalError::Corrupt {
182 path: path.display().to_string(),
183 line: 1,
184 message: format!(
185 "sequence {} is not greater than prior segment sequence {}",
186 first.seq,
187 next_seq - 1
188 ),
189 });
190 }
191 let bytes = fs::metadata(path)
192 .map_err(|e| io_err("stat journal segment", &e))?
193 .len();
194 sealed.push(SegmentMeta {
195 path: path.clone(),
196 first_seq: first.seq,
197 last_seq: last.seq,
198 created_at_secs: first.written_at_secs,
199 last_written_at_secs: last.written_at_secs,
200 bytes,
201 });
202 next_seq = last.seq + 1;
203 }
204
205 Ok(Self {
206 root: root.to_path_buf(),
207 config,
208 clock,
209 sealed,
210 active: None,
211 next_seq,
212 read_cache: Mutex::new(HashMap::new()),
213 })
214 }
215
216 pub fn append(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
226 self.append_line(Some(event), None)
227 }
228
229 pub fn append_revocation(&mut self, revoked_cursor: &str) -> Result<String, JournalError> {
239 let revoked = revoked_cursor.parse::<u64>().map_err(|e| {
240 JournalError::InvalidCursor(format!("revoked cursor `{revoked_cursor}`: {e}"))
241 })?;
242 self.append_line(None, Some(revoked))
243 }
244
245 fn append_line(
246 &mut self,
247 event: Option<&TraverseEvent>,
248 revokes: Option<u64>,
249 ) -> Result<String, JournalError> {
250 let now_secs = self.now_secs()?;
251
252 let needs_rollover = self.active.as_ref().is_some_and(|(meta, _)| {
253 meta.bytes >= self.config.max_segment_bytes
254 || now_secs.saturating_sub(meta.created_at_secs) >= self.config.max_segment_age_secs
255 });
256 if needs_rollover && let Some((meta, file)) = self.active.take() {
257 drop(file);
258 self.sealed.push(meta);
259 }
260
261 let mut active = match self.active.take() {
262 Some(active) => active,
263 None => self.open_segment(now_secs)?,
264 };
265 let result = append_record(&mut active, self.next_seq, now_secs, event, revokes);
266 self.active = Some(active);
267 let cursor = result?;
268 self.next_seq += 1;
269 Ok(cursor)
270 }
271
272 fn open_segment(&self, now_secs: u64) -> Result<(SegmentMeta, fs::File), JournalError> {
273 let path = self.root.join(format!(
274 "{SEGMENT_PREFIX}{:020}{SEGMENT_SUFFIX}",
275 self.next_seq
276 ));
277 let file = fs::OpenOptions::new()
278 .create_new(true)
279 .append(true)
280 .open(&path)
281 .map_err(|e| io_err("create journal segment", &e))?;
282 Ok((
283 SegmentMeta {
284 path,
285 first_seq: self.next_seq,
286 last_seq: self.next_seq,
287 created_at_secs: now_secs,
288 last_written_at_secs: now_secs,
289 bytes: 0,
290 },
291 file,
292 ))
293 }
294
295 pub fn replay_from(
308 &self,
309 cursor: &str,
310 max_events: usize,
311 ) -> Result<Vec<(String, TraverseEvent)>, JournalError> {
312 let after = cursor
313 .parse::<u64>()
314 .map_err(|e| JournalError::InvalidCursor(format!("cursor `{cursor}`: {e}")))?;
315
316 let oldest = self.oldest_retained_seq();
317 if let Some(oldest_seq) = oldest
318 && after + 1 < oldest_seq
319 {
320 return Err(JournalError::CursorExpired {
321 oldest_available_cursor: (oldest_seq - 1).to_string(),
322 });
323 }
324
325 let mut revoked = std::collections::HashSet::new();
328 let mut collected: Vec<(u64, TraverseEvent)> = Vec::new();
329 let last_index = self.segment_count().saturating_sub(1);
330 for (index, meta) in self.segments().enumerate() {
331 if meta.last_seq <= after {
332 continue;
333 }
334 let allow_torn_tail = index == last_index;
335 for record in self.cached_segment_records(&meta.path, allow_torn_tail)? {
336 if let Some(revoked_seq) = record.revokes {
337 let _ = revoked.insert(revoked_seq);
338 } else if record.seq > after
339 && let Some(event) = record.event
340 {
341 collected.push((record.seq, event));
342 }
343 }
344 }
345 collected.retain(|(seq, _)| !revoked.contains(seq));
346 collected.truncate(max_events);
347 Ok(collected
348 .into_iter()
349 .map(|(seq, event)| (seq.to_string(), event))
350 .collect())
351 }
352
353 #[must_use]
356 pub fn oldest_available_cursor(&self) -> String {
357 match self.oldest_retained_seq() {
358 Some(seq) => (seq - 1).to_string(),
359 None => (self.next_seq - 1).to_string(),
360 }
361 }
362
363 #[must_use]
369 pub(crate) fn latest_cursor(&self) -> u64 {
370 self.next_seq.saturating_sub(1)
371 }
372
373 pub fn prune(&mut self) -> Result<Vec<PathBuf>, JournalError> {
384 let now_secs = self.now_secs()?;
385 let mut deleted = Vec::new();
386
387 if let Some(max_age) = self.config.retention_max_age_secs {
388 while let Some(meta) = self.sealed.first() {
389 if now_secs.saturating_sub(meta.last_written_at_secs) <= max_age {
390 break;
391 }
392 let meta = self.sealed.remove(0);
393 fs::remove_file(&meta.path)
394 .map_err(|e| io_err("remove expired journal segment", &e))?;
395 deleted.push(meta.path);
396 }
397 }
398
399 if let Some(max_total) = self.config.retention_max_total_bytes {
400 let mut total: u64 = self.segments().map(|meta| meta.bytes).sum();
401 while total > max_total && !self.sealed.is_empty() {
402 let meta = self.sealed.remove(0);
403 fs::remove_file(&meta.path)
404 .map_err(|e| io_err("remove oversized journal segment", &e))?;
405 total -= meta.bytes;
406 deleted.push(meta.path);
407 }
408 }
409
410 if !deleted.is_empty() {
411 let mut cache = self
412 .read_cache
413 .lock()
414 .unwrap_or_else(PoisonError::into_inner);
415 for path in &deleted {
416 cache.remove(path);
417 }
418 }
419
420 Ok(deleted)
421 }
422
423 fn now_secs(&self) -> Result<u64, JournalError> {
424 let now = self.clock.now();
425 let elapsed = now
426 .duration_since(std::time::UNIX_EPOCH)
427 .map_err(|e| JournalError::Io(format!("system time before epoch: {e}")))?;
428 Ok(elapsed.as_secs())
429 }
430
431 fn segments(&self) -> impl Iterator<Item = &SegmentMeta> {
432 self.sealed
433 .iter()
434 .chain(self.active.iter().map(|(meta, _)| meta))
435 }
436
437 fn segment_count(&self) -> usize {
438 self.sealed.len() + usize::from(self.active.is_some())
439 }
440
441 fn oldest_retained_seq(&self) -> Option<u64> {
442 self.segments().map(|meta| meta.first_seq).next()
443 }
444
445 fn cached_segment_records(
448 &self,
449 path: &Path,
450 allow_torn_tail: bool,
451 ) -> Result<Vec<JournalRecordV1>, JournalError> {
452 let mut cache = self
453 .read_cache
454 .lock()
455 .unwrap_or_else(PoisonError::into_inner);
456 let entry = cache.entry(path.to_path_buf()).or_default();
457 refresh_segment_cache(entry, path, allow_torn_tail)?;
458 Ok(entry.records.clone())
459 }
460}
461
462fn validate_config(config: &JournalConfig) -> Result<(), JournalError> {
463 if config.max_segment_bytes == 0 {
464 return Err(JournalError::InvalidConfig(
465 "max_segment_bytes must be at least 1".to_string(),
466 ));
467 }
468 if config.max_segment_age_secs == 0 {
469 return Err(JournalError::InvalidConfig(
470 "max_segment_age_secs must be at least 1".to_string(),
471 ));
472 }
473 if config.retention_max_age_secs == Some(0) {
474 return Err(JournalError::InvalidConfig(
475 "retention_max_age_secs must be at least 1 when set".to_string(),
476 ));
477 }
478 if config.retention_max_total_bytes == Some(0) {
479 return Err(JournalError::InvalidConfig(
480 "retention_max_total_bytes must be at least 1 when set".to_string(),
481 ));
482 }
483 Ok(())
484}
485
486fn append_record(
489 active: &mut (SegmentMeta, fs::File),
490 seq: u64,
491 now_secs: u64,
492 event: Option<&TraverseEvent>,
493 revokes: Option<u64>,
494) -> Result<String, JournalError> {
495 let record = JournalRecordV1 {
496 seq,
497 written_at_secs: now_secs,
498 event: event.cloned(),
499 revokes,
500 };
501 let mut line = serde_json::to_vec(&record)
502 .map_err(|e| JournalError::Io(format!("serialize journal record: {e}")))?;
503 line.push(b'\n');
504
505 let (meta, file) = active;
506 write_durable(file, &line)?;
507 meta.bytes += line.len() as u64;
508 meta.last_seq = seq;
509 meta.last_written_at_secs = now_secs;
510 Ok(seq.to_string())
511}
512
513fn write_durable(file: &mut fs::File, line: &[u8]) -> Result<(), JournalError> {
516 let write_then_sync = |file: &mut fs::File| -> std::io::Result<()> {
517 file.write_all(line)?;
518 file.sync_data()
519 };
520 write_then_sync(file).map_err(|e| io_err("append journal record", &e))
521}
522
523fn refresh_segment_cache(
531 cache: &mut SegmentReadCache,
532 path: &Path,
533 allow_torn_tail: bool,
534) -> Result<(), JournalError> {
535 use std::io::{Read, Seek, SeekFrom};
536
537 let mut file = fs::File::open(path).map_err(|e| io_err("read journal segment", &e))?;
538 let file_len = file
539 .metadata()
540 .map_err(|e| io_err("read journal segment", &e))?
541 .len();
542 if file_len <= cache.consumed_bytes {
543 return Ok(());
544 }
545
546 file.seek(SeekFrom::Start(cache.consumed_bytes))
547 .map_err(|e| io_err("read journal segment", &e))?;
548 let mut new_bytes =
549 Vec::with_capacity(usize::try_from(file_len - cache.consumed_bytes).unwrap_or(0));
550 file.read_to_end(&mut new_bytes)
551 .map_err(|e| io_err("read journal segment", &e))?;
552
553 let ends_with_newline = new_bytes.last() == Some(&b'\n');
554 let chunks: Vec<&[u8]> = new_bytes
555 .split(|byte| *byte == b'\n')
556 .filter(|chunk| !chunk.is_empty())
557 .collect();
558
559 let mut new_records: Vec<JournalRecordV1> = Vec::new();
560 let mut bytes_advanced = 0u64;
561 let mut last_seq = cache.records.last().map(|record| record.seq);
562 for (offset, chunk) in chunks.iter().enumerate() {
563 let is_torn_tail = !ends_with_newline && offset + 1 == chunks.len();
564 let line = cache.consumed_lines + offset;
565 match serde_json::from_slice::<JournalRecordV1>(chunk) {
566 Ok(record) => {
567 if is_torn_tail {
568 if allow_torn_tail {
572 break;
573 }
574 return Err(corrupt(path, line, "unterminated record"));
575 }
576 if let Some(previous_seq) = last_seq
577 && record.seq <= previous_seq
578 {
579 return Err(corrupt(
580 path,
581 line,
582 &format!(
583 "sequence {} is not greater than prior sequence {}",
584 record.seq, previous_seq
585 ),
586 ));
587 }
588 bytes_advanced += chunk.len() as u64 + 1;
589 last_seq = Some(record.seq);
590 new_records.push(record);
591 }
592 Err(error) => {
593 if is_torn_tail && allow_torn_tail {
594 break;
595 }
596 return Err(corrupt(path, line, &format!("malformed record: {error}")));
597 }
598 }
599 }
600
601 cache.consumed_lines += new_records.len();
602 cache.consumed_bytes += bytes_advanced;
603 cache.records.extend(new_records);
604 Ok(())
605}
606
607fn read_segment_records(
616 path: &Path,
617 allow_torn_tail: bool,
618) -> Result<Vec<JournalRecordV1>, JournalError> {
619 let bytes = fs::read(path).map_err(|e| io_err("read journal segment", &e))?;
620 let ends_with_newline = bytes.last() == Some(&b'\n');
621
622 let mut records: Vec<JournalRecordV1> = Vec::new();
623 let chunks: Vec<&[u8]> = bytes
624 .split(|byte| *byte == b'\n')
625 .filter(|chunk| !chunk.is_empty())
626 .collect();
627 for (index, chunk) in chunks.iter().enumerate() {
628 let is_torn_tail = !ends_with_newline && index + 1 == chunks.len();
629 match serde_json::from_slice::<JournalRecordV1>(chunk) {
630 Ok(record) => {
631 if is_torn_tail {
632 if allow_torn_tail {
636 break;
637 }
638 return Err(corrupt(path, index, "unterminated record"));
639 }
640 if let Some(previous) = records.last()
641 && record.seq <= previous.seq
642 {
643 return Err(corrupt(
644 path,
645 index,
646 &format!(
647 "sequence {} is not greater than prior sequence {}",
648 record.seq, previous.seq
649 ),
650 ));
651 }
652 records.push(record);
653 }
654 Err(error) => {
655 if is_torn_tail && allow_torn_tail {
656 break;
657 }
658 return Err(corrupt(path, index, &format!("malformed record: {error}")));
659 }
660 }
661 }
662 Ok(records)
663}
664
665fn corrupt(path: &Path, index: usize, message: &str) -> JournalError {
666 JournalError::Corrupt {
667 path: path.display().to_string(),
668 line: index + 1,
669 message: message.to_string(),
670 }
671}
672
673fn io_err(action: &str, error: &std::io::Error) -> JournalError {
674 JournalError::Io(format!("{action}: {error}"))
675}
676
677#[cfg(test)]
678#[allow(clippy::expect_used)]
679mod tests {
680 use super::*;
681 use crate::events::types::LifecycleStatus;
682 use std::sync::Mutex;
683 use std::time::{Duration, SystemTime, UNIX_EPOCH};
684 use uuid::Uuid;
685
686 struct TestClock {
687 now: Mutex<SystemTime>,
688 }
689
690 impl TestClock {
691 fn at_secs(secs: u64) -> Arc<Self> {
692 Arc::new(Self {
693 now: Mutex::new(UNIX_EPOCH + Duration::from_secs(secs)),
694 })
695 }
696
697 fn before_epoch() -> Arc<Self> {
698 Arc::new(Self {
699 now: Mutex::new(UNIX_EPOCH - Duration::from_secs(1)),
700 })
701 }
702
703 fn advance(&self, secs: u64) {
704 let mut now = self.now.lock().expect("test clock lock must not poison");
705 *now += Duration::from_secs(secs);
706 }
707 }
708
709 impl BrokerClock for TestClock {
710 fn now(&self) -> SystemTime {
711 *self.now.lock().expect("test clock lock must not poison")
712 }
713 }
714
715 fn test_root(name: &str) -> PathBuf {
716 std::env::temp_dir().join(format!("traverse-journal-{name}-{}", Uuid::new_v4()))
717 }
718
719 fn test_event(marker: &str) -> TraverseEvent {
720 TraverseEvent {
721 id: Uuid::new_v4().to_string(),
722 source: "traverse-runtime/test.capability".to_string(),
723 event_type: "dev.traverse.test.journaled".to_string(),
724 datacontenttype: "application/json".to_string(),
725 time: "2026-07-13T00:00:00Z".to_string(),
726 data: serde_json::json!({ "marker": marker }),
727 owner: "test.capability".to_string(),
728 version: "1.0.0".to_string(),
729 lifecycle_status: LifecycleStatus::Active,
730 deduplication_id: Some(marker.to_string()),
731 ordering_scope: Some("test".to_string()),
732 correlation_id: Some("correlation-test".to_string()),
733 causation_id: Some("command-test".to_string()),
734 subject_id: None,
735 actor_id: None,
736 }
737 }
738
739 fn open_journal(
740 root: &Path,
741 config: JournalConfig,
742 clock: Arc<TestClock>,
743 ) -> DurableEventJournal {
744 DurableEventJournal::open(root, config, clock).expect("journal must open")
745 }
746
747 #[test]
748 fn config_limits_are_validated() {
749 let clock = TestClock::at_secs(1_000);
750 let cases = [
751 JournalConfig {
752 max_segment_bytes: 0,
753 ..JournalConfig::default()
754 },
755 JournalConfig {
756 max_segment_age_secs: 0,
757 ..JournalConfig::default()
758 },
759 JournalConfig {
760 retention_max_age_secs: Some(0),
761 ..JournalConfig::default()
762 },
763 JournalConfig {
764 retention_max_total_bytes: Some(0),
765 ..JournalConfig::default()
766 },
767 ];
768 for config in cases {
769 let err = DurableEventJournal::open(&test_root("bad-config"), config, clock.clone())
770 .map(|_| ())
771 .expect_err("zero limits must be rejected");
772 assert!(matches!(err, JournalError::InvalidConfig(_)), "{err}");
773 }
774 }
775
776 #[test]
777 fn append_and_replay_round_trip() {
778 let root = test_root("round-trip");
779 let clock = TestClock::at_secs(1_000);
780 let mut journal = open_journal(&root, JournalConfig::default(), clock);
781
782 assert_eq!(journal.oldest_available_cursor(), "0");
783 assert!(
784 journal
785 .replay_from("0", 10)
786 .expect("empty journal must replay nothing")
787 .is_empty()
788 );
789
790 let first = journal
791 .append(&test_event("a"))
792 .expect("append must succeed");
793 let second = journal
794 .append(&test_event("b"))
795 .expect("append must succeed");
796 assert_eq!(first, "1");
797 assert_eq!(second, "2");
798
799 let all = journal.replay_from("0", 10).expect("replay must succeed");
800 assert_eq!(all.len(), 2);
801 assert_eq!(all[0].0, "1");
802 assert_eq!(all[0].1.data["marker"], "a");
803
804 let tail = journal.replay_from("1", 10).expect("replay must succeed");
805 assert_eq!(tail.len(), 1);
806 assert_eq!(tail[0].0, "2");
807
808 let head = journal.replay_from("2", 10).expect("replay must succeed");
809 assert!(head.is_empty());
810
811 let capped = journal.replay_from("0", 1).expect("replay must succeed");
812 assert_eq!(capped.len(), 1, "max_events must bound the replay");
813 }
814
815 #[test]
816 fn replay_from_does_not_reread_previously_returned_records() {
817 let root = test_root("incremental-replay");
818 let clock = TestClock::at_secs(1_000);
819 let mut journal = open_journal(&root, JournalConfig::default(), clock);
820
821 journal
822 .append(&test_event("a"))
823 .expect("append must succeed");
824 let second = journal
825 .append(&test_event("b"))
826 .expect("append must succeed");
827
828 let first_pass = journal.replay_from("0", 10).expect("replay must succeed");
829 assert_eq!(first_pass.len(), 2, "both records must replay initially");
830
831 let segment_path = root.join(format!("{SEGMENT_PREFIX}{:020}{SEGMENT_SUFFIX}", 1));
838 let mut contents = fs::read(&segment_path).expect("segment must be readable");
839 let first_newline = contents
840 .iter()
841 .position(|byte| *byte == b'\n')
842 .expect("first record must be newline-terminated");
843 for byte in &mut contents[..first_newline] {
844 *byte = b'x';
845 }
846 fs::write(&segment_path, &contents).expect("segment must be writable");
847
848 let empty = journal
852 .replay_from(&second, 10)
853 .expect("replay of an already-corrupted, already-cached prefix must not re-read it");
854 assert!(empty.is_empty());
855
856 let third = journal
859 .append(&test_event("c"))
860 .expect("append must succeed");
861 let tail = journal
862 .replay_from(&second, 10)
863 .expect("replay must only read bytes appended after the cached prefix");
864 assert_eq!(tail.len(), 1);
865 assert_eq!(tail[0].0, third);
866 assert_eq!(tail[0].1.data["marker"], "c");
867 }
868
869 #[test]
870 fn segments_roll_over_by_size_and_age() {
871 let root = test_root("rollover");
872 let clock = TestClock::at_secs(1_000);
873 let config = JournalConfig {
874 max_segment_bytes: 1,
875 ..JournalConfig::default()
876 };
877 let mut journal = open_journal(&root, config, clock.clone());
878 journal
879 .append(&test_event("a"))
880 .expect("append must succeed");
881 journal
882 .append(&test_event("b"))
883 .expect("append must succeed");
884 journal
885 .append(&test_event("c"))
886 .expect("append must succeed");
887 assert_eq!(journal.sealed.len(), 2, "size bound must seal segments");
888
889 let across = journal.replay_from("0", 10).expect("replay must succeed");
890 assert_eq!(across.len(), 3, "replay must cross segment boundaries");
891 let capped = journal.replay_from("0", 2).expect("replay must succeed");
892 assert_eq!(capped.len(), 2, "max_events must stop mid-journal");
893
894 let age_root = test_root("rollover-age");
895 let mut aged = open_journal(&age_root, JournalConfig::default(), clock.clone());
896 aged.append(&test_event("a")).expect("append must succeed");
897 clock.advance(601);
898 aged.append(&test_event("b")).expect("append must succeed");
899 assert_eq!(aged.sealed.len(), 1, "age bound must seal segments");
900 }
901
902 #[test]
903 fn reopen_recovers_segments_and_continues_sequences() {
904 let root = test_root("reopen");
905 let clock = TestClock::at_secs(1_000);
906 let config = JournalConfig {
907 max_segment_bytes: 1,
908 ..JournalConfig::default()
909 };
910 {
911 let mut journal = open_journal(&root, config.clone(), clock.clone());
912 journal
913 .append(&test_event("a"))
914 .expect("append must succeed");
915 journal
916 .append(&test_event("b"))
917 .expect("append must succeed");
918 }
919
920 let mut reopened = open_journal(&root, config, clock);
921 assert_eq!(reopened.oldest_available_cursor(), "0");
922 let cursor = reopened
923 .append(&test_event("c"))
924 .expect("append must succeed");
925 assert_eq!(cursor, "3", "sequence must continue across restart");
926 let all = reopened.replay_from("0", 10).expect("replay must succeed");
927 assert_eq!(all.len(), 3);
928 assert_eq!(all[2].1.data["marker"], "c");
929 }
930
931 #[test]
932 fn recovery_tolerates_only_an_incomplete_final_record() {
933 let root = test_root("torn-tail");
934 let clock = TestClock::at_secs(1_000);
935 {
936 let mut journal = open_journal(&root, JournalConfig::default(), clock.clone());
937 journal
938 .append(&test_event("a"))
939 .expect("append must succeed");
940 }
941 let segment = fs::read_dir(&root)
942 .expect("root must list")
943 .next()
944 .expect("segment must exist")
945 .expect("entry must read")
946 .path();
947
948 let original = fs::read(&segment).expect("segment must read");
949 let mut torn = original.clone();
950 torn.extend_from_slice(b"{\"seq\":2,\"truncated");
951 fs::write(&segment, &torn).expect("torn tail must write");
952 let journal = open_journal(&root, JournalConfig::default(), clock.clone());
953 let recovered = journal.replay_from("0", 10).expect("replay must succeed");
954 assert_eq!(recovered.len(), 1, "unparseable torn tail must be ignored");
955
956 let newline = original
957 .iter()
958 .position(|byte| *byte == b'\n')
959 .expect("newline");
960 let mut unterminated = original.clone();
961 unterminated.extend_from_slice(&original[..newline]);
962 fs::write(&segment, &unterminated).expect("unterminated record must write");
963 let journal = open_journal(&root, JournalConfig::default(), clock);
964 let recovered = journal.replay_from("0", 10).expect("replay must succeed");
965 assert_eq!(
966 recovered.len(),
967 1,
968 "a parseable but unterminated tail was never acknowledged and must be ignored"
969 );
970 }
971
972 #[test]
973 fn recovery_fails_loudly_on_malformed_completed_records() {
974 let clock = TestClock::at_secs(1_000);
975
976 let corrupt_root = test_root("corrupt-interior");
977 fs::create_dir_all(&corrupt_root).expect("root must be creatable");
978 fs::write(
979 corrupt_root.join("segment-00000000000000000001.jsonl"),
980 b"not-json\n",
981 )
982 .expect("corrupt segment must write");
983 let err = DurableEventJournal::open(&corrupt_root, JournalConfig::default(), clock.clone())
984 .map(|_| ())
985 .expect_err("malformed completed record must fail");
986 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
987
988 let torn_old_root = test_root("torn-old-segment");
989 {
990 let config = JournalConfig {
991 max_segment_bytes: 1,
992 ..JournalConfig::default()
993 };
994 let mut journal = open_journal(&torn_old_root, config, clock.clone());
995 journal
996 .append(&test_event("a"))
997 .expect("append must succeed");
998 journal
999 .append(&test_event("b"))
1000 .expect("append must succeed");
1001 }
1002 let oldest = fs::read_dir(&torn_old_root)
1003 .expect("root must list")
1004 .filter_map(Result::ok)
1005 .map(|entry| entry.path())
1006 .min()
1007 .expect("oldest segment must exist");
1008 let mut torn = fs::read(&oldest).expect("segment must read");
1009 torn.extend_from_slice(b"{\"seq\":9,\"truncated");
1010 fs::write(&oldest, &torn).expect("torn tail must write");
1011 let err =
1012 DurableEventJournal::open(&torn_old_root, JournalConfig::default(), clock.clone())
1013 .map(|_| ())
1014 .expect_err("a torn tail in an older segment must fail");
1015 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1016
1017 let original = fs::read(&oldest).expect("segment must read");
1018 let unterminated_end = original
1019 .iter()
1020 .position(|byte| *byte == b'\n')
1021 .expect("newline");
1022 fs::write(&oldest, &original[..unterminated_end])
1023 .expect("unterminated valid record must write");
1024 let err =
1025 DurableEventJournal::open(&torn_old_root, JournalConfig::default(), clock.clone())
1026 .map(|_| ())
1027 .expect_err("a parseable unterminated record in an older segment must fail");
1028 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1029 }
1030
1031 #[test]
1032 fn recovery_rejects_non_monotonic_sequences() {
1033 let clock = TestClock::at_secs(1_000);
1034 let record = |seq: u64| {
1035 let mut line = serde_json::to_vec(&JournalRecordV1 {
1036 seq,
1037 written_at_secs: 1_000,
1038 event: Some(test_event("x")),
1039 revokes: None,
1040 })
1041 .expect("record must serialize");
1042 line.push(b'\n');
1043 line
1044 };
1045
1046 let within_root = test_root("non-monotonic-within");
1047 fs::create_dir_all(&within_root).expect("root must be creatable");
1048 let mut lines = record(2);
1049 lines.extend_from_slice(&record(2));
1050 fs::write(
1051 within_root.join("segment-00000000000000000002.jsonl"),
1052 &lines,
1053 )
1054 .expect("segment must write");
1055 let err = DurableEventJournal::open(&within_root, JournalConfig::default(), clock.clone())
1056 .map(|_| ())
1057 .expect_err("non-monotonic records within a segment must fail");
1058 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1059
1060 let across_root = test_root("non-monotonic-across");
1061 fs::create_dir_all(&across_root).expect("root must be creatable");
1062 fs::write(
1063 across_root.join("segment-00000000000000000001.jsonl"),
1064 record(5),
1065 )
1066 .expect("segment must write");
1067 fs::write(
1068 across_root.join("segment-00000000000000000002.jsonl"),
1069 record(3),
1070 )
1071 .expect("segment must write");
1072 let err = DurableEventJournal::open(&across_root, JournalConfig::default(), clock)
1073 .map(|_| ())
1074 .expect_err("non-monotonic records across segments must fail");
1075 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1076 }
1077
1078 fn append_raw_bytes(path: &Path, raw: &[u8]) {
1083 let mut contents = fs::read(path).expect("segment must be readable");
1084 contents.extend_from_slice(raw);
1085 fs::write(path, &contents).expect("segment must be writable");
1086 }
1087
1088 #[test]
1089 fn replay_from_rejects_a_torn_tail_in_a_non_final_segment() {
1090 let root = test_root("replay-torn-non-final");
1091 let clock = TestClock::at_secs(1_000);
1092 let config = JournalConfig {
1093 max_segment_bytes: 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
1104 let oldest = fs::read_dir(&root)
1105 .expect("root must list")
1106 .filter_map(Result::ok)
1107 .map(|entry| entry.path())
1108 .min()
1109 .expect("oldest segment must exist");
1110 let unterminated = serde_json::to_vec(&JournalRecordV1 {
1114 seq: 9,
1115 written_at_secs: 1_000,
1116 event: Some(test_event("x")),
1117 revokes: None,
1118 })
1119 .expect("record must serialize");
1120 append_raw_bytes(&oldest, &unterminated);
1121
1122 let err = journal
1123 .replay_from("0", 10)
1124 .expect_err("a torn tail in a non-final segment must fail on first replay");
1125 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1126 }
1127
1128 #[test]
1129 fn replay_from_rejects_a_non_monotonic_sequence_on_first_read() {
1130 let root = test_root("replay-non-monotonic");
1131 let clock = TestClock::at_secs(1_000);
1132 let mut journal = open_journal(&root, JournalConfig::default(), clock);
1133 let first = journal
1134 .append(&test_event("a"))
1135 .expect("append must succeed");
1136
1137 let mut line = serde_json::to_vec(&JournalRecordV1 {
1138 seq: first.parse().expect("cursor must be numeric"),
1139 written_at_secs: 1_000,
1140 event: Some(test_event("x")),
1141 revokes: None,
1142 })
1143 .expect("record must serialize");
1144 line.push(b'\n');
1145 let segment = fs::read_dir(&root)
1146 .expect("root must list")
1147 .next()
1148 .expect("segment must exist")
1149 .expect("entry must read")
1150 .path();
1151 append_raw_bytes(&segment, &line);
1152
1153 let err = journal
1154 .replay_from("0", 10)
1155 .expect_err("a non-increasing sequence must fail on first replay");
1156 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1157 }
1158
1159 #[test]
1160 fn replay_from_rejects_a_malformed_completed_record_on_first_read() {
1161 let root = test_root("replay-malformed");
1162 let clock = TestClock::at_secs(1_000);
1163 let mut journal = open_journal(&root, JournalConfig::default(), clock);
1164 journal
1165 .append(&test_event("a"))
1166 .expect("append must succeed");
1167
1168 let segment = fs::read_dir(&root)
1169 .expect("root must list")
1170 .next()
1171 .expect("segment must exist")
1172 .expect("entry must read")
1173 .path();
1174 append_raw_bytes(&segment, b"not-json\n");
1175
1176 let err = journal
1177 .replay_from("0", 10)
1178 .expect_err("a malformed completed record must fail on first replay");
1179 assert!(matches!(err, JournalError::Corrupt { .. }), "{err}");
1180 }
1181
1182 #[test]
1183 fn recovery_drops_segments_with_no_acknowledged_records() {
1184 let clock = TestClock::at_secs(1_000);
1185 let root = test_root("empty-segment");
1186 fs::create_dir_all(&root).expect("root must be creatable");
1187 let empty = root.join("segment-00000000000000000001.jsonl");
1188 fs::write(&empty, b"").expect("empty segment must write");
1189 fs::write(root.join("ignored.txt"), b"not a segment").expect("stray file must write");
1190 let journal = open_journal(&root, JournalConfig::default(), clock);
1191 assert!(!empty.exists(), "unacknowledged segment must be removed");
1192 assert_eq!(journal.oldest_available_cursor(), "0");
1193 }
1194
1195 #[test]
1196 fn filesystem_failures_surface_as_io_errors() {
1197 let clock = TestClock::at_secs(1_000);
1198
1199 let blocked_parent = test_root("blocked-parent");
1200 fs::create_dir_all(&blocked_parent).expect("parent must be creatable");
1201 fs::write(blocked_parent.join("root"), b"file").expect("squatting file must write");
1202 let err = DurableEventJournal::open(
1203 &blocked_parent.join("root"),
1204 JournalConfig::default(),
1205 clock.clone(),
1206 )
1207 .map(|_| ())
1208 .expect_err("root creation over a file must fail");
1209 assert!(matches!(err, JournalError::Io(_)), "{err}");
1210
1211 let dir_segment_root = test_root("dir-segment");
1212 fs::create_dir_all(dir_segment_root.join("segment-00000000000000000001.jsonl"))
1213 .expect("directory squatting on a segment must be creatable");
1214 let err =
1215 DurableEventJournal::open(&dir_segment_root, JournalConfig::default(), clock.clone())
1216 .map(|_| ())
1217 .expect_err("reading a directory as a segment must fail");
1218 assert!(matches!(err, JournalError::Io(_)), "{err}");
1219
1220 let squat_root = test_root("squat-next-segment");
1221 fs::create_dir_all(squat_root.join("segment-00000000000000000001.jsonl"))
1222 .expect("squatting directory must be creatable");
1223 let mut journal =
1224 open_journal(&test_root("fresh"), JournalConfig::default(), clock.clone());
1225 journal.root = squat_root;
1226 let err = journal
1227 .append(&test_event("a"))
1228 .expect_err("creating a segment over a directory must fail");
1229 assert!(matches!(err, JournalError::Io(_)), "{err}");
1230
1231 let read_only_root = test_root("read-only-file");
1232 fs::create_dir_all(&read_only_root).expect("root must be creatable");
1233 let path = read_only_root.join("segment.jsonl");
1234 fs::write(&path, b"").expect("file must write");
1235 let mut file = fs::OpenOptions::new()
1236 .read(true)
1237 .open(&path)
1238 .expect("file must open read-only");
1239 let err = write_durable(&mut file, b"line\n")
1240 .expect_err("writing through a read-only handle must fail");
1241 assert!(matches!(err, JournalError::Io(_)), "{err}");
1242 }
1243
1244 #[test]
1245 fn pre_epoch_clock_fails_closed() {
1246 let root = test_root("pre-epoch");
1247 let clock = TestClock::before_epoch();
1248 let mut journal = open_journal(&root, JournalConfig::default(), clock);
1249 let append_err = journal
1250 .append(&test_event("a"))
1251 .expect_err("append with a pre-epoch clock must fail");
1252 assert!(matches!(append_err, JournalError::Io(_)), "{append_err}");
1253 let prune_err = journal
1254 .prune()
1255 .expect_err("prune with a pre-epoch clock must fail");
1256 assert!(matches!(prune_err, JournalError::Io(_)), "{prune_err}");
1257 }
1258
1259 #[test]
1260 fn cursors_are_validated_and_expire_after_pruning() {
1261 let root = test_root("cursor-expiry");
1262 let clock = TestClock::at_secs(1_000);
1263 let config = JournalConfig {
1264 max_segment_bytes: 1,
1265 retention_max_age_secs: Some(10),
1266 ..JournalConfig::default()
1267 };
1268 let mut journal = open_journal(&root, config, clock.clone());
1269
1270 let invalid = journal
1271 .replay_from("not-a-cursor", 10)
1272 .expect_err("malformed cursor must be rejected");
1273 assert!(
1274 matches!(invalid, JournalError::InvalidCursor(_)),
1275 "{invalid}"
1276 );
1277
1278 journal
1279 .append(&test_event("a"))
1280 .expect("append must succeed");
1281 journal
1282 .append(&test_event("b"))
1283 .expect("append must succeed");
1284 journal
1285 .append(&test_event("c"))
1286 .expect("append must succeed");
1287
1288 clock.advance(100);
1289 let deleted = journal.prune().expect("prune must succeed");
1290 assert_eq!(deleted.len(), 2, "expired sealed segments must be deleted");
1291 for path in &deleted {
1292 assert!(!path.exists(), "pruned segment file must be removed");
1293 }
1294
1295 let expired = journal
1296 .replay_from("0", 10)
1297 .expect_err("cursor before retained history must expire");
1298 assert_eq!(
1299 expired,
1300 JournalError::CursorExpired {
1301 oldest_available_cursor: "2".to_string(),
1302 }
1303 );
1304 assert_eq!(journal.oldest_available_cursor(), "2");
1305
1306 let resumed = journal
1307 .replay_from("2", 10)
1308 .expect("oldest available cursor must replay");
1309 assert_eq!(resumed.len(), 1);
1310 assert_eq!(resumed[0].1.data["marker"], "c");
1311 }
1312
1313 #[test]
1314 fn prune_reclaims_whole_segments_only_and_spares_the_active_one() {
1315 let root = test_root("prune-rules");
1316 let clock = TestClock::at_secs(1_000);
1317 let config = JournalConfig {
1318 max_segment_bytes: 1,
1319 retention_max_age_secs: Some(1_000_000),
1320 retention_max_total_bytes: Some(1),
1321 ..JournalConfig::default()
1322 };
1323 let mut journal = open_journal(&root, config, clock.clone());
1324 journal
1325 .append(&test_event("a"))
1326 .expect("append must succeed");
1327 journal
1328 .append(&test_event("b"))
1329 .expect("append must succeed");
1330
1331 let deleted = journal.prune().expect("prune must succeed");
1332 assert_eq!(
1333 deleted.len(),
1334 1,
1335 "size retention must delete oldest sealed segments only"
1336 );
1337 assert!(
1338 journal.active.is_some(),
1339 "the active segment must never be pruned"
1340 );
1341 let survivors = journal.replay_from(&journal.oldest_available_cursor(), 10);
1342 assert_eq!(survivors.expect("replay must succeed").len(), 1);
1343
1344 let unlimited_root = test_root("prune-unlimited");
1345 let mut unlimited = open_journal(&unlimited_root, JournalConfig::default(), clock.clone());
1346 unlimited
1347 .append(&test_event("a"))
1348 .expect("append must succeed");
1349 assert!(
1350 unlimited
1351 .prune()
1352 .expect("prune without retention must succeed")
1353 .is_empty(),
1354 "no retention configured means nothing is reclaimed"
1355 );
1356
1357 let missing_root = test_root("prune-missing-file");
1358 let config = JournalConfig {
1359 max_segment_bytes: 1,
1360 retention_max_age_secs: Some(1),
1361 ..JournalConfig::default()
1362 };
1363 let mut missing = open_journal(&missing_root, config, clock.clone());
1364 missing
1365 .append(&test_event("a"))
1366 .expect("append must succeed");
1367 missing
1368 .append(&test_event("b"))
1369 .expect("append must succeed");
1370 let sealed_path = missing.sealed[0].path.clone();
1371 fs::remove_file(&sealed_path).expect("sealed segment must be removable");
1372 clock.advance(100);
1373 let err = missing
1374 .prune()
1375 .expect_err("pruning an already-missing segment must surface an io error");
1376 assert!(matches!(err, JournalError::Io(_)), "{err}");
1377 }
1378
1379 #[test]
1380 fn prune_size_rule_surfaces_removal_failures() {
1381 let root = test_root("prune-size-missing");
1382 let clock = TestClock::at_secs(1_000);
1383 let config = JournalConfig {
1384 max_segment_bytes: 1,
1385 retention_max_total_bytes: Some(1),
1386 ..JournalConfig::default()
1387 };
1388 let mut journal = open_journal(&root, config, clock);
1389 journal
1390 .append(&test_event("a"))
1391 .expect("append must succeed");
1392 journal
1393 .append(&test_event("b"))
1394 .expect("append must succeed");
1395 let sealed_path = journal.sealed[0].path.clone();
1396 fs::remove_file(&sealed_path).expect("sealed segment must be removable");
1397 let err = journal
1398 .prune()
1399 .expect_err("size pruning an already-missing segment must surface an io error");
1400 assert!(matches!(err, JournalError::Io(_)), "{err}");
1401 }
1402
1403 #[test]
1404 fn revocations_suppress_events_from_replay() {
1405 let root = test_root("revocation");
1406 let clock = TestClock::at_secs(1_000);
1407 let mut journal = open_journal(&root, JournalConfig::default(), clock.clone());
1408 journal
1409 .append(&test_event("a"))
1410 .expect("append must succeed");
1411 let second = journal
1412 .append(&test_event("b"))
1413 .expect("append must succeed");
1414 journal
1415 .append(&test_event("c"))
1416 .expect("append must succeed");
1417
1418 journal
1419 .append_revocation(&second)
1420 .expect("revocation must be durable");
1421
1422 let replayed = journal.replay_from("0", 10).expect("replay must succeed");
1423 let markers: Vec<_> = replayed
1424 .iter()
1425 .map(|(_, event)| event.data["marker"].clone())
1426 .collect();
1427 assert_eq!(
1428 markers,
1429 vec![serde_json::json!("a"), serde_json::json!("c")],
1430 "the revoked event must not be delivered and the revocation record itself must not appear"
1431 );
1432
1433 let capped = journal.replay_from("0", 2).expect("replay must succeed");
1434 assert_eq!(
1435 capped.len(),
1436 2,
1437 "max_events must apply after revocation filtering"
1438 );
1439
1440 let reopened = open_journal(&root, JournalConfig::default(), clock);
1441 let recovered = reopened.replay_from("0", 10).expect("replay must succeed");
1442 assert_eq!(
1443 recovered.len(),
1444 2,
1445 "revocations must keep suppressing events across restart"
1446 );
1447
1448 let mut invalid = reopened;
1449 let err = invalid
1450 .append_revocation("not-a-cursor")
1451 .expect_err("unparseable revoked cursor must be rejected");
1452 assert!(matches!(err, JournalError::InvalidCursor(_)), "{err}");
1453 }
1454
1455 #[test]
1456 fn errors_render_stable_messages() {
1457 let cases: Vec<(JournalError, &str)> = vec![
1458 (JournalError::Io("boom".to_string()), "journal io failure"),
1459 (
1460 JournalError::Corrupt {
1461 path: "p".to_string(),
1462 line: 3,
1463 message: "bad".to_string(),
1464 },
1465 "journal corrupt at p:3",
1466 ),
1467 (
1468 JournalError::InvalidCursor("bad".to_string()),
1469 "invalid journal cursor",
1470 ),
1471 (
1472 JournalError::CursorExpired {
1473 oldest_available_cursor: "7".to_string(),
1474 },
1475 "oldest available cursor is 7",
1476 ),
1477 (
1478 JournalError::InvalidConfig("bad".to_string()),
1479 "invalid journal config",
1480 ),
1481 ];
1482 for (error, expected) in cases {
1483 assert!(
1484 error.to_string().contains(expected),
1485 "{error} must mention {expected}"
1486 );
1487 }
1488 }
1489}