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