Skip to main content

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}