txn_db/error.rs
1//! The crate error type.
2//!
3//! Every fallible operation in `txn-db` returns [`Result<T>`], whose error is
4//! [`TxnError`]. 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.
7//!
8//! The error a caller meets most often is [`TxnError::Conflict`]: under
9//! snapshot isolation, two transactions that wrote the same key race at commit
10//! time, and the later committer is aborted. That outcome is *expected* and
11//! *retryable* — the contract is that the caller re-runs the transaction
12//! against a fresher snapshot rather than treating it as a failure. The
13//! [`TxnError::is_retryable`] helper makes that decision a single call in a
14//! retry loop.
15
16use core::fmt;
17
18use error_forge::ForgeError;
19
20/// A specialised [`Result`](core::result::Result) for transaction operations.
21///
22/// Defaults its error to [`TxnError`], so most signatures read `Result<T>`.
23pub type Result<T, E = TxnError> = core::result::Result<T, E>;
24
25/// Everything that can go wrong while running a transaction.
26///
27/// The type is [`#[non_exhaustive]`](https://doc.rust-lang.org/reference/attributes/type_system.html#the-non_exhaustive-attribute):
28/// later versions may add variants without a major bump, so a `match` over it
29/// must include a wildcard arm. Each variant documents what the caller should
30/// do when they encounter it.
31#[non_exhaustive]
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum TxnError {
34 /// A write-write conflict aborted the transaction at commit time.
35 ///
36 /// Under snapshot isolation the database applies *first-committer-wins*:
37 /// when a transaction commits, every key it wrote is checked against the
38 /// version store, and if any of those keys was written by a different
39 /// transaction that committed *after* this one took its snapshot, this
40 /// commit is rejected. None of its writes are applied.
41 ///
42 /// This is the mechanism that prevents lost updates, and it is a normal
43 /// part of operating under optimistic concurrency control. The correct
44 /// response is to retry: begin a fresh transaction, re-read, re-apply the
45 /// logic, and commit again. [`TxnError::is_retryable`] returns `true` for
46 /// this variant.
47 ///
48 /// Only the length of the conflicting key is carried, never its bytes, so
49 /// the error is safe to log even when keys hold sensitive data.
50 Conflict {
51 /// Length in bytes of the key whose conflict aborted the commit.
52 key_len: usize,
53 },
54
55 /// The backing version store failed to service a read or apply a write.
56 ///
57 /// The in-memory store that ships with `txn-db` never produces this; it is
58 /// the channel through which a custom [`VersionStore`](crate::VersionStore)
59 /// — for example one backed by an on-disk engine — surfaces a failure
60 /// through the same [`Result`]. `context` names the operation that was
61 /// attempted (such as `"read visible version"`); `detail` carries the
62 /// store's own message. Whether to retry depends on the store, so this
63 /// variant is reported as non-fatal and left for the caller to judge.
64 Store {
65 /// The operation the store was performing when it failed.
66 context: &'static str,
67 /// The store's human-readable description of the failure.
68 detail: String,
69 },
70
71 /// The durable commit log failed, or a record read back from it was not
72 /// intact.
73 ///
74 /// Produced only with the `durability` feature: when appending or syncing a
75 /// commit record fails, or when recovery on [`Db::open`](crate::Db) reads a
76 /// record whose bytes do not decode. A commit that fails to become durable
77 /// is *not* acknowledged — the contract that an acknowledged commit survives
78 /// a crash holds — but the failure is fatal in the sense that the database's
79 /// durability guarantee is in question, so treat it as unrecoverable rather
80 /// than retrying blindly.
81 Durability {
82 /// A human-readable description of the durability failure.
83 detail: String,
84 },
85}
86
87impl TxnError {
88 /// Build a [`TxnError::Conflict`] for a key of the given length.
89 ///
90 /// A custom [`VersionStore`](crate::VersionStore) returns this from
91 /// [`try_commit`](crate::VersionStore::try_commit) when validation detects
92 /// that a written or read key changed after the transaction's snapshot. Pass
93 /// the conflicting key's length; its bytes are deliberately not carried, so
94 /// the error stays safe to log. The shipped in-memory store uses this
95 /// internally.
96 ///
97 /// # Examples
98 ///
99 /// ```
100 /// use txn_db::TxnError;
101 ///
102 /// let err = TxnError::conflict(b"account:42".len());
103 /// assert!(err.is_retryable());
104 /// ```
105 #[inline]
106 #[must_use]
107 pub fn conflict(key_len: usize) -> Self {
108 TxnError::Conflict { key_len }
109 }
110
111 /// Build a [`TxnError::Store`] from a static context and a store message.
112 ///
113 /// Intended for [`VersionStore`](crate::VersionStore) implementations that
114 /// can fail; the in-memory store never calls it.
115 #[inline]
116 #[must_use]
117 pub fn store(context: &'static str, detail: impl fmt::Display) -> Self {
118 TxnError::Store {
119 context,
120 detail: detail.to_string(),
121 }
122 }
123
124 /// Build a [`TxnError::Durability`] from a description of the failure.
125 #[cfg(feature = "durability")]
126 #[inline]
127 #[must_use]
128 pub(crate) fn durability(detail: impl fmt::Display) -> Self {
129 TxnError::Durability {
130 detail: detail.to_string(),
131 }
132 }
133
134 /// Returns `true` if re-running the transaction is the right response.
135 ///
136 /// A [`Conflict`](TxnError::Conflict) is retryable: another transaction won
137 /// the race, and a fresh attempt against the newer snapshot will typically
138 /// succeed. Backing-store failures are reported as not retryable here
139 /// because their recoverability is store-specific; inspect the variant when
140 /// a store can distinguish transient from permanent faults.
141 ///
142 /// # Examples
143 ///
144 /// ```
145 /// use txn_db::{Db, TxnError};
146 ///
147 /// let db = Db::new();
148 ///
149 /// // The common retry loop: keep trying while the commit is retryable.
150 /// let outcome = loop {
151 /// let mut tx = db.begin();
152 /// let current = tx.get(b"counter")?.map_or(0u64, |v| {
153 /// let mut buf = [0u8; 8];
154 /// buf.copy_from_slice(&v);
155 /// u64::from_le_bytes(buf)
156 /// });
157 /// tx.put(b"counter".to_vec(), (current + 1).to_le_bytes().to_vec());
158 /// match tx.commit() {
159 /// Ok(ts) => break ts,
160 /// Err(e) if e.is_retryable() => continue,
161 /// Err(e) => return Err(e),
162 /// }
163 /// };
164 /// # let _ = outcome;
165 /// # Ok::<(), TxnError>(())
166 /// ```
167 #[inline]
168 #[must_use]
169 pub fn is_retryable(&self) -> bool {
170 matches!(self, TxnError::Conflict { .. })
171 }
172}
173
174impl fmt::Display for TxnError {
175 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176 match self {
177 TxnError::Conflict { key_len } => write!(
178 f,
179 "write-write conflict on a {key_len}-byte key; retry the transaction"
180 ),
181 TxnError::Store { context, detail } => {
182 write!(f, "version store error while {context}: {detail}")
183 }
184 TxnError::Durability { detail } => {
185 write!(f, "durable commit log error: {detail}")
186 }
187 }
188 }
189}
190
191impl core::error::Error for TxnError {}
192
193impl ForgeError for TxnError {
194 fn kind(&self) -> &'static str {
195 match self {
196 TxnError::Conflict { .. } => "Conflict",
197 TxnError::Store { .. } => "Store",
198 TxnError::Durability { .. } => "Durability",
199 }
200 }
201
202 fn caption(&self) -> &'static str {
203 "transaction error"
204 }
205
206 /// A [`Conflict`](TxnError::Conflict) is the retry signal and a
207 /// [`Store`](TxnError::Store) failure is the store's to classify, so neither
208 /// is fatal. A [`Durability`](TxnError::Durability) failure puts the crash
209 /// guarantee in doubt and is reported as fatal.
210 fn is_fatal(&self) -> bool {
211 matches!(self, TxnError::Durability { .. })
212 }
213}
214
215#[cfg(test)]
216#[allow(clippy::unwrap_used, clippy::expect_used)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_conflict_is_retryable() {
222 assert!(TxnError::conflict(8).is_retryable());
223 }
224
225 #[test]
226 fn test_store_error_is_not_retryable() {
227 assert!(!TxnError::store("read", "disk gone").is_retryable());
228 }
229
230 #[test]
231 fn test_conflict_display_reports_key_len_not_bytes() {
232 let msg = TxnError::conflict(16).to_string();
233 assert!(msg.contains("16-byte"));
234 assert!(msg.contains("retry"));
235 }
236
237 #[test]
238 fn test_kind_matches_variant() {
239 assert_eq!(TxnError::conflict(1).kind(), "Conflict");
240 assert_eq!(TxnError::store("x", "y").kind(), "Store");
241 }
242
243 #[test]
244 fn test_no_variant_is_fatal() {
245 assert!(!TxnError::conflict(1).is_fatal());
246 assert!(!TxnError::store("x", "y").is_fatal());
247 }
248
249 #[test]
250 fn test_error_is_clonable_and_comparable() {
251 let a = TxnError::conflict(4);
252 assert_eq!(a.clone(), a);
253 }
254}