Skip to main content

noxu_db/
error.rs

1//! Error types for Noxu DB.
2//!
3//! Implements exception hierarchy:
4//!
5//! ```text
6//! DatabaseException (base)
7//!   ├── EnvironmentFailureException      → NoxuError::EnvironmentFailure
8//!   │     ├── LogWriteException          → NoxuError::LogWriteFailure
9//!   │     ├── DiskLimitException         → NoxuError::DiskLimitExceeded
10//!   │     ├── ThreadInterruptedException → NoxuError::ThreadInterrupted
11//!   │     ├── EnvironmentWedgedException → NoxuError::EnvironmentWedged
12//!   │     └── VersionMismatchException   → NoxuError::VersionMismatch
13//!   └── OperationFailureException
14//!         ├── LockConflictException      → NoxuError::LockConflict
15//!         │     ├── DeadlockException    → NoxuError::DeadlockDetected
16//!         │     ├── LockTimeoutException → NoxuError::LockTimeout
17//!         │     └── LockNotAvailableEx   → NoxuError::LockNotAvailable
18//!         ├── TransactionTimeoutException→ NoxuError::TransactionTimeout
19//!         ├── LockPreemptedException     → NoxuError::LockPreempted
20//!         ├── UniqueConstraintException  → NoxuError::UniqueConstraintViolation
21//!         ├── DeleteConstraintException  → NoxuError::DeleteConstraintViolation
22//!         ├── ForeignConstraintException → NoxuError::ForeignConstraintViolation
23//!         ├── SecondaryIntegrityException→ NoxuError::SecondaryIntegrityException
24//!         └── DuplicateDataException     → NoxuError::DuplicateDataException
25//! ```
26
27use thiserror::Error;
28
29// ── EnvironmentFailureReason ───────────────────────────────────────────────
30
31/// Distinguishes the root cause of an `EnvironmentFailure`.
32///
33/// Callers can
34/// match on this to decide whether to attempt restart (`invalidates_environment
35/// = false`) or give up (`invalidates_environment = true`).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum EnvironmentFailureReason {
38    // ── Log / checksum ─────────────────────────────────────────────────────
39    /// A checksum mismatch was detected in the log (persistent corruption).
40    /// `isCorrupted() == true`.  : `LOG_CHECKSUM`.
41    LogChecksum,
42
43    /// A log write I/O error occurred.  : `LOG_WRITE`.
44    LogWrite,
45
46    /// A log file was not found during read (truncation or deletion).
47    /// Mirrors `LOG_FILE_NOT_FOUND`.
48    LogFileNotFound,
49
50    /// The log is incomplete or internally inconsistent.
51    /// Mirrors `LOG_INTEGRITY`.
52    LogIntegrity,
53
54    // ── B-tree ──────────────────────────────────────────────────────────────
55    /// A persistent B-tree structure inconsistency was detected.
56    /// `isCorrupted() == true`.  : `BTREE_CORRUPTION`.
57    BtreeCorruption,
58
59    // ── Unexpected internal state ──────────────────────────────────────────
60    /// An unexpected internal state was reached (non-fatal; env still valid).
61    /// Mirrors `UNEXPECTED_STATE`.
62    UnexpectedState,
63
64    /// An unexpected internal state was reached (fatal; env is invalidated).
65    /// Mirrors `UNEXPECTED_STATE_FATAL`.
66    UnexpectedStateFatal,
67
68    /// An unexpected exception was caught internally (non-fatal).
69    /// Mirrors `UNEXPECTED_EXCEPTION`.
70    UnexpectedException,
71
72    /// An unexpected exception was caught internally (fatal; env invalidated).
73    /// Mirrors `UNEXPECTED_EXCEPTION_FATAL`.
74    UnexpectedExceptionFatal,
75
76    // ── Resource limits ─────────────────────────────────────────────────────
77    /// The disk limit (`MAX_DISK`) or free-disk threshold (`FREE_DISK`) was
78    /// exceeded.  : `DISK_LIMIT`.
79    DiskLimit,
80
81    /// A latch acquisition timed out.  : `LATCH_TIMEOUT`.
82    LatchTimeout,
83
84    // ── Thread lifecycle ────────────────────────────────────────────────────
85    /// The calling thread was interrupted while performing a
86    /// Mirrors `THREAD_INTERRUPTED`.
87    ThreadInterrupted,
88
89    // ── Replication ─────────────────────────────────────────────────────────
90    /// The master transitioned to a replica while a transaction was active.
91    /// Mirrors `MASTER_TO_REPLICA_TRANSITION`.
92    MasterToReplicaTransition,
93
94    /// The replica was fenced by the master.
95    /// Mirrors `REPLICA_FENCING`.
96    ReplicaFencing,
97
98    /// A replication handshake error occurred.
99    /// Mirrors `HANDSHAKE_ERROR`.
100    HandshakeError,
101
102    /// Replication protocol version mismatch.
103    /// Mirrors `PROTOCOL_VERSION_MISMATCH`.
104    ProtocolVersionMismatch,
105
106    /// An uncaught exception in a background replication thread.
107    /// Mirrors `UNCAUGHT_EXCEPTION`.
108    UncaughtException,
109
110    /// Forced shutdown was requested.
111    /// Mirrors `FORCED_SHUTDOWN`.
112    ForcedShutdown,
113
114    // ── Catch-all ──────────────────────────────────────────────────────────
115    /// Recovery (WAL replay during environment open) failed.
116    ///
117    /// The v1.5.0 layer
118    /// surfaced every recovery failure as `UnexpectedState`, which
119    /// forced callers to string-match the prefix "recovery failed:"
120    /// to distinguish it from other unexpected-state errors.  This
121    /// variant is now produced specifically when WAL replay aborts
122    /// the open path; if `invalidates_environment` returns `true` the
123    /// environment is unusable and a fresh open is required.
124    RecoveryFailure,
125
126    /// The specific reason is not mapped to a named variant.
127    Other(String),
128}
129
130impl EnvironmentFailureReason {
131    /// Returns `true` if this reason causes the environment to be invalidated.
132    ///
133    /// After an invalidating failure, all open `Environment` handles become
134    /// unusable; they must be closed and re-opened to run recovery.
135    ///
136    /// Mirrors `EnvironmentFailureReason.invalidatesEnvironment()`.
137    pub fn invalidates_environment(&self) -> bool {
138        matches!(
139            self,
140            EnvironmentFailureReason::LogChecksum
141                | EnvironmentFailureReason::LogWrite
142                | EnvironmentFailureReason::LogIntegrity
143                | EnvironmentFailureReason::BtreeCorruption
144                | EnvironmentFailureReason::UnexpectedStateFatal
145                | EnvironmentFailureReason::UnexpectedExceptionFatal
146                | EnvironmentFailureReason::RecoveryFailure
147                | EnvironmentFailureReason::DiskLimit
148                | EnvironmentFailureReason::LatchTimeout
149                | EnvironmentFailureReason::ReplicaFencing
150                | EnvironmentFailureReason::ForcedShutdown
151        )
152    }
153
154    /// Returns `true` if the environment log is persistently corrupted,
155    /// meaning a network restore or backup restore may be required.
156    ///
157    /// Mirrors `EnvironmentFailureException.isCorrupted()`.
158    pub fn is_corrupted(&self) -> bool {
159        matches!(
160            self,
161            EnvironmentFailureReason::LogChecksum
162                | EnvironmentFailureReason::BtreeCorruption
163        )
164    }
165}
166
167impl std::fmt::Display for EnvironmentFailureReason {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            EnvironmentFailureReason::LogChecksum => write!(f, "LOG_CHECKSUM"),
171            EnvironmentFailureReason::LogWrite => write!(f, "LOG_WRITE"),
172            EnvironmentFailureReason::LogFileNotFound => {
173                write!(f, "LOG_FILE_NOT_FOUND")
174            }
175            EnvironmentFailureReason::LogIntegrity => {
176                write!(f, "LOG_INTEGRITY")
177            }
178            EnvironmentFailureReason::BtreeCorruption => {
179                write!(f, "BTREE_CORRUPTION")
180            }
181            EnvironmentFailureReason::UnexpectedState => {
182                write!(f, "UNEXPECTED_STATE")
183            }
184            EnvironmentFailureReason::UnexpectedStateFatal => {
185                write!(f, "UNEXPECTED_STATE_FATAL")
186            }
187            EnvironmentFailureReason::UnexpectedException => {
188                write!(f, "UNEXPECTED_EXCEPTION")
189            }
190            EnvironmentFailureReason::UnexpectedExceptionFatal => {
191                write!(f, "UNEXPECTED_EXCEPTION_FATAL")
192            }
193            EnvironmentFailureReason::DiskLimit => write!(f, "DISK_LIMIT"),
194            EnvironmentFailureReason::LatchTimeout => {
195                write!(f, "LATCH_TIMEOUT")
196            }
197            EnvironmentFailureReason::ThreadInterrupted => {
198                write!(f, "THREAD_INTERRUPTED")
199            }
200            EnvironmentFailureReason::MasterToReplicaTransition => {
201                write!(f, "MASTER_TO_REPLICA_TRANSITION")
202            }
203            EnvironmentFailureReason::ReplicaFencing => {
204                write!(f, "REPLICA_FENCING")
205            }
206            EnvironmentFailureReason::HandshakeError => {
207                write!(f, "HANDSHAKE_ERROR")
208            }
209            EnvironmentFailureReason::ProtocolVersionMismatch => {
210                write!(f, "PROTOCOL_VERSION_MISMATCH")
211            }
212            EnvironmentFailureReason::UncaughtException => {
213                write!(f, "UNCAUGHT_EXCEPTION")
214            }
215            EnvironmentFailureReason::ForcedShutdown => {
216                write!(f, "FORCED_SHUTDOWN")
217            }
218            EnvironmentFailureReason::RecoveryFailure => {
219                write!(f, "RECOVERY_FAILURE")
220            }
221            EnvironmentFailureReason::Other(s) => write!(f, "{s}"),
222        }
223    }
224}
225
226// ── ExceptionListener ──────────────────────────────────────────────────────
227
228/// The source subsystem that raised an exception event.
229///
230/// Mirrors the thread-name conventions used by reporting background
231/// daemon exceptions via `ExceptionListener`.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum ExceptionSource {
234    Checkpointer,
235    Cleaner,
236    Evictor,
237    INCompressor,
238    Verifier,
239    ReplicationThread,
240    Unknown(String),
241}
242
243impl std::fmt::Display for ExceptionSource {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            ExceptionSource::Checkpointer => write!(f, "Checkpointer"),
247            ExceptionSource::Cleaner => write!(f, "Cleaner"),
248            ExceptionSource::Evictor => write!(f, "Evictor"),
249            ExceptionSource::INCompressor => write!(f, "INCompressor"),
250            ExceptionSource::Verifier => write!(f, "Verifier"),
251            ExceptionSource::ReplicationThread => {
252                write!(f, "ReplicationThread")
253            }
254            ExceptionSource::Unknown(s) => write!(f, "{s}"),
255        }
256    }
257}
258
259/// An exception event delivered to an [`ExceptionListener`].
260///
261
262#[derive(Debug, Clone)]
263pub struct ExceptionEvent {
264    /// Human-readable error message.
265    pub message: String,
266    /// The background subsystem or thread that encountered the exception.
267    pub source: ExceptionSource,
268    /// Name of the OS thread (for logging / diagnostics).
269    pub thread_name: String,
270}
271
272impl ExceptionEvent {
273    /// Create a new `ExceptionEvent`.
274    pub fn new(
275        message: impl Into<String>,
276        source: ExceptionSource,
277        thread_name: impl Into<String>,
278    ) -> Self {
279        Self {
280            message: message.into(),
281            source,
282            thread_name: thread_name.into(),
283        }
284    }
285}
286
287/// Callback interface for exceptions thrown in background daemon threads.
288///
289/// Register an implementation via
290/// `EnvironmentConfig.set_exception_listener()`.  Background threads
291/// (Checkpointer, Cleaner, Evictor, INCompressor, Verifier) call
292/// [`ExceptionListener::exception_event`] when they encounter an unhandled
293/// error.
294pub trait ExceptionListener: Send + Sync {
295    fn exception_event(&self, event: &ExceptionEvent);
296}
297
298// ── NoxuError ──────────────────────────────────────────────────────────────
299
300/// Errors that can occur when using Noxu DB.
301///
302/// Implements exception hierarchy:
303///
304/// - [`NoxuError::EnvironmentFailure`] — potentially fatal; check
305///   [`NoxuError::is_fatal_to_environment`].  Carries an
306///   [`EnvironmentFailureReason`] for discriminating the cause.
307/// - Operation-failure variants are retryable after abort
308///   ([`NoxuError::is_retryable`]).
309/// - HA / replication variants ([`NoxuError::InsufficientReplicas`],
310///   [`NoxuError::ReplicaWrite`], [`NoxuError::RollbackRequired`]).
311#[derive(Debug, Error)]
312pub enum NoxuError {
313    // ── Fatal / environment failure ────────────────────────────────────────
314    /// A failure has occurred that may require the environment to be closed
315    /// and re-opened.  Check [`NoxuError::is_fatal_to_environment`] /
316    /// [`NoxuError::reason`] to determine whether restart is required.
317    ///
318
319    #[error("environment failure ({reason}): {msg}")]
320    EnvironmentFailure {
321        /// The root cause of the failure.
322        reason: EnvironmentFailureReason,
323        /// Human-readable detail message.
324        msg: String,
325    },
326
327    /// The environment is permanently wedged and cannot recover even after
328    /// close/re-open.  Operator intervention or backup restore is required.
329    ///
330
331    #[error("environment wedged (permanent failure): {0}")]
332    EnvironmentWedged(String),
333
334    /// The environment home directory was not found and `allow_create = false`.
335    ///
336
337    #[error("environment not found: {0}")]
338    EnvironmentNotFound(String),
339
340    /// The environment is already open by another process.
341    ///
342
343    #[error("environment locked by another process: {0}")]
344    EnvironmentLocked(String),
345
346    /// An I/O error occurred while writing to the log.  The disk may be full.
347    ///
348
349    #[error("log write failure: {0}")]
350    LogWriteFailure(String),
351
352    /// The disk limit (`MAX_DISK` / `FREE_DISK`) was exceeded.
353    ///
354
355    #[error("disk limit exceeded: used={used}, limit={limit}")]
356    DiskLimitExceeded {
357        /// Bytes currently used by the environment.
358        used: u64,
359        /// Configured limit in bytes.
360        limit: u64,
361    },
362
363    /// The calling thread was interrupted while performing a
364    ///
365
366    #[error("thread interrupted during database operation")]
367    ThreadInterrupted,
368
369    // ── Database / cursor lifecycle ────────────────────────────────────────
370    /// The requested database was not found in the environment.
371    #[error("database not found: {0}")]
372    DatabaseNotFound(String),
373
374    /// An attempt was made to create a database that already exists.
375    ///
376
377    #[error("database already exists: {0}")]
378    DatabaseAlreadyExists(String),
379
380    /// An operation was attempted on a closed database.
381    #[error("database closed")]
382    DatabaseClosed,
383
384    /// An operation was attempted on a closed environment.
385    #[error("environment closed")]
386    EnvironmentClosed,
387
388    /// An operation was attempted on a closed cursor.
389    #[error("cursor closed")]
390    CursorClosed,
391
392    // ── Lock / transaction failures ────────────────────────────────────────
393    /// A lock conflict occurred (locker blocked and could not acquire).
394    ///
395    /// Retryable.
396    #[error("lock conflict: {0}")]
397    LockConflict(String),
398
399    /// A deadlock was detected between two or more transactions.
400    ///
401    /// Retryable.
402    #[error("deadlock detected")]
403    DeadlockDetected,
404
405    /// A lock-wait timeout expired.
406    ///
407    /// Retryable.
408    #[error("lock timeout after {timeout_ms}ms{detail}")]
409    LockTimeout {
410        /// How long the locker waited before giving up.
411        timeout_ms: u64,
412        /// Full diagnostic message from the lock manager (owner, requester, LSN).
413        /// Empty string when not available.
414        #[allow(dead_code)]
415        detail: String,
416    },
417
418    /// A lock was requested with `no-wait` semantics and was not immediately
419    /// available.
420    ///
421    /// Retryable.
422    #[error("lock not available (no-wait)")]
423    LockNotAvailable,
424
425    /// A transaction-level timeout expired.
426    ///
427    /// Retryable.
428    #[error("transaction timeout after {timeout_ms}ms for txn {txn_id}")]
429    TransactionTimeout {
430        /// Transaction-level timeout in milliseconds.
431        timeout_ms: u64,
432        /// ID of the timed-out transaction.
433        txn_id: i64,
434    },
435
436    /// A lock was preempted by a higher-priority locker (HA).
437    ///
438    /// Retryable.
439    #[error("lock preempted by higher-priority locker")]
440    LockPreempted,
441
442    /// The transaction was aborted.
443    #[error("transaction aborted: {0}")]
444    TransactionAborted(String),
445
446    // ── Constraint violations ──────────────────────────────────────────────
447    /// The key already exists (`put_no_overwrite` / cursor `put_no_dup_data`).
448    #[error("key already exists")]
449    KeyExists,
450
451    /// A unique-index constraint was violated.
452    ///
453
454    #[error("unique constraint violated: {0}")]
455    UniqueConstraintViolation(String),
456
457    /// A delete was attempted on a primary record referenced by a secondary
458    /// index.
459    ///
460
461    #[error("delete constraint violated: {0}")]
462    DeleteConstraintViolation(String),
463
464    /// A foreign-key constraint was violated.
465    ///
466
467    #[error("foreign constraint violated: {0}")]
468    ForeignConstraintViolation(String),
469
470    /// Duplicate data was supplied to a `putNoDupData` operation in a
471    /// duplicate-sorted database.
472    ///
473
474    #[error("duplicate data not allowed in no-dup-data operation")]
475    DuplicateDataException,
476
477    /// A secondary database integrity constraint was violated.
478    ///
479
480    #[error("secondary integrity constraint violated: {0}")]
481    SecondaryIntegrityException(String),
482
483    // ── Sequence errors ────────────────────────────────────────────────────
484    /// A sequence with the given name already exists.
485    ///
486
487    #[error("sequence already exists: {0}")]
488    SequenceExists(String),
489
490    /// A sequence with the given name was not found.
491    ///
492
493    #[error("sequence not found: {0}")]
494    SequenceNotFound(String),
495
496    /// A sequence has overflowed or underflowed its range.
497    ///
498
499    #[error("sequence overflow")]
500    SequenceOverflow,
501
502    /// A sequence integrity violation was detected.
503    ///
504
505    #[error("sequence integrity violation: {0}")]
506    SequenceIntegrity(String),
507
508    // ── Not-found / access control ─────────────────────────────────────────
509    /// A key or data item was not found.
510    #[error("not found")]
511    NotFound,
512
513    /// The database or environment is in read-only mode.
514    #[error("read-only mode")]
515    ReadOnly,
516
517    // ── HA / replication ───────────────────────────────────────────────────
518    /// A write was attempted on a replica node.
519    ///
520
521    #[error("write not allowed on replica")]
522    ReplicaWrite,
523
524    /// Insufficient replicas acknowledged the commit.
525    ///
526
527    #[error(
528        "insufficient replicas: required {required}, available {available}"
529    )]
530    InsufficientReplicas {
531        /// Acknowledgement quorum required.
532        required: u32,
533        /// Number of replicas that responded.
534        available: u32,
535    },
536
537    /// The transaction must be rolled back due to a replication state change.
538    ///
539
540    #[error("rollback required: {0}")]
541    RollbackRequired(String),
542
543    // ── Log / I/O ──────────────────────────────────────────────────────────
544    /// A log checksum mismatch was detected (potential corruption).
545    ///
546    /// Fatal: the environment will be invalidated.
547    #[error("log checksum mismatch: {0}")]
548    LogChecksumMismatch(String),
549
550    /// A log file was not found.
551    ///
552
553    #[error("log file not found: {0}")]
554    LogFileNotFound(String),
555
556    /// An I/O error occurred.
557    #[error("io error: {0}")]
558    IoError(#[from] std::io::Error),
559
560    // ── Version ─────────────────────────────────────────────────────────────
561    /// A version mismatch occurred (e.g. on-disk format vs. code version).
562    ///
563
564    #[error("version mismatch: {0}")]
565    VersionMismatch(String),
566
567    // ── General ────────────────────────────────────────────────────────────
568    /// The operation is not allowed in the current state.
569    ///
570
571    #[error("operation not allowed: {0}")]
572    OperationNotAllowed(String),
573
574    /// An illegal argument was provided to a method.
575    ///
576    /// Mirrors `IllegalArgumentException` (DB flavour).
577    #[error("illegal argument: {0}")]
578    IllegalArgument(String),
579
580    /// The operation timed out (non-lock, non-txn — e.g. network or sync).
581    #[error("operation timed out")]
582    Timeout,
583
584    /// An invalid operation was requested.
585    #[error("invalid operation: {0}")]
586    InvalidOperation(String),
587
588    /// The requested operation is recognised by the API but not yet
589    /// implemented.  The argument names the operation (for example
590    /// `"Get::SearchLte"`).
591    ///
592    /// Returned by API arms that previously fell through to a silent
593    /// `OperationStatus::NotFound`; users now see a loud, typed error
594    /// instead of a misleading miss.  Tracked in
595    /// `docs/src/internal/api-audit-2026-05-cursor.md` Finding 3.
596    #[error("operation not yet supported: {0}")]
597    Unsupported(String),
598}
599
600impl NoxuError {
601    // ── Classification helpers ─────────────────────────────────────────────
602
603    /// Returns `true` if the failed operation may be retried after aborting
604    /// the current transaction.
605    ///
606    /// Mirrors `OperationFailureException.isRetryable()`.
607    pub fn is_retryable(&self) -> bool {
608        matches!(
609            self,
610            NoxuError::LockConflict(_)
611                | NoxuError::DeadlockDetected
612                | NoxuError::LockTimeout { .. }
613                | NoxuError::LockNotAvailable
614                | NoxuError::TransactionTimeout { .. }
615                | NoxuError::LockPreempted
616        )
617    }
618
619    /// Returns `true` if this error is fatal to the environment.
620    ///
621    /// After a fatal error the environment must be closed and re-opened.
622    /// Subsequent operations on an invalidated environment will return
623    /// `EnvironmentClosed`.
624    ///
625    /// Mirrors `EnvironmentFailureException` detection + `isValid()`.
626    pub fn is_fatal_to_environment(&self) -> bool {
627        match self {
628            NoxuError::EnvironmentFailure { reason, .. } => {
629                reason.invalidates_environment()
630            }
631            NoxuError::LogChecksumMismatch(_)
632            | NoxuError::LogWriteFailure(_)
633            | NoxuError::DiskLimitExceeded { .. }
634            | NoxuError::EnvironmentWedged(_) => true,
635            _ => false,
636        }
637    }
638
639    /// Returns the `EnvironmentFailureReason` if this is an
640    /// `EnvironmentFailure` variant, `None` otherwise.
641    ///
642    /// Mirrors `EnvironmentFailureException.getReason()`.
643    pub fn reason(&self) -> Option<&EnvironmentFailureReason> {
644        match self {
645            NoxuError::EnvironmentFailure { reason, .. } => Some(reason),
646            _ => None,
647        }
648    }
649
650    /// Returns `true` if the environment log is persistently corrupted.
651    ///
652    /// Mirrors `EnvironmentFailureException.isCorrupted()`.
653    pub fn is_corrupted(&self) -> bool {
654        match self {
655            NoxuError::EnvironmentFailure { reason, .. } => {
656                reason.is_corrupted()
657            }
658            NoxuError::LogChecksumMismatch(_) => true,
659            _ => false,
660        }
661    }
662
663    /// Returns `true` if this is a lock-conflict error.
664    pub fn is_lock_conflict(&self) -> bool {
665        matches!(
666            self,
667            NoxuError::LockConflict(_)
668                | NoxuError::DeadlockDetected
669                | NoxuError::LockPreempted
670                | NoxuError::LockNotAvailable
671        )
672    }
673
674    /// Returns `true` if this is a lock or transaction timeout.
675    pub fn is_lock_timeout(&self) -> bool {
676        matches!(
677            self,
678            NoxuError::LockTimeout { .. }
679                | NoxuError::TransactionTimeout { .. }
680        )
681    }
682
683    /// Returns `true` if the named database was not found.
684    pub fn is_database_not_found(&self) -> bool {
685        matches!(self, NoxuError::DatabaseNotFound(_))
686    }
687
688    /// Returns `true` for any `OperationFailureException`-equivalent.
689    pub fn is_operation_failure(&self) -> bool {
690        self.is_retryable()
691    }
692
693    // ── Constructor helpers ────────────────────────────────────────────────
694
695    /// Creates an `EnvironmentFailure` with `UnexpectedState` reason.
696    /// Use when the specific reason is unknown.
697    pub fn environment(msg: impl Into<String>) -> Self {
698        NoxuError::EnvironmentFailure {
699            reason: EnvironmentFailureReason::UnexpectedState,
700            msg: msg.into(),
701        }
702    }
703
704    /// Creates an `EnvironmentFailure` with an explicit reason.
705    pub fn environment_with_reason(
706        reason: EnvironmentFailureReason,
707        msg: impl Into<String>,
708    ) -> Self {
709        NoxuError::EnvironmentFailure { reason, msg: msg.into() }
710    }
711
712    /// Creates an `OperationNotAllowed` error.
713    pub fn database(msg: impl Into<String>) -> Self {
714        NoxuError::OperationNotAllowed(msg.into())
715    }
716
717    /// Creates an `IllegalArgument` error.
718    pub fn invalid_argument(msg: impl Into<String>) -> Self {
719        NoxuError::IllegalArgument(msg.into())
720    }
721
722    /// Creates a `LockConflict` error.
723    pub fn lock_conflict(msg: impl Into<String>) -> Self {
724        NoxuError::LockConflict(msg.into())
725    }
726
727    /// Creates a `LockTimeout` error.
728    pub fn lock_timeout(timeout_ms: u64) -> Self {
729        NoxuError::LockTimeout { timeout_ms, detail: String::new() }
730    }
731
732    /// Creates a `DatabaseNotFound` error.
733    pub fn database_not_found(name: impl Into<String>) -> Self {
734        NoxuError::DatabaseNotFound(name.into())
735    }
736
737    /// Creates a `DiskLimitExceeded` error.
738    pub fn disk_limit_exceeded(used: u64, limit: u64) -> Self {
739        NoxuError::DiskLimitExceeded { used, limit }
740    }
741}
742
743// ── Conversions from sub-crate errors ─────────────────────────────────────
744
745impl From<noxu_dbi::DbiError> for NoxuError {
746    fn from(e: noxu_dbi::DbiError) -> Self {
747        use noxu_dbi::DbiError;
748        match e {
749            DbiError::DatabaseNotFound(s) => NoxuError::DatabaseNotFound(s),
750            DbiError::DatabaseAlreadyExists(s)
751            | DbiError::DatabaseExists(s) => {
752                NoxuError::DatabaseAlreadyExists(s)
753            }
754            DbiError::EnvironmentFailure { reason } => {
755                NoxuError::EnvironmentFailure {
756                    reason: EnvironmentFailureReason::UnexpectedState,
757                    msg: reason,
758                }
759            }
760            // Wave 1C audit cleanup (transaction-env F22): map
761            // recovery failures to a typed `EnvironmentFailure` whose
762            // `reason` carries the recovery-specific
763            // `HardRecovery`/`LogIntegrity` distinction so callers can
764            // branch on the failure shape (corrupt log vs.
765            // unexpected state) without parsing the message string.
766            DbiError::RecoveryFailure { reason } => {
767                NoxuError::EnvironmentFailure {
768                    reason: EnvironmentFailureReason::RecoveryFailure,
769                    msg: reason,
770                }
771            }
772            DbiError::EnvironmentNotOpen | DbiError::EnvironmentLocked(_) => {
773                NoxuError::EnvironmentClosed
774            }
775            DbiError::CursorClosed | DbiError::CursorNotInitialized => {
776                NoxuError::CursorClosed
777            }
778            DbiError::LockConflict(s) => NoxuError::LockConflict(s),
779            DbiError::IoError(io) => NoxuError::IoError(io),
780            DbiError::TxnError(txn_err) => NoxuError::from(txn_err),
781            DbiError::LogError(log_err) => {
782                NoxuError::OperationNotAllowed(log_err.to_string())
783            }
784            DbiError::TreeError(tree_err) => {
785                NoxuError::OperationNotAllowed(tree_err.to_string())
786            }
787            DbiError::DatabaseInUse(s) => NoxuError::OperationNotAllowed(s),
788            DbiError::OperationFailed(s) => NoxuError::OperationNotAllowed(s),
789        }
790    }
791}
792
793impl From<noxu_txn::TxnError> for NoxuError {
794    fn from(e: noxu_txn::TxnError) -> Self {
795        use noxu_txn::TxnError;
796        match e {
797            TxnError::Deadlock(_) => NoxuError::DeadlockDetected,
798            TxnError::LockConflict(s) => NoxuError::LockConflict(s),
799            TxnError::LockTimeout {
800                timeout_ms,
801                ref owner,
802                ref requester,
803                ref requested_type,
804                lsn,
805            } => NoxuError::LockTimeout {
806                timeout_ms,
807                detail: format!(
808                    " on LSN {lsn}: held by {owner}, \
809                         requested {requested_type:?} by locker {requester}"
810                ),
811            },
812            TxnError::TransactionTimeout { timeout_ms, txn_id } => {
813                NoxuError::TransactionTimeout { timeout_ms, txn_id }
814            }
815            TxnError::LockNotAvailable { .. } => NoxuError::LockNotAvailable,
816            TxnError::RangeRestart => {
817                NoxuError::LockConflict("range restart".into())
818            }
819            TxnError::InvalidTransaction { txn_id, state } => {
820                NoxuError::TransactionAborted(format!("txn {txn_id}: {state}"))
821            }
822            TxnError::StateError(s) => NoxuError::TransactionAborted(s),
823            TxnError::LogError(log_err) => {
824                NoxuError::OperationNotAllowed(log_err.to_string())
825            }
826        }
827    }
828}
829
830/// Result type for Noxu DB operations.
831pub type Result<T> = std::result::Result<T, NoxuError>;
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    #[test]
838    fn test_error_display() {
839        let err = NoxuError::DatabaseNotFound("test_db".to_string());
840        assert_eq!(err.to_string(), "database not found: test_db");
841    }
842
843    #[test]
844    fn test_environment_failure() {
845        let err = NoxuError::environment("disk full");
846        assert!(err.to_string().contains("environment failure"));
847        assert!(err.to_string().contains("disk full"));
848    }
849
850    #[test]
851    fn test_environment_failure_with_reason() {
852        let err = NoxuError::environment_with_reason(
853            EnvironmentFailureReason::LogChecksum,
854            "checksum mismatch in file 7",
855        );
856        assert!(err.to_string().contains("LOG_CHECKSUM"));
857        assert!(err.is_corrupted());
858        assert!(err.is_fatal_to_environment());
859        assert_eq!(err.reason(), Some(&EnvironmentFailureReason::LogChecksum));
860    }
861
862    #[test]
863    fn test_environment_failure_reason_invalidates() {
864        assert!(
865            EnvironmentFailureReason::LogChecksum.invalidates_environment()
866        );
867        assert!(
868            EnvironmentFailureReason::BtreeCorruption.invalidates_environment()
869        );
870        assert!(EnvironmentFailureReason::DiskLimit.invalidates_environment());
871        assert!(
872            !EnvironmentFailureReason::UnexpectedState
873                .invalidates_environment()
874        );
875        assert!(
876            !EnvironmentFailureReason::UnexpectedException
877                .invalidates_environment()
878        );
879    }
880
881    #[test]
882    fn test_environment_failure_reason_corrupted() {
883        assert!(EnvironmentFailureReason::LogChecksum.is_corrupted());
884        assert!(EnvironmentFailureReason::BtreeCorruption.is_corrupted());
885        assert!(!EnvironmentFailureReason::DiskLimit.is_corrupted());
886        assert!(!EnvironmentFailureReason::LogWrite.is_corrupted());
887    }
888
889    #[test]
890    fn test_deadlock_detected() {
891        let err = NoxuError::DeadlockDetected;
892        assert_eq!(err.to_string(), "deadlock detected");
893    }
894
895    #[test]
896    fn test_cursor_closed() {
897        let err = NoxuError::CursorClosed;
898        assert_eq!(err.to_string(), "cursor closed");
899    }
900
901    #[test]
902    fn test_io_error_conversion() {
903        let io_err =
904            std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
905        let err: NoxuError = io_err.into();
906        assert!(matches!(err, NoxuError::IoError(_)));
907    }
908
909    #[test]
910    fn test_result_type() {
911        let ok_result: Result<i32> = Ok(42);
912        assert!(ok_result.is_ok_and(|v| v == 42));
913
914        let err_result: Result<i32> = Err(NoxuError::NotFound);
915        assert!(err_result.is_err());
916    }
917
918    #[test]
919    fn test_not_found() {
920        let err = NoxuError::NotFound;
921        assert_eq!(err.to_string(), "not found");
922    }
923
924    #[test]
925    fn test_key_exists() {
926        let err = NoxuError::KeyExists;
927        assert_eq!(err.to_string(), "key already exists");
928    }
929
930    #[test]
931    fn test_timeout() {
932        let err = NoxuError::Timeout;
933        assert_eq!(err.to_string(), "operation timed out");
934    }
935
936    #[test]
937    fn test_from_dbi_error() {
938        let dbi_err = noxu_dbi::DbiError::CursorClosed;
939        let err: NoxuError = NoxuError::from(dbi_err);
940        assert!(matches!(err, NoxuError::CursorClosed));
941
942        let e: NoxuError =
943            noxu_dbi::DbiError::DatabaseNotFound("x".into()).into();
944        assert!(matches!(e, NoxuError::DatabaseNotFound(_)));
945
946        let e: NoxuError =
947            noxu_dbi::DbiError::EnvironmentFailure { reason: "disk".into() }
948                .into();
949        assert!(matches!(e, NoxuError::EnvironmentFailure { .. }));
950    }
951
952    #[test]
953    fn test_from_txn_error() {
954        use noxu_txn::{LockType, TxnError};
955
956        let e: NoxuError = TxnError::Deadlock("cycle".into()).into();
957        assert!(matches!(e, NoxuError::DeadlockDetected));
958
959        let e: NoxuError = TxnError::LockTimeout {
960            timeout_ms: 500,
961            lsn: 1,
962            owner: "t1".into(),
963            requested_type: LockType::Write,
964            requester: "t2".into(),
965        }
966        .into();
967        assert!(matches!(e, NoxuError::LockTimeout { timeout_ms: 500, .. }));
968
969        let e: NoxuError =
970            TxnError::TransactionTimeout { timeout_ms: 1000, txn_id: 42 }
971                .into();
972        assert!(matches!(
973            e,
974            NoxuError::TransactionTimeout { timeout_ms: 1000, txn_id: 42 }
975        ));
976
977        // LockNotAvailable maps to NoxuError::LockNotAvailable (not LockConflict)
978        let e: NoxuError = TxnError::LockNotAvailable { lsn: 0 }.into();
979        assert!(matches!(e, NoxuError::LockNotAvailable));
980    }
981
982    #[test]
983    fn test_is_retryable() {
984        assert!(NoxuError::DeadlockDetected.is_retryable());
985        assert!(NoxuError::LockConflict("x".into()).is_retryable());
986        assert!(
987            NoxuError::LockTimeout { timeout_ms: 500, detail: String::new() }
988                .is_retryable()
989        );
990        assert!(
991            NoxuError::TransactionTimeout { timeout_ms: 1000, txn_id: 1 }
992                .is_retryable()
993        );
994        assert!(NoxuError::LockPreempted.is_retryable());
995        assert!(NoxuError::LockNotAvailable.is_retryable());
996
997        assert!(!NoxuError::NotFound.is_retryable());
998        assert!(!NoxuError::environment("x").is_retryable());
999        assert!(!NoxuError::DatabaseClosed.is_retryable());
1000    }
1001
1002    #[test]
1003    fn test_is_fatal_to_environment() {
1004        assert!(
1005            NoxuError::environment_with_reason(
1006                EnvironmentFailureReason::LogChecksum,
1007                "x"
1008            )
1009            .is_fatal_to_environment()
1010        );
1011        assert!(
1012            NoxuError::LogChecksumMismatch("bad".into())
1013                .is_fatal_to_environment()
1014        );
1015        assert!(
1016            NoxuError::LogWriteFailure("io".into()).is_fatal_to_environment()
1017        );
1018        assert!(
1019            NoxuError::DiskLimitExceeded { used: 100, limit: 50 }
1020                .is_fatal_to_environment()
1021        );
1022        assert!(
1023            NoxuError::EnvironmentWedged("x".into()).is_fatal_to_environment()
1024        );
1025
1026        // Non-fatal EnvironmentFailure variants
1027        assert!(
1028            !NoxuError::environment_with_reason(
1029                EnvironmentFailureReason::UnexpectedState,
1030                "x"
1031            )
1032            .is_fatal_to_environment()
1033        );
1034
1035        assert!(!NoxuError::DeadlockDetected.is_fatal_to_environment());
1036        assert!(!NoxuError::NotFound.is_fatal_to_environment());
1037        assert!(!NoxuError::LockConflict("x".into()).is_fatal_to_environment());
1038    }
1039
1040    #[test]
1041    fn test_new_variants() {
1042        let e =
1043            NoxuError::LockTimeout { timeout_ms: 250, detail: String::new() };
1044        assert!(e.to_string().contains("250ms"));
1045
1046        let e = NoxuError::TransactionTimeout { timeout_ms: 1000, txn_id: 7 };
1047        assert!(e.to_string().contains("1000ms"));
1048        assert!(e.to_string().contains("7"));
1049
1050        let e = NoxuError::LockPreempted;
1051        assert!(e.to_string().contains("preempted"));
1052
1053        let e = NoxuError::LockNotAvailable;
1054        assert!(e.to_string().contains("no-wait"));
1055
1056        let e = NoxuError::UniqueConstraintViolation("idx_email".into());
1057        assert!(e.to_string().contains("unique constraint"));
1058
1059        let e = NoxuError::DeleteConstraintViolation("key=42".into());
1060        assert!(e.to_string().contains("delete constraint"));
1061
1062        let e = NoxuError::ForeignConstraintViolation("fk_user".into());
1063        assert!(e.to_string().contains("foreign constraint"));
1064
1065        let e = NoxuError::DuplicateDataException;
1066        assert!(e.to_string().contains("duplicate data"));
1067
1068        let e = NoxuError::ReplicaWrite;
1069        assert!(e.to_string().contains("replica"));
1070
1071        let e = NoxuError::InsufficientReplicas { required: 3, available: 1 };
1072        assert!(e.to_string().contains("required 3"));
1073        assert!(e.to_string().contains("available 1"));
1074
1075        let e = NoxuError::RollbackRequired("ha failover".into());
1076        assert!(e.to_string().contains("rollback required"));
1077
1078        let e = NoxuError::LogChecksumMismatch("file 7".into());
1079        assert!(e.to_string().contains("log checksum mismatch"));
1080
1081        let e = NoxuError::LogFileNotFound("00000007.ndb".into());
1082        assert!(e.to_string().contains("log file not found"));
1083
1084        let e = NoxuError::EnvironmentWedged("perm fail".into());
1085        assert!(e.to_string().contains("permanent failure"));
1086
1087        let e = NoxuError::EnvironmentNotFound("/bad/path".into());
1088        assert!(e.to_string().contains("not found"));
1089
1090        let e = NoxuError::EnvironmentLocked("/data".into());
1091        assert!(e.to_string().contains("locked"));
1092
1093        let e = NoxuError::LogWriteFailure("ENOSPC".into());
1094        assert!(e.to_string().contains("log write failure"));
1095
1096        let e = NoxuError::DiskLimitExceeded { used: 1000, limit: 500 };
1097        assert!(e.to_string().contains("used=1000"));
1098        assert!(e.to_string().contains("limit=500"));
1099
1100        let e = NoxuError::ThreadInterrupted;
1101        assert!(e.to_string().contains("interrupted"));
1102    }
1103
1104    #[test]
1105    fn test_sequence_variants() {
1106        let e = NoxuError::SequenceExists("counter".into());
1107        assert!(e.to_string().contains("counter"));
1108
1109        let e = NoxuError::SequenceNotFound("counter".into());
1110        assert!(e.to_string().contains("sequence not found"));
1111
1112        let e = NoxuError::SequenceOverflow;
1113        assert!(e.to_string().contains("overflow"));
1114
1115        let e = NoxuError::SequenceIntegrity("bad state".into());
1116        assert!(e.to_string().contains("integrity"));
1117    }
1118
1119    #[test]
1120    fn test_database_already_exists() {
1121        let e = NoxuError::DatabaseAlreadyExists("mydb".into());
1122        assert!(e.to_string().contains("mydb"));
1123    }
1124
1125    #[test]
1126    fn test_lock_conflict() {
1127        let e = NoxuError::LockConflict("timeout".into());
1128        assert!(e.to_string().contains("lock conflict"));
1129    }
1130
1131    #[test]
1132    fn test_transaction_aborted() {
1133        let e = NoxuError::TransactionAborted("rolled back".into());
1134        assert!(e.to_string().contains("transaction aborted"));
1135    }
1136
1137    #[test]
1138    fn test_helpers() {
1139        assert!(matches!(
1140            NoxuError::environment("x"),
1141            NoxuError::EnvironmentFailure { .. }
1142        ));
1143        assert!(matches!(
1144            NoxuError::database("x"),
1145            NoxuError::OperationNotAllowed(_)
1146        ));
1147        assert!(matches!(
1148            NoxuError::invalid_argument("x"),
1149            NoxuError::IllegalArgument(_)
1150        ));
1151        assert!(matches!(
1152            NoxuError::lock_conflict("x"),
1153            NoxuError::LockConflict(_)
1154        ));
1155        assert!(matches!(
1156            NoxuError::lock_timeout(500),
1157            NoxuError::LockTimeout { timeout_ms: 500, .. }
1158        ));
1159        assert!(matches!(
1160            NoxuError::database_not_found("db"),
1161            NoxuError::DatabaseNotFound(_)
1162        ));
1163        assert!(matches!(
1164            NoxuError::disk_limit_exceeded(100, 50),
1165            NoxuError::DiskLimitExceeded { used: 100, limit: 50 }
1166        ));
1167    }
1168
1169    #[test]
1170    fn test_is_lock_conflict() {
1171        assert!(NoxuError::LockConflict("x".into()).is_lock_conflict());
1172        assert!(NoxuError::DeadlockDetected.is_lock_conflict());
1173        assert!(NoxuError::LockPreempted.is_lock_conflict());
1174        assert!(NoxuError::LockNotAvailable.is_lock_conflict());
1175        assert!(
1176            !NoxuError::LockTimeout { timeout_ms: 500, detail: String::new() }
1177                .is_lock_conflict()
1178        );
1179        assert!(!NoxuError::NotFound.is_lock_conflict());
1180    }
1181
1182    #[test]
1183    fn test_is_lock_timeout() {
1184        assert!(
1185            NoxuError::LockTimeout { timeout_ms: 500, detail: String::new() }
1186                .is_lock_timeout()
1187        );
1188        assert!(
1189            NoxuError::TransactionTimeout { timeout_ms: 1000, txn_id: 1 }
1190                .is_lock_timeout()
1191        );
1192        assert!(!NoxuError::LockConflict("x".into()).is_lock_timeout());
1193        assert!(!NoxuError::NotFound.is_lock_timeout());
1194    }
1195
1196    #[test]
1197    fn test_is_database_not_found() {
1198        assert!(
1199            NoxuError::DatabaseNotFound("mydb".into()).is_database_not_found()
1200        );
1201        assert!(!NoxuError::DatabaseClosed.is_database_not_found());
1202        assert!(!NoxuError::NotFound.is_database_not_found());
1203    }
1204
1205    #[test]
1206    fn test_exception_event() {
1207        let evt = ExceptionEvent::new(
1208            "cleaner failed",
1209            ExceptionSource::Cleaner,
1210            "cleaner-1",
1211        );
1212        assert_eq!(evt.message, "cleaner failed");
1213        assert_eq!(evt.source, ExceptionSource::Cleaner);
1214        assert_eq!(evt.thread_name, "cleaner-1");
1215        assert!(evt.source.to_string().contains("Cleaner"));
1216    }
1217
1218    #[test]
1219    fn test_exception_listener_trait() {
1220        struct NoopListener;
1221        impl ExceptionListener for NoopListener {
1222            fn exception_event(&self, _event: &ExceptionEvent) {}
1223        }
1224        let listener: Box<dyn ExceptionListener> = Box::new(NoopListener);
1225        let evt =
1226            ExceptionEvent::new("x", ExceptionSource::Checkpointer, "ckpt");
1227        listener.exception_event(&evt);
1228    }
1229
1230    #[test]
1231    fn test_reason_display() {
1232        assert_eq!(
1233            EnvironmentFailureReason::LogChecksum.to_string(),
1234            "LOG_CHECKSUM"
1235        );
1236        assert_eq!(
1237            EnvironmentFailureReason::BtreeCorruption.to_string(),
1238            "BTREE_CORRUPTION"
1239        );
1240        assert_eq!(
1241            EnvironmentFailureReason::DiskLimit.to_string(),
1242            "DISK_LIMIT"
1243        );
1244        assert_eq!(
1245            EnvironmentFailureReason::Other("CUSTOM".into()).to_string(),
1246            "CUSTOM"
1247        );
1248    }
1249}