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::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/// Journal runtime configuration (067 FR-001 defaults: 64 MB / 10 minutes).
24#[derive(Debug, Clone)]
25pub struct JournalConfig {
26    /// Maximum bytes in a segment before it rolls over.
27    pub max_segment_bytes: u64,
28    /// Maximum age of a segment before it rolls over, in seconds.
29    pub max_segment_age_secs: u64,
30    /// Retention by age: events older than this may be reclaimed.
31    pub retention_max_age_secs: Option<u64>,
32    /// Retention by size: total journal bytes above this may be reclaimed.
33    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/// Errors surfaced by the durable journal.
48#[derive(Debug, PartialEq, Eq)]
49pub enum JournalError {
50    /// Filesystem operation failed.
51    Io(String),
52    /// A completed journal record is malformed (066 FR-009: fail loudly).
53    Corrupt {
54        path: String,
55        line: usize,
56        message: String,
57    },
58    /// Cursor string could not be parsed.
59    InvalidCursor(String),
60    /// The requested cursor points before the retained history (066 FR-008).
61    CursorExpired { oldest_available_cursor: String },
62    /// Journal was configured with invalid limits.
63    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/// One durable record: an acknowledged event with its journal sequence, or a
90/// revocation suppressing a previously written sequence from replay
91/// (067 FR-004: a rejected event must not be delivered through any path).
92#[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/// Metadata for one on-disk segment, derived entirely from its contents so
103/// cursors stay independent of segment layout (066 FR-007).
104#[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/// Incrementally accumulated parse state for one on-disk segment, keyed by
115/// path in [`DurableEventJournal::read_cache`]. A sealed segment is read to
116/// completion once and never re-read; the active (still-growing) segment
117/// only has the bytes appended since the previous call re-read and parsed,
118/// so repeated `replay_from` polling does not re-read and re-parse the
119/// whole segment on every call.
120#[derive(Debug, Default)]
121struct SegmentReadCache {
122    consumed_bytes: u64,
123    consumed_lines: usize,
124    records: Vec<JournalRecordV1>,
125}
126
127/// Append-only segmented journal with fsync-before-acknowledgement.
128pub 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    /// Open (or create) the journal under `root`, recovering existing
140    /// segments. Recovery ignores only an incomplete final record of the
141    /// newest segment and fails loudly on any malformed completed record
142    /// (066 FR-009).
143    ///
144    /// # Errors
145    ///
146    /// Returns [`JournalError::InvalidConfig`] for zero limits,
147    /// [`JournalError::Io`] on filesystem failures, and
148    /// [`JournalError::Corrupt`] when a completed record is malformed.
149    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                // Nothing in this segment was ever acknowledged (fsync happens
176                // before ack), so dropping the file loses no durable data.
177                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    /// Append an event, fsync it, and return its cursor (066 FR-006: the
217    /// record is durable before this returns). Rolls the active segment over
218    /// at the configured size or age bound, whichever occurs first
219    /// (067 FR-001).
220    ///
221    /// # Errors
222    ///
223    /// Returns [`JournalError::Io`] when the durable write fails; the event
224    /// is not acknowledged in that case.
225    pub fn append(&mut self, event: &TraverseEvent) -> Result<String, JournalError> {
226        self.append_line(Some(event), None)
227    }
228
229    /// Durably record that the event at `revoked_cursor` was rejected and
230    /// must never be delivered through replay (067 FR-004). Used when a
231    /// caller abandoned a write that later completed, or when a durably
232    /// written event could not be delivered.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`JournalError::InvalidCursor`] for unparseable cursors and
237    /// [`JournalError::Io`] when the durable write fails.
238    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    /// Replay up to `max_events` events strictly after `cursor`.
296    ///
297    /// `"0"` replays from the start of retained history. Cursors are opaque
298    /// monotonic sequence identifiers independent of segment layout
299    /// (066 FR-007).
300    ///
301    /// # Errors
302    ///
303    /// Returns [`JournalError::InvalidCursor`] for unparseable cursors,
304    /// [`JournalError::CursorExpired`] with the oldest available cursor when
305    /// the requested history was reclaimed (066 FR-008), and
306    /// [`JournalError::Io`] / [`JournalError::Corrupt`] on read failures.
307    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        // Revocations always carry a later sequence than the record they
326        // suppress, so the full scan must finish before results are final.
327        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    /// The cursor from which the oldest retained event replays; callers that
354    /// receive [`JournalError::CursorExpired`] resume from here.
355    #[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    /// The most recent cursor ever durably assigned, or `0` when nothing has
364    /// been written yet. [`super::durable::DurableBroker::open`] uses this to
365    /// seed a freshly constructed in-memory broker's restart floor (spec 066
366    /// FR-007), so it correctly defers cursors it cannot itself vouch for to
367    /// durable replay instead of accepting them by default.
368    #[must_use]
369    pub(crate) fn latest_cursor(&self) -> u64 {
370        self.next_seq.saturating_sub(1)
371    }
372
373    /// Reclaim expired history by deleting whole sealed segments only — never
374    /// rewriting or truncating in place (067 FR-002). A segment is deleted
375    /// only once every event in it falls outside the retention window; the
376    /// active segment is never deleted, bounding the overhang to one rollover
377    /// period.
378    ///
379    /// # Errors
380    ///
381    /// Returns [`JournalError::Io`] when a reclaimable segment cannot be
382    /// deleted.
383    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    /// Returns `path`'s parsed records, reading and parsing from disk only
446    /// the bytes appended since the previous call for this path.
447    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
486/// Serialize, durably write, and acknowledge one record into the active
487/// segment, returning its cursor.
488fn 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
513/// Write and fsync one record line; the record is only acknowledged after
514/// both succeed (066 FR-006).
515fn 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
523/// Reads and parses only the bytes appended to `path` since `cache` was last
524/// refreshed (tracked by `cache.consumed_bytes`), appending newly parsed
525/// records to `cache.records`. Applies exactly the same corrupt/torn-tail
526/// semantics as [`read_segment_records`] to the incremental slice, using
527/// `cache`'s prior state (consumed line count, last parsed sequence) as
528/// context. On error, `cache` is left unmodified so a retry re-parses the
529/// same bytes rather than duplicating already-cached records.
530fn 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                    // A record is only acknowledged once its full line
569                    // (including the terminator) is fsynced; a tail without a
570                    // terminator was never acknowledged, even if it parses.
571                    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
607/// Parse every record in a segment. A trailing chunk without a newline
608/// terminator is an incomplete final record: ignored when `allow_torn_tail`
609/// (the newest segment interrupted mid-write), corrupt otherwise. Any
610/// newline-terminated record that fails to parse is corrupt (066 FR-009).
611///
612/// Used only for the one-time recovery scan in [`DurableEventJournal::open`];
613/// [`DurableEventJournal::replay_from`] uses the incremental
614/// [`refresh_segment_cache`] instead.
615fn 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                    // A record is only acknowledged once its full line
633                    // (including the terminator) is fsynced; a tail without a
634                    // terminator was never acknowledged, even if it parses.
635                    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        // Corrupt the on-disk bytes for the already-consumed prefix (the
832        // first record). If a later call re-reads and re-parses the whole
833        // segment from byte 0 (the pre-fix behavior), this now-invalid JSON
834        // makes it fail with JournalError::Corrupt. If it correctly reuses
835        // its cached parse of the consumed prefix and only reads bytes
836        // appended after it, this corruption is never touched again.
837        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        // Advancing the cursor past the corrupted prefix must succeed: only
849        // the bytes after `second` are new, and nothing new has been
850        // appended yet, so this must return empty without touching disk.
851        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        // Appending and replaying a genuinely new record must also succeed,
857        // proving only the newly appended bytes were read and parsed.
858        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    /// Appends `raw` directly to `path`'s bytes, bypassing `DurableEventJournal`
1079    /// entirely. Used to simulate a segment corrupted after the journal last
1080    /// validated it, so the first `replay_from` for that segment (not
1081    /// `DurableEventJournal::open`'s recovery scan) is what encounters it.
1082    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        // A syntactically valid record with no trailing newline: parseable,
1111        // but only acknowledged once fsynced with its terminator, so this
1112        // must still be rejected as an unterminated tail (not malformed).
1113        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}