Skip to main content

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
72impl TxnError {
73    /// Build a [`TxnError::Conflict`] for a key of the given length.
74    #[inline]
75    #[must_use]
76    pub(crate) fn conflict(key_len: usize) -> Self {
77        TxnError::Conflict { key_len }
78    }
79
80    /// Build a [`TxnError::Store`] from a static context and a store message.
81    ///
82    /// Intended for [`VersionStore`](crate::VersionStore) implementations that
83    /// can fail; the in-memory store never calls it.
84    #[inline]
85    #[must_use]
86    pub fn store(context: &'static str, detail: impl fmt::Display) -> Self {
87        TxnError::Store {
88            context,
89            detail: detail.to_string(),
90        }
91    }
92
93    /// Returns `true` if re-running the transaction is the right response.
94    ///
95    /// A [`Conflict`](TxnError::Conflict) is retryable: another transaction won
96    /// the race, and a fresh attempt against the newer snapshot will typically
97    /// succeed. Backing-store failures are reported as not retryable here
98    /// because their recoverability is store-specific; inspect the variant when
99    /// a store can distinguish transient from permanent faults.
100    ///
101    /// # Examples
102    ///
103    /// ```
104    /// use txn_db::{Db, TxnError};
105    ///
106    /// let db = Db::new();
107    ///
108    /// // The common retry loop: keep trying while the commit is retryable.
109    /// let outcome = loop {
110    ///     let mut tx = db.begin();
111    ///     let current = tx.get(b"counter")?.map_or(0u64, |v| {
112    ///         let mut buf = [0u8; 8];
113    ///         buf.copy_from_slice(&v);
114    ///         u64::from_le_bytes(buf)
115    ///     });
116    ///     tx.put(b"counter".to_vec(), (current + 1).to_le_bytes().to_vec());
117    ///     match tx.commit() {
118    ///         Ok(ts) => break ts,
119    ///         Err(e) if e.is_retryable() => continue,
120    ///         Err(e) => return Err(e),
121    ///     }
122    /// };
123    /// # let _ = outcome;
124    /// # Ok::<(), TxnError>(())
125    /// ```
126    #[inline]
127    #[must_use]
128    pub fn is_retryable(&self) -> bool {
129        matches!(self, TxnError::Conflict { .. })
130    }
131}
132
133impl fmt::Display for TxnError {
134    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            TxnError::Conflict { key_len } => write!(
137                f,
138                "write-write conflict on a {key_len}-byte key; retry the transaction"
139            ),
140            TxnError::Store { context, detail } => {
141                write!(f, "version store error while {context}: {detail}")
142            }
143        }
144    }
145}
146
147impl core::error::Error for TxnError {}
148
149impl ForgeError for TxnError {
150    fn kind(&self) -> &'static str {
151        match self {
152            TxnError::Conflict { .. } => "Conflict",
153            TxnError::Store { .. } => "Store",
154        }
155    }
156
157    fn caption(&self) -> &'static str {
158        "transaction error"
159    }
160
161    /// No transaction error is fatal: a [`Conflict`](TxnError::Conflict) is the
162    /// retry signal, and a [`Store`](TxnError::Store) failure is the store's to
163    /// classify. The crate never panics in place of returning one of these.
164    fn is_fatal(&self) -> bool {
165        false
166    }
167}
168
169#[cfg(test)]
170#[allow(clippy::unwrap_used, clippy::expect_used)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_conflict_is_retryable() {
176        assert!(TxnError::conflict(8).is_retryable());
177    }
178
179    #[test]
180    fn test_store_error_is_not_retryable() {
181        assert!(!TxnError::store("read", "disk gone").is_retryable());
182    }
183
184    #[test]
185    fn test_conflict_display_reports_key_len_not_bytes() {
186        let msg = TxnError::conflict(16).to_string();
187        assert!(msg.contains("16-byte"));
188        assert!(msg.contains("retry"));
189    }
190
191    #[test]
192    fn test_kind_matches_variant() {
193        assert_eq!(TxnError::conflict(1).kind(), "Conflict");
194        assert_eq!(TxnError::store("x", "y").kind(), "Store");
195    }
196
197    #[test]
198    fn test_no_variant_is_fatal() {
199        assert!(!TxnError::conflict(1).is_fatal());
200        assert!(!TxnError::store("x", "y").is_fatal());
201    }
202
203    #[test]
204    fn test_error_is_clonable_and_comparable() {
205        let a = TxnError::conflict(4);
206        assert_eq!(a.clone(), a);
207    }
208}