chronix_engine/durable.rs
1//! The files the durable paths write to, behind a trait so a test can make
2//! them fail.
3//!
4//! **A durability path is defined by what it does when the write fails**, and
5//! a real filesystem will not fail on request — `ENOSPC` is the failure an
6//! embedded deployment on flash storage actually meets. The write-ahead log
7//! has had this seam since the pass that found a full disk poisoning it for
8//! ever; the catalog, which is the durable record of *what may be deleted*,
9//! did not, and its recovery path was argued rather than exercised. It was
10//! wrong: a short write left a fragment, the next append put a valid record
11//! after it, and replay then stopped at the fragment and **silently dropped
12//! every transition that followed** — segments the caller had been told were
13//! registered, whose files `open()`'s orphan sweep then deletes.
14//!
15//! One trait for both, and no runtime surface: a fault is worth injecting
16//! only where a verdict is checked, not behind an endpoint on a live
17//! database.
18
19use std::fs::File;
20use std::io::{self, Write};
21
22/// A file a durable path appends to.
23///
24/// `Write` plus the three file operations a rewind needs. Boxed behind a
25/// `BufWriter` where there is one, so the dynamic call happens once per
26/// buffer flush rather than once per record.
27pub trait DurableFile: Write + Send + Sync + std::fmt::Debug {
28 /// Flush the file's data to stable storage.
29 ///
30 /// # Errors
31 ///
32 /// The underlying `fsync` error. A failure here is **not** recoverable by
33 /// retrying: on a failed `fsync` the kernel may discard the dirty pages
34 /// and clear the error, so a second call can succeed while the data is
35 /// gone. The caller poisons itself instead.
36 fn sync_data(&self) -> io::Result<()>;
37
38 /// Flush the file's data and metadata to stable storage.
39 ///
40 /// # Errors
41 ///
42 /// The underlying `fsync` error.
43 fn sync_all(&self) -> io::Result<()>;
44
45 /// Truncate or extend the file to `size` bytes.
46 ///
47 /// # Errors
48 ///
49 /// The underlying `ftruncate` error.
50 fn set_len(&self, size: u64) -> io::Result<()>;
51}
52
53impl DurableFile for File {
54 fn sync_data(&self) -> io::Result<()> {
55 File::sync_data(self)
56 }
57
58 fn sync_all(&self) -> io::Result<()> {
59 File::sync_all(self)
60 }
61
62 fn set_len(&self, size: u64) -> io::Result<()> {
63 File::set_len(self, size)
64 }
65}
66
67/// A file that reports the disk full after a set number of bytes.
68///
69/// Wraps a real file, so truncation, seeking and replay behave exactly as in
70/// production and only the budget is artificial. The budget is shared, so a
71/// test can refill it — which is the case that matters: the disk fills, a
72/// retention pass frees space, and the next append succeeds *after* the
73/// fragment the failed one left.
74#[cfg(test)]
75#[derive(Debug)]
76pub(crate) struct FullDiskFile {
77 file: File,
78 budget: std::sync::Arc<std::sync::atomic::AtomicI64>,
79}
80
81#[cfg(test)]
82impl FullDiskFile {
83 /// Wrap `file`, accepting `budget` more bytes before the disk "fills".
84 pub(crate) fn new(file: File, budget: std::sync::Arc<std::sync::atomic::AtomicI64>) -> Self {
85 Self { file, budget }
86 }
87
88 pub(crate) fn enospc() -> io::Error {
89 io::Error::new(io::ErrorKind::StorageFull, "No space left on device")
90 }
91}
92
93#[cfg(test)]
94impl Write for FullDiskFile {
95 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
96 use std::sync::atomic::Ordering;
97 let remaining = self.budget.load(Ordering::SeqCst);
98 if remaining <= 0 {
99 return Err(Self::enospc());
100 }
101 // A short write is what a real filesystem does at the boundary, and
102 // it is the case that leaves a partial record behind — so this
103 // produces it rather than failing cleanly on the whole buffer.
104 let n = buf
105 .len()
106 .min(usize::try_from(remaining).unwrap_or(usize::MAX));
107 let written = self.file.write(&buf[..n])?;
108 self.budget
109 .fetch_sub(i64::try_from(written).unwrap_or(i64::MAX), Ordering::SeqCst);
110 Ok(written)
111 }
112
113 fn flush(&mut self) -> io::Result<()> {
114 self.file.flush()
115 }
116}
117
118#[cfg(test)]
119impl std::io::Seek for FullDiskFile {
120 fn seek(&mut self, pos: io::SeekFrom) -> io::Result<u64> {
121 self.file.seek(pos)
122 }
123}
124
125#[cfg(test)]
126impl DurableFile for FullDiskFile {
127 fn sync_data(&self) -> io::Result<()> {
128 // A full disk does not stop `fsync` from working on what is already
129 // written; keeping this honest is what lets an ENOSPC test isolate
130 // the *write* path from the fsync path, which fail differently and
131 // for different reasons.
132 self.file.sync_data()
133 }
134
135 fn sync_all(&self) -> io::Result<()> {
136 self.file.sync_all()
137 }
138
139 fn set_len(&self, size: u64) -> io::Result<()> {
140 self.file.set_len(size)
141 }
142}