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}