Skip to main content

mongreldb_core/
error.rs

1use thiserror::Error;
2
3pub type Result<T> = std::result::Result<T, MongrelError>;
4
5#[non_exhaustive]
6#[derive(Debug, Error)]
7pub enum MongrelError {
8    #[error("io error: {0}")]
9    Io(#[from] std::io::Error),
10    #[error("database at {path} is locked: {message}")]
11    DatabaseLocked {
12        path: std::path::PathBuf,
13        message: String,
14    },
15    #[error("database still has {strong_handles} shared handles")]
16    DatabaseBusy { strong_handles: usize },
17    #[error("database handle belongs to process {owner_pid}, not post-fork process {current_pid}; open after exec")]
18    ForkedProcess { owner_pid: u32, current_pid: u32 },
19    #[error("serialization error: {0}")]
20    Serialization(#[from] bincode::Error),
21    #[error("corrupt wal record at offset {offset}: {reason}")]
22    CorruptWal { offset: u64, reason: String },
23    #[error("torn wal write detected at offset {offset}")]
24    TornWrite { offset: u64 },
25    #[error("checksum mismatch for {context}: expected {expected}, got {actual}")]
26    ChecksumMismatch {
27        expected: u64,
28        actual: u64,
29        context: String,
30    },
31    #[error("magic mismatch in {what}: expected {expected:?}, got {got:?}")]
32    MagicMismatch {
33        what: &'static str,
34        expected: [u8; 8],
35        got: [u8; 8],
36    },
37    #[error("unsupported {component} storage version {found}: this build supports version {supported} only; recreate the database or export data using the engine version that created it")]
38    UnsupportedStorageVersion {
39        component: &'static str,
40        found: u16,
41        supported: u16,
42    },
43    #[error("schema error: {0}")]
44    Schema(String),
45    #[error("column not found: {0}")]
46    ColumnNotFound(String),
47    #[error("encryption is required for this table but no encryption key was provided")]
48    EncryptionDisabled,
49    #[error("encryption error: {0}")]
50    Encryption(String),
51    #[error("decryption error: {0}")]
52    Decryption(String),
53    #[error("OS CSPRNG unavailable: {0}")]
54    EntropyUnavailable(String),
55    #[error("not found: {0}")]
56    NotFound(String),
57    #[error("invalid argument: {0}")]
58    InvalidArgument(String),
59    #[error("table is full: {0}")]
60    Full(String),
61    #[error("transaction conflict: {0}")]
62    Conflict(String),
63    #[error(
64        "deadlock: transaction {victim} was chosen as the deadlock victim (wait-for cycle {cycle}); retry the whole transaction"
65    )]
66    Deadlock { victim: u64, cycle: String },
67    #[error("serialization failure: {message}")]
68    SerializationFailure { message: String },
69    #[error("trigger validation failed: {0}")]
70    TriggerValidation(String),
71    #[error("read-only replica: writes must be applied by ReplicationFollower")]
72    ReadOnlyReplica,
73    #[error("read-only database handle cannot perform {operation}")]
74    ReadOnlyHandle { operation: &'static str },
75    #[error("authentication required: this database has require_auth enabled; reopen with open_with_credentials / open_encrypted_with_credentials")]
76    AuthRequired,
77    #[error("authentication not required: this database does not have require_auth enabled; use the plain open/create constructors")]
78    AuthNotRequired,
79    #[error("invalid credentials for user {username:?}")]
80    InvalidCredentials { username: String },
81    #[error("permission denied: principal {principal:?} lacks {required}")]
82    PermissionDenied {
83        required: crate::auth::Permission,
84        principal: String,
85    },
86    #[error("execution deadline exceeded")]
87    DeadlineExceeded,
88    #[error("AI query work budget exceeded")]
89    WorkBudgetExceeded,
90    #[error(
91        "execution resource limit exceeded for {resource}: requested {requested}, limit {limit}"
92    )]
93    ResourceLimitExceeded {
94        resource: &'static str,
95        requested: usize,
96        limit: usize,
97    },
98    #[error("execution cancelled")]
99    Cancelled,
100    #[error("commit {epoch} is durable: {message}")]
101    DurableCommit { epoch: u64, message: String },
102    #[error("commit outcome at epoch {epoch} is unknown: {message}")]
103    CommitOutcomeUnknown { epoch: u64, message: String },
104    #[error("cursor stale: {0}")]
105    CursorStale(String),
106    #[error("cursor expired")]
107    CursorExpired,
108    #[error("{0}")]
109    Other(String),
110}
111
112impl MongrelError {
113    /// Maps this error onto the stable cross-language error taxonomy
114    /// (spec section 9.7, FND-007).
115    ///
116    /// The taxonomy is deliberately coarser than [`MongrelError`]: bindings
117    /// and gateways only see the [`ErrorCategory`], while the full variant
118    /// and message remain available in-process. The mapping is total; several
119    /// single-node variants have no exact cluster counterpart, so non-obvious
120    /// choices are documented at the arm.
121    pub fn category(&self) -> mongreldb_types::errors::ErrorCategory {
122        use mongreldb_types::errors::ErrorCategory;
123        match self {
124            // A storage I/O failure means this replica cannot serve the
125            // request; a retry (possibly against another replica) may succeed.
126            MongrelError::Io(_) => ErrorCategory::ReplicaUnavailable,
127            // The exclusive database-file lock is a contended resource.
128            MongrelError::DatabaseLocked { .. } => ErrorCategory::ResourceExhausted,
129            // Outstanding shared handles are a transient busy resource.
130            MongrelError::DatabaseBusy { .. } => ErrorCategory::ResourceExhausted,
131            // The operating system refuses to hand the parent's handles to
132            // the forked child: an ownership denial, closest to an
133            // authorization failure (no credential can fix it).
134            MongrelError::ForkedProcess { .. } | MongrelError::ReadOnlyHandle { .. } => {
135                ErrorCategory::PermissionDenied
136            }
137            // A codec failure means the payload does not match the durable /
138            // log format this binary reads and writes — a format-version
139            // disagreement (§11.8 advertises log/snapshot format min/max).
140            MongrelError::Serialization(_) => ErrorCategory::ClusterVersionMismatch,
141            // Corruption or integrity-check failure of local durable state
142            // renders this replica unable to serve until repaired or rebuilt.
143            MongrelError::CorruptWal { .. }
144            | MongrelError::TornWrite { .. }
145            | MongrelError::ChecksumMismatch { .. }
146            | MongrelError::MagicMismatch { .. } => ErrorCategory::ReplicaUnavailable,
147            // Explicit skew between the binary and the on-disk format version.
148            MongrelError::UnsupportedStorageVersion { .. } => ErrorCategory::ClusterVersionMismatch,
149            // Schema errors and dangling column references describe requests
150            // that disagree with the current schema; the taxonomy has no
151            // plain invalid-DDL category, so they share the schema-mismatch
152            // category. Retrying the identical request re-fails.
153            MongrelError::Schema(_) | MongrelError::ColumnNotFound(_) => {
154                ErrorCategory::SchemaVersionMismatch
155            }
156            // Feature-set disagreement (§11.8): this build lacks the
157            // encryption feature the database requires.
158            MongrelError::EncryptionDisabled => ErrorCategory::ClusterVersionMismatch,
159            // AES-256-GCM tag verification failing is an authentication
160            // failure: wrong key material or tampered ciphertext.
161            MongrelError::Encryption(_) | MongrelError::Decryption(_) => {
162                ErrorCategory::Unauthenticated
163            }
164            // The OS entropy source is an environmental resource that is
165            // temporarily unavailable.
166            MongrelError::EntropyUnavailable(_) => ErrorCategory::ResourceExhausted,
167            // Absence is judged against the caller's current view; the safe
168            // interpretation is that the caller may hold stale metadata and
169            // should refresh before concluding the object does not exist.
170            MongrelError::NotFound(_) => ErrorCategory::StaleMetadata,
171            // The taxonomy has no invalid-request category: a request this
172            // binary rejects as malformed reflects a client/server contract
173            // disagreement. Not retryable — only changing one side helps.
174            MongrelError::InvalidArgument(_) => ErrorCategory::ClusterVersionMismatch,
175            MongrelError::Full(_) => ErrorCategory::ResourceExhausted,
176            MongrelError::Conflict(_) => ErrorCategory::TransactionConflict,
177            // The deterministic deadlock victim and the SSI certification
178            // abort share Conflict's retry-the-whole-transaction discipline
179            // (spec section 9.7, `ErrorCategory::retry_class`), but the
180            // taxonomy reserves precise categories for both so bindings can
181            // tell them apart from a plain write-write conflict.
182            MongrelError::Deadlock { .. } => ErrorCategory::Deadlock,
183            MongrelError::SerializationFailure { .. } => ErrorCategory::SerializationFailure,
184            // Triggers are schema objects; the write violates constraints
185            // declared in the current schema. Re-issuing the identical write
186            // re-fails unless the schema changed.
187            MongrelError::TriggerValidation(_) => ErrorCategory::SchemaVersionMismatch,
188            // A follower rejects writes exactly like a non-leader: reroute to
189            // the writer with a leader hint.
190            MongrelError::ReadOnlyReplica => ErrorCategory::NotLeader,
191            MongrelError::AuthRequired => ErrorCategory::Unauthenticated,
192            // The client negotiated the wrong open contract (credentials
193            // offered to a database that does not require them): a contract
194            // disagreement, not a credential failure.
195            MongrelError::AuthNotRequired => ErrorCategory::ClusterVersionMismatch,
196            MongrelError::InvalidCredentials { .. } => ErrorCategory::Unauthenticated,
197            MongrelError::PermissionDenied { .. } => ErrorCategory::PermissionDenied,
198            MongrelError::DeadlineExceeded => ErrorCategory::DeadlineExceeded,
199            MongrelError::WorkBudgetExceeded | MongrelError::ResourceLimitExceeded { .. } => {
200                ErrorCategory::ResourceExhausted
201            }
202            MongrelError::Cancelled => ErrorCategory::Cancelled,
203            // The outcome of a DurableCommit is in fact known — the commit is
204            // durable — but the taxonomy has no committed-with-warning
205            // category. The critical shared property with CommitOutcomeUnknown
206            // is that blindly replaying the request may duplicate an
207            // already-durable commit, so it inherits the §11.7 "never replay
208            // without a durable idempotency key" rule.
209            MongrelError::DurableCommit { .. } | MongrelError::CommitOutcomeUnknown { .. } => {
210                ErrorCategory::CommitOutcomeUnknown
211            }
212            // The cursor's server-side state no longer matches or no longer
213            // exists; refresh by re-issuing the query.
214            MongrelError::CursorStale(_) | MongrelError::CursorExpired => {
215                ErrorCategory::StaleMetadata
216            }
217            // Catch-all for uncategorized subsystem failures (backup, PITR,
218            // replication glue): treated as the serving replica failing to
219            // complete the request. Callers must inspect the message.
220            MongrelError::Other(_) => ErrorCategory::ReplicaUnavailable,
221        }
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use mongreldb_types::errors::ErrorCategory;
229
230    fn io_error() -> std::io::Error {
231        std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied")
232    }
233
234    fn bincode_error() -> bincode::Error {
235        bincode::ErrorKind::Custom("codec".to_string()).into()
236    }
237
238    #[test]
239    fn category_mapping_is_total_and_matches_expectations() {
240        let cases: Vec<(MongrelError, ErrorCategory)> = vec![
241            (
242                MongrelError::Io(io_error()),
243                ErrorCategory::ReplicaUnavailable,
244            ),
245            (
246                MongrelError::DatabaseLocked {
247                    path: "db.mdb".into(),
248                    message: "held".into(),
249                },
250                ErrorCategory::ResourceExhausted,
251            ),
252            (
253                MongrelError::DatabaseBusy { strong_handles: 2 },
254                ErrorCategory::ResourceExhausted,
255            ),
256            (
257                MongrelError::ForkedProcess {
258                    owner_pid: 1,
259                    current_pid: 2,
260                },
261                ErrorCategory::PermissionDenied,
262            ),
263            (
264                MongrelError::Serialization(bincode_error()),
265                ErrorCategory::ClusterVersionMismatch,
266            ),
267            (
268                MongrelError::CorruptWal {
269                    offset: 8,
270                    reason: "bad".into(),
271                },
272                ErrorCategory::ReplicaUnavailable,
273            ),
274            (
275                MongrelError::TornWrite { offset: 16 },
276                ErrorCategory::ReplicaUnavailable,
277            ),
278            (
279                MongrelError::ChecksumMismatch {
280                    expected: 1,
281                    actual: 2,
282                    context: "page".into(),
283                },
284                ErrorCategory::ReplicaUnavailable,
285            ),
286            (
287                MongrelError::MagicMismatch {
288                    what: "wal",
289                    expected: [1; 8],
290                    got: [2; 8],
291                },
292                ErrorCategory::ReplicaUnavailable,
293            ),
294            (
295                MongrelError::UnsupportedStorageVersion {
296                    component: "wal",
297                    found: 2,
298                    supported: 1,
299                },
300                ErrorCategory::ClusterVersionMismatch,
301            ),
302            (
303                MongrelError::Schema("bad ddl".into()),
304                ErrorCategory::SchemaVersionMismatch,
305            ),
306            (
307                MongrelError::ColumnNotFound("c".into()),
308                ErrorCategory::SchemaVersionMismatch,
309            ),
310            (
311                MongrelError::EncryptionDisabled,
312                ErrorCategory::ClusterVersionMismatch,
313            ),
314            (
315                MongrelError::Encryption("seal".into()),
316                ErrorCategory::Unauthenticated,
317            ),
318            (
319                MongrelError::Decryption("open".into()),
320                ErrorCategory::Unauthenticated,
321            ),
322            (
323                MongrelError::EntropyUnavailable("rng".into()),
324                ErrorCategory::ResourceExhausted,
325            ),
326            (
327                MongrelError::NotFound("row".into()),
328                ErrorCategory::StaleMetadata,
329            ),
330            (
331                MongrelError::InvalidArgument("arg".into()),
332                ErrorCategory::ClusterVersionMismatch,
333            ),
334            (
335                MongrelError::Full("table".into()),
336                ErrorCategory::ResourceExhausted,
337            ),
338            (
339                MongrelError::Conflict("rw".into()),
340                ErrorCategory::TransactionConflict,
341            ),
342            (
343                MongrelError::Deadlock {
344                    victim: 3,
345                    cycle: "3 → 1 → 3".into(),
346                },
347                ErrorCategory::Deadlock,
348            ),
349            (
350                MongrelError::SerializationFailure {
351                    message: "a concurrent commit invalidated this transaction's reads".into(),
352                },
353                ErrorCategory::SerializationFailure,
354            ),
355            (
356                MongrelError::TriggerValidation("ck".into()),
357                ErrorCategory::SchemaVersionMismatch,
358            ),
359            (MongrelError::ReadOnlyReplica, ErrorCategory::NotLeader),
360            (MongrelError::AuthRequired, ErrorCategory::Unauthenticated),
361            (
362                MongrelError::AuthNotRequired,
363                ErrorCategory::ClusterVersionMismatch,
364            ),
365            (
366                MongrelError::InvalidCredentials {
367                    username: "alice".into(),
368                },
369                ErrorCategory::Unauthenticated,
370            ),
371            (
372                MongrelError::PermissionDenied {
373                    required: crate::auth::Permission::Admin,
374                    principal: "bob".into(),
375                },
376                ErrorCategory::PermissionDenied,
377            ),
378            (
379                MongrelError::DeadlineExceeded,
380                ErrorCategory::DeadlineExceeded,
381            ),
382            (
383                MongrelError::WorkBudgetExceeded,
384                ErrorCategory::ResourceExhausted,
385            ),
386            (
387                MongrelError::ResourceLimitExceeded {
388                    resource: "rows",
389                    requested: 10,
390                    limit: 5,
391                },
392                ErrorCategory::ResourceExhausted,
393            ),
394            (MongrelError::Cancelled, ErrorCategory::Cancelled),
395            (
396                MongrelError::DurableCommit {
397                    epoch: 9,
398                    message: "callback".into(),
399                },
400                ErrorCategory::CommitOutcomeUnknown,
401            ),
402            (
403                MongrelError::CommitOutcomeUnknown {
404                    epoch: 10,
405                    message: "lost".into(),
406                },
407                ErrorCategory::CommitOutcomeUnknown,
408            ),
409            (
410                MongrelError::CursorStale("gen".into()),
411                ErrorCategory::StaleMetadata,
412            ),
413            (MongrelError::CursorExpired, ErrorCategory::StaleMetadata),
414            (
415                MongrelError::Other("misc".into()),
416                ErrorCategory::ReplicaUnavailable,
417            ),
418        ];
419        assert_eq!(
420            cases.len(),
421            37,
422            "every MongrelError variant must appear in the mapping table"
423        );
424        for (error, expected) in cases {
425            assert_eq!(error.category(), expected, "wrong category for {error}");
426        }
427    }
428
429    #[test]
430    fn prescribed_fnd_007_mappings_hold() {
431        assert_eq!(
432            MongrelError::Conflict("x".into()).category(),
433            ErrorCategory::TransactionConflict
434        );
435        assert_eq!(MongrelError::Cancelled.category(), ErrorCategory::Cancelled);
436        assert_eq!(
437            MongrelError::CommitOutcomeUnknown {
438                epoch: 1,
439                message: "m".into(),
440            }
441            .category(),
442            ErrorCategory::CommitOutcomeUnknown
443        );
444        assert_eq!(
445            MongrelError::DatabaseLocked {
446                path: "p".into(),
447                message: "m".into(),
448            }
449            .category(),
450            ErrorCategory::ResourceExhausted
451        );
452        assert_eq!(
453            MongrelError::ReadOnlyReplica.category(),
454            ErrorCategory::NotLeader
455        );
456        assert_eq!(
457            MongrelError::Deadlock {
458                victim: 2,
459                cycle: "2 → 1 → 2".into(),
460            }
461            .category(),
462            ErrorCategory::Deadlock
463        );
464        assert_eq!(
465            MongrelError::SerializationFailure {
466                message: "m".into(),
467            }
468            .category(),
469            ErrorCategory::SerializationFailure
470        );
471    }
472
473    #[test]
474    fn deadlock_display_names_victim_and_cycle() {
475        let error = MongrelError::Deadlock {
476            victim: 7,
477            cycle: "7 → 4 → 7".into(),
478        };
479        let display = error.to_string();
480        assert!(
481            display.starts_with("deadlock: transaction 7"),
482            "display names the victim: {display}"
483        );
484        assert!(
485            display.contains("7 → 4 → 7"),
486            "display carries the wait-for cycle: {display}"
487        );
488    }
489
490    #[test]
491    fn serialization_failure_display_keeps_the_stable_marker() {
492        // The query layer bridges core commit aborts onto this variant by
493        // matching the "serialization failure" marker (see the MongrelQueryError
494        // conversion); the display must keep that marker stable.
495        let error = MongrelError::SerializationFailure {
496            message: "a concurrent commit invalidated this transaction's reads".into(),
497        };
498        assert!(
499            error.to_string().starts_with("serialization failure: "),
500            "display keeps the marker prefix: {error}"
501        );
502    }
503}