Skip to main content

truefix_store/
file.rs

1//! File-backed message stores.
2//!
3//! Layout in the store directory:
4//! - `senderseqnums`/`targetseqnums` — one text value each (BUG-30/BUG-99, feature 007), each
5//!   written atomically (write-temp-then-rename). A legacy combined `seqnums` file (one sender
6//!   line, one target line — the pre-007 layout) is auto-migrated into this pair on first open if
7//!   found and left in place afterward (FR-001a).
8//! - `body` — append log of records: `seq(8 LE) | len(4 LE) | bytes`.
9//!
10//! On open, the body log's offset index is replayed into memory (not the message bytes
11//! themselves); a truncated/corrupt trailing record is tolerated (recovered up to the last
12//! complete record) and flagged, supporting ForceResendWhenCorruptedStore (T060).
13//!
14//! [`FileStore`] reads message bodies from disk on every `get()` (no in-memory message cache,
15//! matching QuickFIX's plain file store — only the offset index is kept in memory).
16//! [`CachedFileStore`] additionally keeps a bounded in-memory cache of message bodies
17//! (`FileStoreMaxCachedMsgs`; `0` means unbounded) to avoid the disk read for warm resends,
18//! falling back to disk for cache misses. Both honor a `FileStoreSync` fsync toggle via
19//! [`FileStoreOptions`].
20
21use std::collections::{BTreeMap, VecDeque};
22use std::fs::{self, File, OpenOptions};
23use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
24use std::path::{Path, PathBuf};
25use std::sync::Mutex;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28use async_trait::async_trait;
29
30use crate::{MessageStore, StoreError};
31
32/// GAP-38/FR-017 (feature 005): read the creation-time sibling file (a single line, Unix
33/// seconds), writing it with `now` if it doesn't exist yet — the store's creation time is
34/// recorded once, on first `open()`, and never overwritten by later opens.
35fn creation_time_file(path: &Path) -> Result<time::OffsetDateTime, StoreError> {
36    match fs::read_to_string(path) {
37        Ok(s) => {
38            let corrupted =
39                || StoreError::Backend(format!("corrupted creation-time file {path:?}: {s:?}"));
40            let secs: i64 = s.trim().parse().map_err(|_| corrupted())?;
41            time::OffsetDateTime::from_unix_timestamp(secs).map_err(|_| corrupted())
42        }
43        Err(e) if e.kind() == ErrorKind::NotFound => reset_creation_time_file(path),
44        Err(e) => Err(io_err(e)),
45    }
46}
47
48/// Overwrite the creation-time sibling file with the current time (used at first `open()` and on
49/// every `reset()`, matching QFJ/QFGo's "creation time updates on reset" semantics).
50fn reset_creation_time_file(path: &Path) -> Result<time::OffsetDateTime, StoreError> {
51    let now = time::OffsetDateTime::now_utc();
52    fs::write(path, now.unix_timestamp().to_string()).map_err(io_err)?;
53    Ok(now)
54}
55
56fn io_err(e: std::io::Error) -> StoreError {
57    StoreError::Io(e.to_string())
58}
59
60fn poisoned() -> StoreError {
61    StoreError::Backend("poisoned lock".into())
62}
63
64/// Drop the lowest-sequence entries from `index` until at most `max_records` remain (NEW-108,
65/// audit 006). `BodyIndex` is a `BTreeMap`, so its keys are already sequence-ordered.
66fn evict_oldest(index: &mut BodyIndex, max_records: usize) {
67    while index.len() > max_records {
68        let Some(&oldest) = index.keys().next() else {
69            break;
70        };
71        index.remove(&oldest);
72    }
73}
74
75/// Options shared by [`FileStore`] and [`CachedFileStore`] (FR-025).
76#[derive(Debug, Clone, Copy)]
77pub struct FileStoreOptions {
78    /// `FileStoreSync`: fsync the body/seqnum files after every write.
79    pub sync: bool,
80    /// `FileStoreMaxCachedMsgs` (honored by [`CachedFileStore`] only): maximum number of message
81    /// bodies held in memory; `0` means unbounded (cache every message saved this session, the
82    /// original pre-FR-025 behavior).
83    pub max_cached_msgs: usize,
84    /// `FileStoreMaxBodyRecords` (NEW-108, audit 006): bound the body log's *index* to at most
85    /// this many of the most-recently-appended records; `0` (the default) means unbounded, the
86    /// original behavior. When set, the oldest indexed records are dropped from the index (never
87    /// physically truncated from the file -- like a corrupt-tail's unindexed bytes, they simply
88    /// become unreachable) once the bound is exceeded. This is an explicit, opt-in trade-off: a
89    /// bounded store can no longer serve a resend request for a sequence number old enough to have
90    /// aged out of the index, so only enable it when the deployment's own reset/rollover cadence
91    /// (or downstream reconciliation) makes that acceptable -- unlike log rotation, this changes
92    /// resend/recovery semantics, not just disk footprint.
93    pub max_body_records: usize,
94}
95
96impl Default for FileStoreOptions {
97    fn default() -> Self {
98        Self {
99            sync: true,
100            max_cached_msgs: 0,
101            max_body_records: 0,
102        }
103    }
104}
105
106/// The on-disk body log shared by both file-backed stores: an append-only record log, plus an
107/// in-memory offset index (seq → (offset, len)) so `get()` can seek directly rather than
108/// rescanning the file.
109/// seq → (offset of record start, message length).
110type BodyIndex = BTreeMap<u64, (u64, u32)>;
111
112struct BodyLog {
113    path: PathBuf,
114    index: Mutex<BodyIndex>,
115    sync: bool,
116    /// `FileStoreMaxBodyRecords` (NEW-108, audit 006); `0` = unbounded. See
117    /// [`FileStoreOptions::max_body_records`]'s doc for the resend/recovery trade-off.
118    max_records: usize,
119}
120
121impl BodyLog {
122    fn open_with_max_records(
123        path: PathBuf,
124        sync: bool,
125        max_records: usize,
126    ) -> Result<(Self, bool), StoreError> {
127        let (mut index, corrupted, clean_len) = load_index(&path)?;
128        if corrupted {
129            let f = OpenOptions::new()
130                .create(true)
131                .write(true)
132                .truncate(false)
133                .open(&path)
134                .map_err(io_err)?;
135            f.set_len(clean_len).map_err(io_err)?;
136            if sync {
137                f.sync_data().map_err(io_err)?;
138            }
139        }
140        if max_records > 0 {
141            evict_oldest(&mut index, max_records);
142        }
143        Ok((
144            Self {
145                path,
146                index: Mutex::new(index),
147                sync,
148                max_records,
149            },
150            corrupted,
151        ))
152    }
153
154    fn lock(&self) -> Result<std::sync::MutexGuard<'_, BodyIndex>, StoreError> {
155        self.index.lock().map_err(|_| poisoned())
156    }
157
158    fn append(&self, seq: u64, message: &[u8]) -> Result<(), StoreError> {
159        // T088/BUG-20 (feature 006): the offset-determining `fs::metadata` read must happen
160        // inside the same critical section as the write and index-insert below, not before it —
161        // otherwise two concurrent `append` callers could both read the file's length before
162        // either has written, racing to record the *same* (now-wrong-for-one-of-them) offset in
163        // the index. Holding `self.lock()` across the whole body (all synchronous std calls, no
164        // `.await` inside) serializes appends entirely, closing that window.
165        let mut guard = self.lock()?;
166        let offset = fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
167        let mut f = OpenOptions::new()
168            .create(true)
169            .append(true)
170            .open(&self.path)
171            .map_err(io_err)?;
172        f.write_all(&seq.to_le_bytes()).map_err(io_err)?;
173        let len = u32::try_from(message.len())
174            .map_err(|_| StoreError::Backend("message too large".into()))?;
175        f.write_all(&len.to_le_bytes()).map_err(io_err)?;
176        f.write_all(message).map_err(io_err)?;
177        if self.sync {
178            f.sync_data().map_err(io_err)?;
179        }
180        guard.insert(seq, (offset, len));
181        // NEW-108 (audit 006): drop the oldest indexed record(s) once the bound is exceeded --
182        // the physical bytes stay on disk (like an unindexed corrupt-tail record, they're simply
183        // never read again), so this never rewrites/truncates the file on the hot append path.
184        if self.max_records > 0 {
185            evict_oldest(&mut guard, self.max_records);
186        }
187        Ok(())
188    }
189
190    /// Read one message's body at an already-known `(offset, len)`, from an already-open handle
191    /// (T174/T175/T176, feature 009, NEW-41) — shared by [`Self::read`] (opens its own handle) and
192    /// [`Self::range`] (opens one handle for the whole range, rather than one per message).
193    fn read_body_at(f: &mut File, offset: u64, len: u32) -> Result<Vec<u8>, StoreError> {
194        f.seek(SeekFrom::Start(offset + 12)).map_err(io_err)?;
195        let mut buf = vec![0u8; len as usize];
196        f.read_exact(&mut buf).map_err(io_err)?;
197        Ok(buf)
198    }
199
200    fn read(&self, seq: u64) -> Result<Option<Vec<u8>>, StoreError> {
201        let entry = self.lock()?.get(&seq).copied();
202        let Some((offset, len)) = entry else {
203            return Ok(None);
204        };
205        let mut f = File::open(&self.path).map_err(io_err)?;
206        Self::read_body_at(&mut f, offset, len).map(Some)
207    }
208
209    /// The sequence numbers indexed within `[begin, end]`, in order (no disk read).
210    fn seqs_in_range(&self, begin: u64, end: u64) -> Result<Vec<u64>, StoreError> {
211        Ok(self.lock()?.range(begin..=end).map(|(s, _)| *s).collect())
212    }
213
214    /// Whether `seq` is already indexed (NEW-137, audit 006).
215    fn contains(&self, seq: u64) -> Result<bool, StoreError> {
216        Ok(self.lock()?.contains_key(&seq))
217    }
218
219    /// T174/T175/T176 (feature 009, NEW-41): a multi-message resend previously opened (and
220    /// `seek`ed) a fresh file handle per message via `read()` — one syscall-heavy open per
221    /// message in what's often a tight loop over a whole resend range. Now resolves every
222    /// `(offset, len)` entry up front (one lock acquisition), then reuses a single open handle
223    /// across the whole range.
224    fn range(&self, begin: u64, end: u64) -> Result<Vec<(u64, Vec<u8>)>, StoreError> {
225        let seqs = self.seqs_in_range(begin, end)?;
226        if seqs.is_empty() {
227            return Ok(Vec::new());
228        }
229        let entries: Vec<(u64, (u64, u32))> = {
230            let guard = self.lock()?;
231            seqs.into_iter()
232                .filter_map(|s| guard.get(&s).copied().map(|e| (s, e)))
233                .collect()
234        };
235        let mut f = File::open(&self.path).map_err(io_err)?;
236        let mut out = Vec::with_capacity(entries.len());
237        for (seq, (offset, len)) in entries {
238            out.push((seq, Self::read_body_at(&mut f, offset, len)?));
239        }
240        Ok(out)
241    }
242
243    fn reset(&self) -> Result<(), StoreError> {
244        // NEW-76/FR-045 (feature 009): use the same lock ordering as append(). Truncating before
245        // acquiring the index lock could race with an append that had already calculated its
246        // offset, leaving the body and index inconsistent.
247        let mut guard = self.lock()?;
248        // BUG-15/FR-016 (feature 006): sync when `FileStoreSync=Y`, matching `append()`'s
249        // existing discipline — previously this never synced regardless of the option, so a
250        // crash immediately after `fs::write` truncated the body file, but before the OS
251        // actually flushed that truncation to disk, could leave a stale pre-reset body on disk
252        // after restart even though the in-memory index (and, if this ran second per the
253        // ordering below, the seqnums file) already reflect the reset.
254        let f = OpenOptions::new()
255            .write(true)
256            .truncate(true)
257            .create(true)
258            .open(&self.path)
259            .map_err(io_err)?;
260        if self.sync {
261            f.sync_data().map_err(io_err)?;
262        }
263        guard.clear();
264        Ok(())
265    }
266}
267
268/// The sequence-number half of a file-backed store: `sender`/`target` next-seq counters,
269/// persisted to a small text file.
270/// Sender/target sequence-number persistence (BUG-30/BUG-31/BUG-99, FR-001/FR-001a, feature 007):
271/// two independent files (`senderseqnums`/`targetseqnums`, matching QuickFIX/J's layout), each
272/// written atomically (write-to-temp-then-rename, never truncate-in-place) so a crash mid-write
273/// always leaves either the prior or the new value recoverable, never neither. A present-but-
274/// unparseable file is a typed `StoreError`, distinct from a legitimately-absent one (which
275/// defaults to sequence 1). `reset()` deletes and recreates both files wholesale, matching
276/// QuickFIX/J's `closeAndDeleteFiles()`-then-reinitialize semantics.
277struct SeqFile {
278    sender_path: PathBuf,
279    target_path: PathBuf,
280    state: Mutex<(u64, u64)>,
281    sync: bool,
282}
283
284impl SeqFile {
285    /// `dir` is the store directory. Auto-migrates from a legacy combined `seqnums` file (one
286    /// sender/target pair) if present and either new-format file is still missing — migration is
287    /// itself per-file idempotent/crash-safe: each of `senderseqnums`/`targetseqnums` is only
288    /// written from the legacy source if it doesn't already exist, so an interrupted migration
289    /// simply resumes correctly on the next open. The legacy file is never deleted.
290    fn open(dir: &Path, sync: bool) -> Result<Self, StoreError> {
291        let sender_path = dir.join("senderseqnums");
292        let target_path = dir.join("targetseqnums");
293        let legacy_path = dir.join("seqnums");
294        if legacy_path.exists() && (!sender_path.exists() || !target_path.exists()) {
295            let (legacy_sender, legacy_target) = load_legacy_seqnums(&legacy_path)?;
296            if !sender_path.exists() {
297                write_seq_value(&sender_path, legacy_sender, sync)?;
298            }
299            if !target_path.exists() {
300                write_seq_value(&target_path, legacy_target, sync)?;
301            }
302        }
303        let sender = load_seq_value(&sender_path)?;
304        let target = load_seq_value(&target_path)?;
305        Ok(Self {
306            sender_path,
307            target_path,
308            state: Mutex::new((sender, target)),
309            sync,
310        })
311    }
312
313    fn lock(&self) -> Result<std::sync::MutexGuard<'_, (u64, u64)>, StoreError> {
314        self.state.lock().map_err(|_| poisoned())
315    }
316
317    fn get(&self) -> Result<(u64, u64), StoreError> {
318        Ok(*self.lock()?)
319    }
320
321    fn set_sender(&self, seq: u64) -> Result<(), StoreError> {
322        write_seq_value(&self.sender_path, seq, self.sync)?;
323        self.lock()?.0 = seq;
324        Ok(())
325    }
326
327    fn set_target(&self, seq: u64) -> Result<(), StoreError> {
328        write_seq_value(&self.target_path, seq, self.sync)?;
329        self.lock()?.1 = seq;
330        Ok(())
331    }
332
333    fn reset(&self) -> Result<(), StoreError> {
334        *self.lock()? = (1, 1);
335        let _ = fs::remove_file(&self.sender_path);
336        let _ = fs::remove_file(&self.target_path);
337        write_seq_value(&self.sender_path, 1, self.sync)?;
338        write_seq_value(&self.target_path, 1, self.sync)?;
339        Ok(())
340    }
341}
342
343/// Atomically write a single sequence value to `path`: write to a sibling `.tmp` file, fsync it
344/// (if `sync`), then rename over `path` — the rename is the only step that can be observed
345/// mid-flight, and a rename is atomic on the same filesystem, so a crash never leaves `path`
346/// truncated/empty (BUG-30).
347fn write_seq_value(path: &Path, value: u64, sync: bool) -> Result<(), StoreError> {
348    let tmp_path = path.with_file_name(format!(
349        "{}.tmp",
350        path.file_name().and_then(|n| n.to_str()).unwrap_or("seq")
351    ));
352    {
353        let mut f = OpenOptions::new()
354            .create(true)
355            .write(true)
356            .truncate(true)
357            .open(&tmp_path)
358            .map_err(io_err)?;
359        writeln!(f, "{value}").map_err(io_err)?;
360        if sync {
361            f.sync_data().map_err(io_err)?;
362        }
363    }
364    fs::rename(&tmp_path, path).map_err(io_err)?;
365    Ok(())
366}
367
368/// Read a single sequence-number file: absent means a fresh store (sequence 1, not an error);
369/// present-but-unparseable is a typed error (BUG-31) — the two cases were previously
370/// indistinguishable (`unwrap_or(1)` collapsed both to 1).
371fn load_seq_value(path: &Path) -> Result<u64, StoreError> {
372    match fs::read_to_string(path) {
373        Ok(s) => s
374            .trim()
375            .parse()
376            .map_err(|_| StoreError::Backend(format!("corrupted sequence file {path:?}: {s:?}"))),
377        Err(e) if e.kind() == ErrorKind::NotFound => Ok(1),
378        Err(e) => Err(io_err(e)),
379    }
380}
381
382/// Parse the legacy combined `seqnums` file (sender on line 1, target on line 2) for migration
383/// (FR-001a). Unlike the pre-007 `load_seqnums` this replaced, a present-but-unparseable legacy
384/// file is a typed error rather than silently defaulting — the same BUG-31 fix applies to the
385/// migration path.
386fn load_legacy_seqnums(path: &Path) -> Result<(u64, u64), StoreError> {
387    let corrupt =
388        |s: &str| StoreError::Backend(format!("corrupted legacy seqnums file {path:?}: {s:?}"));
389    let s = fs::read_to_string(path).map_err(io_err)?;
390    let mut lines = s.lines();
391    let sender: u64 = lines
392        .next()
393        .ok_or_else(|| corrupt(&s))?
394        .trim()
395        .parse()
396        .map_err(|_| corrupt(&s))?;
397    let target: u64 = lines
398        .next()
399        .ok_or_else(|| corrupt(&s))?
400        .trim()
401        .parse()
402        .map_err(|_| corrupt(&s))?;
403    Ok((sender, target))
404}
405
406/// A durable, file-backed message store with no in-memory message-body cache (bodies are read
407/// from disk on every `get()`).
408pub struct FileStore {
409    seq: SeqFile,
410    body: BodyLog,
411    corrupted: AtomicBool,
412    creation_time_path: PathBuf,
413    creation_time: Mutex<time::OffsetDateTime>,
414}
415
416impl FileStore {
417    /// Open (creating if needed) a file store in `dir` with default options (`FileStoreSync=Y`).
418    pub fn open(dir: &Path) -> Result<Self, StoreError> {
419        Self::open_with_options(dir, FileStoreOptions::default())
420    }
421
422    /// Open (creating if needed) a file store in `dir` with explicit options (FR-025).
423    pub fn open_with_options(dir: &Path, options: FileStoreOptions) -> Result<Self, StoreError> {
424        fs::create_dir_all(dir).map_err(io_err)?;
425        let seq = SeqFile::open(dir, options.sync)?;
426        let (body, corrupted) = BodyLog::open_with_max_records(
427            dir.join("body"),
428            options.sync,
429            options.max_body_records,
430        )?;
431        let creation_time_path = dir.join("session");
432        let creation_time = creation_time_file(&creation_time_path)?;
433        Ok(Self {
434            seq,
435            body,
436            corrupted: AtomicBool::new(corrupted),
437            creation_time_path,
438            creation_time: Mutex::new(creation_time),
439        })
440    }
441
442    /// Whether a corrupt trailing record was detected and recovered on open.
443    pub fn was_corrupted(&self) -> bool {
444        self.corrupted.load(Ordering::SeqCst)
445    }
446}
447
448#[async_trait]
449impl MessageStore for FileStore {
450    async fn next_sender_seq(&self) -> Result<u64, StoreError> {
451        Ok(self.seq.get()?.0)
452    }
453    async fn next_target_seq(&self) -> Result<u64, StoreError> {
454        Ok(self.seq.get()?.1)
455    }
456    async fn set_next_sender_seq(&self, seq: u64) -> Result<(), StoreError> {
457        self.seq.set_sender(seq)
458    }
459    async fn set_next_target_seq(&self, seq: u64) -> Result<(), StoreError> {
460        self.seq.set_target(seq)
461    }
462    async fn save(&self, seq: u64, message: &[u8]) -> Result<(), StoreError> {
463        self.body.append(seq, message)
464    }
465    async fn get(&self, begin: u64, end: u64) -> Result<Vec<(u64, Vec<u8>)>, StoreError> {
466        self.body.range(begin, end)
467    }
468    async fn reset(&self) -> Result<(), StoreError> {
469        // NEW-136 (audit 006): truncate `body` *before* resetting `seq` -- the reverse of
470        // `save_and_advance_sender`'s ordering, and deliberately so: these two operations protect
471        // against different failure modes. B17/FR-016 (feature 006) originally reset `seq` first
472        // reasoning that a crash would leave harmless, unindexed pre-reset body bytes; but
473        // `load_index()` on the next `open()` unconditionally replays every complete record in
474        // `body` from scratch, so a crash between `seq.reset()` succeeding and `body.reset()`
475        // truncating leaves `next_sender_seq()`/`next_target_seq()` already reporting `1` while
476        // `get()` still returns the stale pre-reset messages a fresh restart re-indexed from the
477        // not-yet-truncated body file -- silently wrong data, not a safely-recoverable gap.
478        // Truncating `body` first instead means a crash before `seq.reset()` completes leaves the
479        // store looking not-yet-reset (old sequence numbers) over an already-empty body, which is
480        // just the ordinary "missing stored message" case `build_resend`'s gap-fill path already
481        // handles safely.
482        self.body.reset()?;
483        self.seq.reset()?;
484        self.corrupted.store(false, Ordering::SeqCst);
485        let now = reset_creation_time_file(&self.creation_time_path)?;
486        *self.creation_time.lock().map_err(|_| poisoned())? = now;
487        Ok(())
488    }
489    fn was_corrupted(&self) -> bool {
490        self.was_corrupted()
491    }
492    async fn creation_time(&self) -> Result<Option<time::OffsetDateTime>, StoreError> {
493        Ok(Some(*self.creation_time.lock().map_err(|_| poisoned())?))
494    }
495    async fn save_and_advance_sender(&self, seq: u64, message: &[u8]) -> Result<(), StoreError> {
496        // GAP-49/FR-015 (feature 006); re-affirmed under NEW-117 (audit 006): unlike the
497        // SQL/MSSQL/Redb overrides (feature 005), a two-separate-file backend has no cross-file
498        // transaction to make this fully atomic. The safest achievable ordering advances the
499        // durable sequence-number record FIRST: if a crash occurs before the body write
500        // completes, `next_out_seq()` on restart already reflects this message as sent (matching
501        // what the counterparty may already have received over the wire -- `persist_sent` in
502        // `truefix-transport` is only called *after* the wire write already succeeded), and the
503        // missing body is safely recoverable via the existing gap-fill path (`build_resend`
504        // already treats a missing stored message as a gap to fill, a sanctioned FIX recovery
505        // mechanism) -- the reverse order (body-first) would instead risk the store reporting an
506        // unadvanced sequence number for a message the peer has already received, so the next
507        // real send after restart would reuse that same MsgSeqNum for different content, which
508        // no gap-fill can repair and is a strictly worse failure than a recoverable missing body.
509        self.seq.set_sender(seq + 1)?;
510        self.body.append(seq, message)
511    }
512    async fn contains(&self, seq: u64) -> Result<bool, StoreError> {
513        self.body.contains(seq)
514    }
515}
516
517struct CacheState {
518    map: BTreeMap<u64, Vec<u8>>,
519    order: VecDeque<u64>,
520    max: usize,
521}
522
523impl CacheState {
524    fn new(max: usize) -> Self {
525        Self {
526            map: BTreeMap::new(),
527            order: VecDeque::new(),
528            max,
529        }
530    }
531
532    fn insert(&mut self, seq: u64, bytes: Vec<u8>) {
533        if self.map.insert(seq, bytes).is_none() {
534            self.order.push_back(seq);
535        }
536        if self.max > 0 {
537            while self.map.len() > self.max {
538                match self.order.pop_front() {
539                    Some(oldest) => {
540                        self.map.remove(&oldest);
541                    }
542                    None => break,
543                }
544            }
545        }
546    }
547
548    fn clear(&mut self) {
549        self.map.clear();
550        self.order.clear();
551    }
552}
553
554/// A file-backed store that also maintains a bounded in-memory cache of message bodies
555/// (`FileStoreMaxCachedMsgs`; `0` = unbounded), avoiding a disk read for cached resends.
556pub struct CachedFileStore {
557    seq: SeqFile,
558    body: BodyLog,
559    corrupted: AtomicBool,
560    cache: Mutex<CacheState>,
561    creation_time_path: PathBuf,
562    creation_time: Mutex<time::OffsetDateTime>,
563}
564
565impl CachedFileStore {
566    /// Open (creating if needed) a cached file store in `dir` with default options
567    /// (`FileStoreSync=Y`, unbounded cache — matching the original pre-FR-025 behavior).
568    pub fn open(dir: &Path) -> Result<Self, StoreError> {
569        Self::open_with_options(dir, FileStoreOptions::default())
570    }
571
572    /// Open (creating if needed) a cached file store in `dir` with explicit options (FR-025). On
573    /// open, the cache is warmed from the existing body log (most-recent messages first, up to
574    /// `max_cached_msgs`).
575    pub fn open_with_options(dir: &Path, options: FileStoreOptions) -> Result<Self, StoreError> {
576        fs::create_dir_all(dir).map_err(io_err)?;
577        let seq = SeqFile::open(dir, options.sync)?;
578        let (body, corrupted) = BodyLog::open_with_max_records(
579            dir.join("body"),
580            options.sync,
581            options.max_body_records,
582        )?;
583        let mut cache = CacheState::new(options.max_cached_msgs);
584        // BUG-81/FR-031 (feature 007): warm the cache by reading only the (at most)
585        // `max_cached_msgs` most-recent bodies, not every body ever stored -- previously
586        // `body.all()` read every message body into memory up front, then relied on `cache.insert`
587        // evicting the extras one by one, so peak memory during open was the *entire* message
588        // history rather than the bound this cache is supposed to enforce. `seqs_in_range` is
589        // index-only (no body reads), so slicing to the tail before reading anything is free.
590        let all_seqs = body.seqs_in_range(0, u64::MAX)?;
591        let warm_from = if options.max_cached_msgs > 0 {
592            all_seqs.len().saturating_sub(options.max_cached_msgs)
593        } else {
594            0 // unbounded cache: warm the whole history, matching the original semantics
595        };
596        for seq_no in all_seqs.get(warm_from..).unwrap_or_default() {
597            if let Some(bytes) = body.read(*seq_no)? {
598                cache.insert(*seq_no, bytes);
599            }
600        }
601        let creation_time_path = dir.join("session");
602        let creation_time = creation_time_file(&creation_time_path)?;
603        Ok(Self {
604            seq,
605            body,
606            corrupted: AtomicBool::new(corrupted),
607            cache: Mutex::new(cache),
608            creation_time_path,
609            creation_time: Mutex::new(creation_time),
610        })
611    }
612
613    /// Whether a corrupt trailing record was detected and recovered on open.
614    pub fn was_corrupted(&self) -> bool {
615        self.corrupted.load(Ordering::SeqCst)
616    }
617
618    /// The number of message bodies currently held in the in-memory cache.
619    pub fn cached_len(&self) -> Result<usize, StoreError> {
620        Ok(self.cache.lock().map_err(|_| poisoned())?.map.len())
621    }
622}
623
624#[async_trait]
625impl MessageStore for CachedFileStore {
626    async fn next_sender_seq(&self) -> Result<u64, StoreError> {
627        Ok(self.seq.get()?.0)
628    }
629    async fn next_target_seq(&self) -> Result<u64, StoreError> {
630        Ok(self.seq.get()?.1)
631    }
632    async fn set_next_sender_seq(&self, seq: u64) -> Result<(), StoreError> {
633        self.seq.set_sender(seq)
634    }
635    async fn set_next_target_seq(&self, seq: u64) -> Result<(), StoreError> {
636        self.seq.set_target(seq)
637    }
638    async fn save(&self, seq: u64, message: &[u8]) -> Result<(), StoreError> {
639        self.body.append(seq, message)?;
640        self.cache
641            .lock()
642            .map_err(|_| poisoned())?
643            .insert(seq, message.to_vec());
644        Ok(())
645    }
646    async fn get(&self, begin: u64, end: u64) -> Result<Vec<(u64, Vec<u8>)>, StoreError> {
647        let seqs = self.body.seqs_in_range(begin, end)?;
648        let mut out = Vec::with_capacity(seqs.len());
649        for seq_no in seqs {
650            let cached = self
651                .cache
652                .lock()
653                .map_err(|_| poisoned())?
654                .map
655                .get(&seq_no)
656                .cloned();
657            match cached {
658                Some(bytes) => out.push((seq_no, bytes)),
659                None => {
660                    if let Some(bytes) = self.body.read(seq_no)? {
661                        self.cache
662                            .lock()
663                            .map_err(|_| poisoned())?
664                            .insert(seq_no, bytes.clone());
665                        out.push((seq_no, bytes));
666                    }
667                }
668            }
669        }
670        Ok(out)
671    }
672    async fn reset(&self) -> Result<(), StoreError> {
673        // NEW-136 (audit 006): see FileStore's identical override for the ordering rationale
674        // (body-truncate-first, avoiding stale post-reset replay on a crash mid-reset).
675        self.body.reset()?;
676        self.seq.reset()?;
677        self.corrupted.store(false, Ordering::SeqCst);
678        self.cache.lock().map_err(|_| poisoned())?.clear();
679        let now = reset_creation_time_file(&self.creation_time_path)?;
680        *self.creation_time.lock().map_err(|_| poisoned())? = now;
681        Ok(())
682    }
683    fn was_corrupted(&self) -> bool {
684        self.was_corrupted()
685    }
686    async fn creation_time(&self) -> Result<Option<time::OffsetDateTime>, StoreError> {
687        Ok(Some(*self.creation_time.lock().map_err(|_| poisoned())?))
688    }
689    async fn save_and_advance_sender(&self, seq: u64, message: &[u8]) -> Result<(), StoreError> {
690        // GAP-49/FR-015 (feature 006): see FileStore's identical override for the ordering
691        // rationale (sequence-number-first, safest achievable ordering for a non-transactional
692        // two-file backend).
693        self.seq.set_sender(seq + 1)?;
694        self.body.append(seq, message)?;
695        self.cache
696            .lock()
697            .map_err(|_| poisoned())?
698            .insert(seq, message.to_vec());
699        Ok(())
700    }
701    async fn contains(&self, seq: u64) -> Result<bool, StoreError> {
702        self.body.contains(seq)
703    }
704}
705
706/// Replays record headers (not bodies) into an offset index, seeking past each record's body
707/// bytes without ever reading them into memory (BUG-45/FR-031, feature 007) -- previously
708/// `fs::read(path)` read the *entire* body-log file (every message ever stored) into one `Vec`
709/// just to walk its 12-byte record headers, allocating memory proportional to the whole message
710/// history rather than to the (tiny, fixed-size-per-record) index being rebuilt. Returns the
711/// index and whether a corrupt trailing record was found.
712fn load_index(path: &Path) -> Result<(BodyIndex, bool, u64), StoreError> {
713    let mut f = match File::open(path) {
714        Ok(f) => f,
715        Err(e) if e.kind() == ErrorKind::NotFound => return Ok((BTreeMap::new(), false, 0)),
716        Err(e) => return Err(io_err(e)),
717    };
718    let file_len = f.metadata().map_err(io_err)?.len();
719    let mut index = BTreeMap::new();
720    let mut pos: u64 = 0;
721    loop {
722        if pos == file_len {
723            return Ok((index, false, pos));
724        }
725        if file_len - pos < 12 {
726            return Ok((index, true, pos)); // trailing bytes too short for a full header
727        }
728        // Read the two header fields into their own exactly-sized buffers, rather than one
729        // 12-byte buffer sliced afterward — this crate forbids `unwrap`/`expect`/indexing
730        // (Constitution Principle I), and a fixed-size array read via `read_exact` needs no
731        // fallible slice-to-array conversion at all.
732        let mut seq_bytes = [0u8; 8];
733        let mut len_bytes = [0u8; 4];
734        f.read_exact(&mut seq_bytes).map_err(io_err)?;
735        f.read_exact(&mut len_bytes).map_err(io_err)?;
736        let seq = u64::from_le_bytes(seq_bytes);
737        let len = u32::from_le_bytes(len_bytes);
738        let body_start = pos + 12;
739        let Some(body_end) = body_start.checked_add(u64::from(len)) else {
740            return Ok((index, true, pos));
741        };
742        if body_end > file_len {
743            return Ok((index, true, pos)); // declared body length runs past actual EOF
744        }
745        index.insert(seq, (pos, len));
746        f.seek(SeekFrom::Start(body_end)).map_err(io_err)?;
747        pos = body_end;
748    }
749}