Skip to main content

nodedb_wal/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2
3/// Errors produced by the WAL subsystem.
4#[derive(Debug, thiserror::Error)]
5pub enum WalError {
6    /// I/O error from the underlying file operations.
7    #[error("WAL I/O error: {0}")]
8    Io(#[from] std::io::Error),
9
10    /// CRC32C checksum mismatch during read/replay.
11    #[error("WAL checksum mismatch at LSN {lsn}: expected {expected:#010x}, got {actual:#010x}")]
12    ChecksumMismatch {
13        lsn: u64,
14        expected: u32,
15        actual: u32,
16    },
17
18    /// Checkpoint frame is corrupt: bad version, truncated payload, or CRC mismatch.
19    #[error("checkpoint frame corrupt at {path}: {detail}")]
20    CheckpointCorrupt { path: String, detail: String },
21
22    /// Record header has an invalid magic number — file is corrupted or not a WAL.
23    #[error("invalid WAL magic at offset {offset}: expected {expected:#010x}, got {actual:#010x}")]
24    InvalidMagic {
25        offset: u64,
26        expected: u32,
27        actual: u32,
28    },
29
30    /// WAL format version is not supported by this binary.
31    #[error("unsupported WAL format version {version} (supported: {supported})")]
32    UnsupportedVersion { version: u16, supported: u16 },
33
34    /// A segment holds records in a WAL format version this build cannot read.
35    ///
36    /// The same finding as [`WalError::UnsupportedVersion`], reported by the
37    /// callers that know which file carries it. A reader sees one header at a
38    /// time and holds no path, and a version alone does not tell an operator
39    /// which segment to act on.
40    #[error(
41        "WAL segment '{path}' holds records in WAL format version {version}; this build reads \
42         version {supported}"
43    )]
44    SegmentFormatVersion {
45        path: String,
46        version: u16,
47        supported: u16,
48    },
49
50    /// Unknown required record type encountered during replay.
51    /// Optional unknown record types are safely skipped.
52    #[error("unknown required record type {record_type} at LSN {lsn}")]
53    UnknownRequiredRecordType { record_type: u32, lsn: u64 },
54
55    /// Write payload exceeds maximum record size.
56    #[error("payload too large: {size} bytes (max: {max})")]
57    PayloadTooLarge { size: usize, max: usize },
58
59    /// Attempted to write to a WAL that has been closed or is in error state.
60    #[error("WAL is sealed and no longer accepting writes")]
61    Sealed,
62
63    /// Alignment violation — O_DIRECT requires aligned buffers and offsets.
64    #[error("alignment violation: {context} (required: {required}, actual: {actual})")]
65    AlignmentViolation {
66        context: &'static str,
67        required: usize,
68        actual: usize,
69    },
70
71    /// A mutex was poisoned (another thread panicked while holding the lock).
72    #[error("WAL lock poisoned: {context}")]
73    LockPoisoned { context: &'static str },
74
75    /// Encryption or decryption failed.
76    #[error("WAL encryption error: {detail}")]
77    EncryptionError { detail: String },
78
79    /// A record still carries `ENCRYPTED_FLAG` at a point where plaintext is
80    /// required, and no key ring is available to turn it back into plaintext.
81    ///
82    /// Passing the ciphertext through would hand every downstream decoder
83    /// bytes it cannot parse: the lucky ones fail with a confusing framing
84    /// error, the unlucky ones decode garbage into engine state. Skipping the
85    /// record would silently drop a committed write. Neither is recoverable at
86    /// this layer, so the only honest outcome is to refuse to replay.
87    #[error("WAL record at LSN {lsn} is encrypted but no decryption key is available ({context})")]
88    EncryptedRecordWithoutKey { lsn: u64, context: &'static str },
89
90    /// `DoubleWriteBuffer::open` was called with `DwbMode::Off`. Callers
91    /// that want the DWB disabled must not call `open` at all.
92    #[error("DoubleWriteBuffer::open called with DwbMode::Off")]
93    DwbOffNotOpenable,
94
95    /// Record payload failed structural validation (truncation, bad length
96    /// prefix, invalid UTF-8, etc.). Distinct from [`WalError::ChecksumMismatch`]
97    /// — the bytes passed CRC but the payload's own framing is wrong.
98    #[error("corrupt WAL record at LSN {lsn}: {detail}")]
99    CorruptRecord { lsn: u64, detail: String },
100
101    /// Record payload is structurally invalid at parse time, before the
102    /// surrounding LSN context is known (e.g., anchor payload decoded from
103    /// a byte slice during unit-level use).
104    #[error("invalid WAL payload: {detail}")]
105    InvalidPayload { detail: String },
106
107    /// The filesystem holding the WAL cannot open files with `O_DIRECT`.
108    ///
109    /// Most overlayfs configurations, many network filesystems, and tmpfs
110    /// before Linux 6.1 reject `O_DIRECT` at `open(2)` with `EINVAL`. Kept
111    /// distinct from a generic [`WalError::Io`] because the operator action
112    /// is specific — relocate the data directory or opt out of direct I/O
113    /// deliberately — and because silently continuing with buffered I/O
114    /// would downgrade the WAL's durability guarantee without anyone being
115    /// told.
116    #[error("filesystem holding {path} does not support O_DIRECT")]
117    DirectIoUnsupported { path: String },
118
119    /// The filesystem ran out of space while appending to the WAL (ENOSPC).
120    ///
121    /// Distinct from a generic [`WalError::Io`] so callers can stop
122    /// acknowledging writes and surface a specific operator action instead of
123    /// retrying a write that cannot succeed.
124    #[error("WAL write failed: no space left on device ({context})")]
125    OutOfSpace { context: &'static str },
126
127    /// An fsync of the WAL segment failed, so the writer is poisoned and
128    /// permanently refuses further work.
129    ///
130    /// A failed fsync is terminal, not transient. Linux reports a writeback
131    /// error exactly once (`errseq_t`) and drops the dirty pages that failed,
132    /// so the bytes the writer already handed to the page cache are gone and
133    /// no retry can put them back. Continuing to serve would let a later
134    /// `sync` see an empty buffer, report success, and acknowledge records
135    /// that no longer exist anywhere. The only correct move at this layer is
136    /// to stop: the segment must be re-opened from what is actually on disk.
137    #[error("WAL durability lost, writer poisoned: {detail}")]
138    DurabilityLost { detail: String },
139
140    /// A WAL segment is damaged in the middle: a valid record was found
141    /// *after* the damaged region, so it cannot be the unfsynced tail of the
142    /// last write.
143    ///
144    /// Stopping at the damage point and calling everything before it "the
145    /// committed prefix" would silently discard the committed records that
146    /// follow, so recovery refuses to proceed instead.
147    #[error(
148        "mid-file WAL corruption in {path} at offset {offset}: a valid record (LSN {resync_lsn}) \
149         follows the damaged region at offset {resync_offset}"
150    )]
151    MidFileCorruption {
152        path: String,
153        offset: u64,
154        resync_offset: u64,
155        resync_lsn: u64,
156    },
157
158    /// Two surviving WAL segments are not contiguous: the later segment's
159    /// declared first LSN is above the LSN that follows the end of the earlier
160    /// one, so a whole segment is missing from the middle of the log.
161    ///
162    /// Concatenating the two would hand recovery a record stream with a silent
163    /// hole in it, so replay refuses instead. A missing *prefix* is legal
164    /// (checkpoint truncation deletes whole segments below the checkpoint) and
165    /// does not produce this error.
166    #[error(
167        "WAL segment LSN gap: {path} starts at LSN {found_lsn}, but {previous_path} ends at \
168         LSN {previous_last_lsn} — LSNs {expected_lsn}..{found_lsn} are missing"
169    )]
170    SegmentLsnGap {
171        path: String,
172        previous_path: String,
173        previous_last_lsn: u64,
174        expected_lsn: u64,
175        found_lsn: u64,
176    },
177
178    /// The last segment starts at or below an LSN an earlier segment already
179    /// holds. An interrupted roll created it while the earlier segment stayed
180    /// active. Resuming it would reissue LSNs, so the WAL refuses to open.
181    #[error(
182        "WAL segment {path} starts at LSN {first_lsn}, but {previous_path} already holds \
183         records through LSN {previous_last_lsn}; resuming would reissue those LSNs. \
184         {path} was created by an interrupted segment roll: check that it holds no \
185         records, remove it, and restart"
186    )]
187    SegmentOverlapsPrevious {
188        path: String,
189        first_lsn: u64,
190        previous_path: String,
191        previous_last_lsn: u64,
192    },
193
194    /// A segment roll failed, and removing the segment file it created also
195    /// failed. The next open refuses that file with `SegmentOverlapsPrevious`.
196    #[error(
197        "WAL segment roll failed: {roll}; removing the unused segment {path} also failed: \
198         {cleanup}"
199    )]
200    RollCleanupFailed {
201        path: String,
202        roll: Box<WalError>,
203        cleanup: Box<WalError>,
204    },
205
206    /// A replay was asked for the suffix starting at `from_lsn`, but the WAL
207    /// no longer retains it: checkpoint truncation has already deleted every
208    /// segment below `retained_floor_lsn`.
209    ///
210    /// Filtering the surviving records by `lsn >= from_lsn` would hand the
211    /// caller a *shorter* suffix that is indistinguishable from a complete
212    /// one — no hole, no gap between surviving segments, just fewer records
213    /// than the caller's own bookkeeping says it must observe. A consumer that
214    /// treats that as complete advances its watermark past records it never
215    /// saw, and every effect keyed on them (change data capture, triggers,
216    /// streaming materialized views) is lost with nothing recorded anywhere.
217    ///
218    /// Truncation is supposed to hold below every consumer's watermark, so
219    /// this is a broken invariant rather than an expected outcome; the error
220    /// exists so a residual violation is loud instead of silent.
221    #[error(
222        "WAL replay requested from LSN {from_lsn}, but the earliest retained LSN is \
223         {retained_floor_lsn} ({earliest_segment}) — LSNs {from_lsn}..{retained_floor_lsn} were \
224         truncated and can never be replayed"
225    )]
226    ReplayBelowRetainedFloor {
227        from_lsn: u64,
228        retained_floor_lsn: u64,
229        earliest_segment: String,
230    },
231}
232
233impl WalError {
234    /// Name the segment a version gap was found in.
235    ///
236    /// A reader reports [`WalError::UnsupportedVersion`] from header validation
237    /// alone and holds no path. The callers that opened the file do, and only
238    /// they can turn the finding into something an operator can act on. Every
239    /// other error passes through unchanged.
240    #[cfg(not(target_arch = "wasm32"))]
241    pub(crate) fn with_segment_path(self, path: &std::path::Path) -> Self {
242        match self {
243            Self::UnsupportedVersion { version, supported } => Self::SegmentFormatVersion {
244                path: path.display().to_string(),
245                version,
246                supported,
247            },
248            other => other,
249        }
250    }
251}
252
253pub type Result<T> = std::result::Result<T, WalError>;