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