Skip to main content

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}