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 pub fn category(&self) -> mongreldb_types::errors::ErrorCategory {
122 use mongreldb_types::errors::ErrorCategory;
123 match self {
124 MongrelError::Io(_) => ErrorCategory::ReplicaUnavailable,
127 MongrelError::DatabaseLocked { .. } => ErrorCategory::ResourceExhausted,
129 MongrelError::DatabaseBusy { .. } => ErrorCategory::ResourceExhausted,
131 MongrelError::ForkedProcess { .. } | MongrelError::ReadOnlyHandle { .. } => {
135 ErrorCategory::PermissionDenied
136 }
137 MongrelError::Serialization(_) => ErrorCategory::ClusterVersionMismatch,
141 MongrelError::CorruptWal { .. }
144 | MongrelError::TornWrite { .. }
145 | MongrelError::ChecksumMismatch { .. }
146 | MongrelError::MagicMismatch { .. } => ErrorCategory::ReplicaUnavailable,
147 MongrelError::UnsupportedStorageVersion { .. } => ErrorCategory::ClusterVersionMismatch,
149 MongrelError::Schema(_) | MongrelError::ColumnNotFound(_) => {
154 ErrorCategory::SchemaVersionMismatch
155 }
156 MongrelError::EncryptionDisabled => ErrorCategory::ClusterVersionMismatch,
159 MongrelError::Encryption(_) | MongrelError::Decryption(_) => {
162 ErrorCategory::Unauthenticated
163 }
164 MongrelError::EntropyUnavailable(_) => ErrorCategory::ResourceExhausted,
167 MongrelError::NotFound(_) => ErrorCategory::StaleMetadata,
171 MongrelError::InvalidArgument(_) => ErrorCategory::ClusterVersionMismatch,
175 MongrelError::Full(_) => ErrorCategory::ResourceExhausted,
176 MongrelError::Conflict(_) => ErrorCategory::TransactionConflict,
177 MongrelError::Deadlock { .. } => ErrorCategory::Deadlock,
183 MongrelError::SerializationFailure { .. } => ErrorCategory::SerializationFailure,
184 MongrelError::TriggerValidation(_) => ErrorCategory::SchemaVersionMismatch,
188 MongrelError::ReadOnlyReplica => ErrorCategory::NotLeader,
191 MongrelError::AuthRequired => ErrorCategory::Unauthenticated,
192 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 MongrelError::DurableCommit { .. } | MongrelError::CommitOutcomeUnknown { .. } => {
210 ErrorCategory::CommitOutcomeUnknown
211 }
212 MongrelError::CursorStale(_) | MongrelError::CursorExpired => {
215 ErrorCategory::StaleMetadata
216 }
217 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 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}