wal_db/error.rs
1//! The crate error type.
2//!
3//! Every fallible operation in `wal-db` returns [`Result<T>`], whose error is
4//! [`WalError`]. The type integrates with the portfolio's `error-forge`
5//! framework — it implements [`error_forge::ForgeError`], so callers get the
6//! stable `kind` / `is_fatal` metadata other crates rely on — while still
7//! exposing the underlying [`std::io::Error`] through [`std::error::Error::source`]
8//! for code that needs to inspect the OS error kind directly.
9
10use std::{fmt, io};
11
12use error_forge::ForgeError;
13
14/// A specialised [`Result`](std::result::Result) for log operations.
15///
16/// Defaults its error to [`WalError`], so most signatures read `Result<T>`.
17pub type Result<T, E = WalError> = std::result::Result<T, E>;
18
19/// Everything that can go wrong while appending to, syncing, or recovering a log.
20///
21/// The type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute):
22/// future versions may add variants without a major bump, so a `match` over it
23/// must include a wildcard arm.
24#[non_exhaustive]
25#[derive(Debug)]
26pub enum WalError {
27 /// An underlying I/O operation failed.
28 ///
29 /// `context` names the operation that was attempted (for example
30 /// `"open log file"` or `"flush to stable storage"`) so the message is
31 /// actionable without a backtrace. The original [`io::Error`] is preserved
32 /// as the [`source`](std::error::Error::source); inspect it when the OS
33 /// error kind (disk full, permission denied, interrupted) drives recovery.
34 Io {
35 /// What the log was trying to do when the I/O error occurred.
36 context: &'static str,
37 /// The underlying operating-system error.
38 source: io::Error,
39 },
40
41 /// An append was rejected because the record is larger than the configured
42 /// limit.
43 ///
44 /// Records are bounded by [`WalConfig::max_record_size`](crate::WalConfig::max_record_size)
45 /// so that a single oversized — or maliciously crafted — record cannot force
46 /// an unbounded allocation on the recovery path. The append makes no change
47 /// to the log; the caller may split the payload or raise the limit.
48 RecordTooLarge {
49 /// The length of the rejected record, in bytes.
50 len: usize,
51 /// The configured maximum record size, in bytes.
52 max: u32,
53 },
54
55 /// Recovery reached a record that is not intact.
56 ///
57 /// Either the record's checksum did not match its bytes, or its length
58 /// prefix is implausible. In an append-only log a damaged record means
59 /// everything after it is untrustworthy, so iteration surfaces this once and
60 /// then stops. `offset` is the byte position of the record where the break
61 /// was detected.
62 Corruption {
63 /// Byte offset of the record at which recovery stopped.
64 offset: u64,
65 /// A short, human-readable reason the record was rejected.
66 reason: &'static str,
67 },
68
69 /// A typed record could not be encoded or decoded.
70 ///
71 /// Produced only by the typed-record API (the `pack-io` feature):
72 /// `Wal::append_typed` when a value fails to serialise, or `Record::decode`
73 /// when a record's bytes do not deserialise into the requested type.
74 /// `detail` carries the underlying codec error's message.
75 Encoding {
76 /// The underlying serialization error, as text.
77 detail: String,
78 },
79}
80
81impl WalError {
82 /// Wrap an [`io::Error`] with the static context describing the operation.
83 pub(crate) fn io(context: &'static str, source: io::Error) -> Self {
84 WalError::Io { context, source }
85 }
86
87 /// Build a [`WalError::Corruption`] for the record at `offset`.
88 pub(crate) fn corruption(offset: u64, reason: &'static str) -> Self {
89 WalError::Corruption { offset, reason }
90 }
91
92 /// Build a [`WalError::Encoding`] from a codec error's message.
93 #[cfg(feature = "pack-io")]
94 pub(crate) fn encoding(detail: impl core::fmt::Display) -> Self {
95 WalError::Encoding {
96 detail: detail.to_string(),
97 }
98 }
99}
100
101impl fmt::Display for WalError {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 match self {
104 WalError::Io { context, source } => {
105 write!(f, "i/o error while {context}: {source}")
106 }
107 WalError::RecordTooLarge { len, max } => {
108 write!(
109 f,
110 "record of {len} bytes exceeds the maximum of {max} bytes"
111 )
112 }
113 WalError::Corruption { offset, reason } => {
114 write!(f, "log corruption at byte offset {offset}: {reason}")
115 }
116 WalError::Encoding { detail } => {
117 write!(f, "typed record codec error: {detail}")
118 }
119 }
120 }
121}
122
123impl std::error::Error for WalError {
124 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125 match self {
126 WalError::Io { source, .. } => Some(source),
127 WalError::RecordTooLarge { .. }
128 | WalError::Corruption { .. }
129 | WalError::Encoding { .. } => None,
130 }
131 }
132}
133
134/// A bare [`io::Error`] converts into [`WalError::Io`] with a generic context.
135///
136/// Call sites that know what they were doing attach a specific context instead;
137/// this exists for the `?` ergonomics of code that does not.
138impl From<io::Error> for WalError {
139 fn from(source: io::Error) -> Self {
140 WalError::Io {
141 context: "performing a log i/o operation",
142 source,
143 }
144 }
145}
146
147impl ForgeError for WalError {
148 fn kind(&self) -> &'static str {
149 match self {
150 WalError::Io { .. } => "Io",
151 WalError::RecordTooLarge { .. } => "RecordTooLarge",
152 WalError::Corruption { .. } => "Corruption",
153 WalError::Encoding { .. } => "Encoding",
154 }
155 }
156
157 fn caption(&self) -> &'static str {
158 "write-ahead log error"
159 }
160
161 /// Corruption is unrecoverable by retry: the bytes on disk are already
162 /// damaged. I/O and size errors are left non-fatal for the caller to judge.
163 fn is_fatal(&self) -> bool {
164 matches!(self, WalError::Corruption { .. })
165 }
166}
167
168#[cfg(test)]
169#[allow(clippy::unwrap_used, clippy::expect_used)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn test_io_error_exposes_source() {
175 let inner = io::Error::new(io::ErrorKind::PermissionDenied, "denied");
176 let err = WalError::io("open log file", inner);
177 let source = std::error::Error::source(&err).expect("io error has a source");
178 let io_source = source
179 .downcast_ref::<io::Error>()
180 .expect("source downcasts to io::Error");
181 assert_eq!(io_source.kind(), io::ErrorKind::PermissionDenied);
182 }
183
184 #[test]
185 fn test_record_too_large_has_no_source() {
186 let err = WalError::RecordTooLarge {
187 len: 4096,
188 max: 1024,
189 };
190 assert!(std::error::Error::source(&err).is_none());
191 }
192
193 #[test]
194 fn test_corruption_is_fatal_others_are_not() {
195 assert!(WalError::corruption(128, "checksum mismatch").is_fatal());
196 assert!(!WalError::RecordTooLarge { len: 1, max: 0 }.is_fatal());
197 let io = WalError::io("x", io::Error::from(io::ErrorKind::Other));
198 assert!(!io.is_fatal());
199 }
200
201 #[test]
202 fn test_kind_matches_variant() {
203 assert_eq!(WalError::corruption(0, "x").kind(), "Corruption");
204 assert_eq!(
205 WalError::RecordTooLarge { len: 1, max: 0 }.kind(),
206 "RecordTooLarge"
207 );
208 }
209
210 #[test]
211 fn test_display_is_actionable() {
212 let err = WalError::RecordTooLarge {
213 len: 4096,
214 max: 1024,
215 };
216 assert_eq!(
217 err.to_string(),
218 "record of 4096 bytes exceeds the maximum of 1024 bytes"
219 );
220 }
221}