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>;