Skip to main content

traverse_runtime/events/
journal.rs

1//! Durable, append-only segmented event journal.
2//!
3//! Governed by spec 066-durable-identity-event-delivery (FR-005..FR-009) and
4//! spec 067-durable-journal-retention-and-write-limits (FR-001, FR-002).
5//!
6//! The bounded publish write path that drives this journal lives in
7//! [`super::durable`].
8
9use 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/// Journal runtime configuration (067 FR-001 defaults: 64 MB / 10 minutes).
23#[derive(Debug, Clone)]
24pub struct JournalConfig {
25    /// Maximum bytes in a segment before it rolls over.
26    pub max_segment_bytes: u64,
27    /// Maximum age of a segment before it rolls over, in seconds.
28    pub max_segment_age_secs: u64,
29    /// Retention by age: events older than this may be reclaimed.
30    pub retention_max_age_secs: Option<u64>,
31    /// Retention by size: total journal bytes above this may be reclaimed.
32    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/// Errors surfaced by the durable journal.
47#[derive(Debug, PartialEq, Eq)]
48pub enum JournalError {
49    /// Filesystem operation failed.
50    Io(String),
51    /// A completed journal record is malformed (066 FR-009: fail loudly).
52    Corrupt {
53        path: String,
54        line: usize,
55        message: String,
56    },
57    /// Cursor string could not be parsed.
58    InvalidCursor(String),
59    /// The requested cursor points before the retained history (066 FR-008).
60    CursorExpired { oldest_available_cursor: String },
61    /// Journal was configured with invalid limits.
62    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/// One durable record: an acknowledged event with its journal sequence, or a
89/// revocation suppressing a previously written sequence from replay
90/// (067 FR-004: a rejected event must not be delivered through any path).
91#[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/// Metadata for one on-disk segment, derived entirely from its contents so
102/// cursors stay independent of segment layout (066 FR-007).
103#[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
113/// Append-only segmented journal with fsync-before-acknowledgement.
114pub 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    /// Open (or create) the journal under `root`, recovering existing
125    /// segments. Recovery ignores only an incomplete final record of the
126    /// newest segment and fails loudly on any malformed completed record
127    /// (066 FR-009).
128    ///
129    /// # Errors
130    ///
131    /// Returns [`JournalError::InvalidConfig`] for zero limits,
132    /// [`JournalError::Io`] on filesystem failures, and
133    /// [`JournalError::Corrupt`] when a completed record is malformed.
134    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                // Nothing in this segment was ever acknowledged (fsync happens
161                // before ack), so dropping the file loses no durable data.
162                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    /// Append an event, fsync it, and return its cursor (066 FR-006: the
201    /// record is durable before this returns). Rolls the active segment over
202    /// at the configured size or age bound, whichever occurs first
203    /// (067 FR-001).
204    ///
205    /// # Errors
206    ///
207    /// Returns [`JournalError::Io`] when the durable write fails; the event
208    /// is not acknowledged in that case.
209    pub fn append(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
210        self.append_line(Some(event), None)
211    }
212
213    /// Durably record that the event at `revoked_cursor` was rejected and
214    /// must never be delivered through replay (067 FR-004). Used when a
215    /// caller abandoned a write that later completed, or when a durably
216    /// written event could not be delivered.
217    ///
218    /// # Errors
219    ///
220    /// Returns [`JournalError::InvalidCursor`] for unparseable cursors and
221    /// [`JournalError::Io`] when the durable write fails.
222    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    /// Replay up to `max_events` events strictly after `cursor`.
280    ///
281    /// `"0"` replays from the start of retained history. Cursors are opaque
282    /// monotonic sequence identifiers independent of segment layout
283    /// (066 FR-007).
284    ///
285    /// # Errors
286    ///
287    /// Returns [`JournalError::InvalidCursor`] for unparseable cursors,
288    /// [`JournalError::CursorExpired`] with the oldest available cursor when
289    /// the requested history was reclaimed (066 FR-008), and
290    /// [`JournalError::Io`] / [`JournalError::Corrupt`] on read failures.
291    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        // Revocations always carry a later sequence than the record they
310        // suppress, so the full scan must finish before results are final.
311        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    /// The cursor from which the oldest retained event replays; callers that
338    /// receive [`JournalError::CursorExpired`] resume from here.
339    #[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    /// The most recent cursor ever durably assigned, or `0` when nothing has
348    /// been written yet. [`super::durable::DurableBroker::open`] uses this to
349    /// seed a freshly constructed in-memory broker's restart floor (spec 066
350    /// FR-007), so it correctly defers cursors it cannot itself vouch for to
351    /// durable replay instead of accepting them by default.
352    #[must_use]
353    pub(crate) fn latest_cursor(&self) -> u64 {
354        self.next_seq.saturating_sub(1)
355    }
356
357    /// Reclaim expired history by deleting whole sealed segments only — never
358    /// rewriting or truncating in place (067 FR-002). A segment is deleted
359    /// only once every event in it falls outside the retention window; the
360    /// active segment is never deleted, bounding the overhang to one rollover
361    /// period.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`JournalError::Io`] when a reclaimable segment cannot be
366    /// deleted.
367    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
444/// Serialize, durably write, and acknowledge one record into the active
445/// segment, returning its cursor.
446fn 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
471/// Write and fsync one record line; the record is only acknowledged after
472/// both succeed (066 FR-006).
473fn 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
481/// Parse every record in a segment. A trailing chunk without a newline
482/// terminator is an incomplete final record: ignored when `allow_torn_tail`
483/// (the newest segment interrupted mid-write), corrupt otherwise. Any
484/// newline-terminated record that fails to parse is corrupt (066 FR-009).
485fn 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                    // A record is only acknowledged once its full line
503                    // (including the terminator) is fsynced; a tail without a
504                    // terminator was never acknowledged, even if it parses.
505                    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}