Skip to main content

noxu_db/
database.rs

1//! Database handle.
2//!
3
4use crate::cursor::Cursor;
5use crate::cursor_config::CursorConfig;
6use crate::database_config::DatabaseConfig;
7use crate::database_entry::DatabaseEntry;
8use crate::database_stats::{BtreeStats, DatabaseStats};
9use crate::error::{NoxuError, Result};
10use crate::join_config::JoinConfig;
11use crate::join_cursor::JoinCursor;
12use crate::lock_mode::LockMode;
13use crate::operation_status::OperationStatus;
14use crate::preload::{PreloadConfig, PreloadStats};
15use crate::read_options::ReadOptions;
16use crate::secondary_cursor::SecondaryCursor;
17use crate::sequence::Sequence;
18use crate::sequence_config::SequenceConfig;
19use crate::stats_config::StatsConfig;
20use crate::transaction::Transaction;
21use crate::write_options::WriteOptions;
22use bytes::Bytes;
23use noxu_dbi::{
24    CursorImpl, DatabaseImpl, EnvironmentImpl, GetMode, PutMode, SearchMode,
25    ThroughputStats,
26};
27use noxu_log::LogManager;
28use noxu_sync::Mutex;
29// DST: `db_impl` is passed into `CursorImpl`, whose `db_impl` RwLock routes
30// through the `noxu_util::dst_sync_pl` seam (cursor shuttle gate). To keep the
31// types matching under `--cfg noxu_shuttle`, `Database`'s RwLock-typed fields
32// use the same seam. Under the default cfg `dst_sync_pl::RwLock` *is*
33// `noxu_sync::RwLock` (transparent re-export), so production is byte-identical.
34use noxu_txn::{Durability, LockManager, TxnManager, UndoRecord};
35use noxu_util::dst_sync_pl::RwLock;
36use noxu_util::lsn::Lsn;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39
40/// A database handle.
41///
42///
43///
44/// Database handles provide methods for inserting, retrieving, and
45/// deleting records. A database belongs to a single environment.
46///
47/// # Example
48/// ```ignore
49/// use noxu_db::{Environment, EnvironmentConfig, DatabaseConfig, DatabaseEntry};
50/// use std::path::PathBuf;
51///
52/// let env_config = EnvironmentConfig::new(PathBuf::from("/tmp/mydb"))
53///     .allow_create(true);
54/// let env = Environment::open(env_config).unwrap();
55///
56/// let db_config = DatabaseConfig::new().allow_create(true);
57/// let db = env.open_database(None, "mydb", &db_config).unwrap();
58///
59/// let key = DatabaseEntry::from_bytes(b"key1");
60/// let value = DatabaseEntry::from_bytes(b"value1");
61/// db.put( &key, &value).unwrap();
62///
63/// db.close().unwrap();
64/// env.close().unwrap();
65/// ```
66pub struct Database {
67    /// Name of this database
68    name: String,
69    /// Database ID
70    id: u64,
71    /// Configuration
72    config: DatabaseConfig,
73    /// The underlying DatabaseImpl (shared with the EnvironmentImpl).
74    pub(crate) db_impl: Arc<RwLock<DatabaseImpl>>,
75    /// Back-reference to the owning EnvironmentImpl (for close/cleanup).
76    env_impl: Arc<Mutex<EnvironmentImpl>>,
77    /// Shared open flag — same `Arc<AtomicBool>` as the environment's
78    /// `DatabaseHandle.open`, so that `Database::close()` automatically
79    /// marks the environment-side handle as closed too.
80    open: Arc<AtomicBool>,
81    /// Throughput counters for this database's operations.
82    ///
83    /// Cloned from `DatabaseImpl.throughput` at open time so that
84    /// `get()`, `put()`, `delete()` can increment stats without
85    /// locking `db_impl`.
86    throughput: Arc<ThroughputStats>,
87    /// Cached lock manager — acquired once at open, never changes.
88    /// Eliminates per-operation `env_impl.lock()` on the hot read/write path.
89    lock_manager: Arc<LockManager>,
90    /// Cached log manager — acquired once at open, None for no-WAL envs.
91    /// Eliminates per-operation `env_impl.lock()` on the hot read/write path.
92    log_manager: Option<Arc<LogManager>>,
93    /// Cached disk-limit tracker (JE: the env's `getDiskLimitViolation()`).
94    /// `Some` only for user databases; internal databases are exempt from the
95    /// limit so the cleaner/checkpointer can still write to free space.
96    /// Wired into each user cursor so the write path can refuse writes while a
97    /// disk limit is violated without locking `env_impl`.
98    disk_limit: Option<Arc<noxu_dbi::disk_limit::DiskLimitTracker>>,
99    /// Cached environment-invalidity flag (X-13).
100    ///
101    /// Cloned from `EnvironmentImpl::is_invalid_flag()` at `Database::new()`
102    /// time so `check_open()` can detect a failed environment without
103    /// acquiring `env_impl.lock()` on every read/write operation.
104    env_invalid: Arc<std::sync::atomic::AtomicBool>,
105    /// Cached cleaner throttle — acquired once at open, None when no cleaner.
106    /// Used by put() for write-path backpressure without locking env_impl.
107    cleaner_throttle: Option<Arc<noxu_cleaner::CleanerThrottle>>,
108    /// Cached cleaner file protector — acquired once at open, None when no
109    /// cleaner.  Passed to a `DiskOrderedCursor` producer so it can protect
110    /// the files it scans from cleaner deletion mid-scan (CLN-7).
111    file_protector: Option<Arc<noxu_cleaner::FileProtector>>,
112    /// Cached transaction manager — acquired once at open.
113    ///
114    /// Used by [`Self::with_auto_txn`] to allocate a synthetic auto-commit
115    /// `Txn` per `txn = None` write so the lock manager sees a typed
116    /// locker id from the explicit-txn id space (`"auto-txn:<id>"` in
117    /// deadlock messages) and the auto-commit op gets full abort-undo
118    /// semantics on any error path.  Closes the F12 residuals.
119    txn_manager: Arc<TxnManager>,
120    /// EV-15: cached evictor — acquired once at open, drives per-write
121    /// synchronous critical eviction (write back-pressure) without locking
122    /// env_impl.  Mirrors JE `EnvironmentImpl.criticalEviction` being called
123    /// before every cursor operation.
124    evictor: Arc<noxu_dbi::Evictor>,
125    /// If true, auto-commit writes skip the log flush entirely (: TXN_NO_SYNC).
126    no_sync: bool,
127    /// If true, auto-commit writes flush to OS but skip fdatasync (: TXN_WRITE_NO_SYNC).
128    write_no_sync: bool,
129    /// Registered secondary indexes that automatically maintain themselves
130    /// when this primary is written.  v1.6 (Decision 1B / audit C3 — the
131    /// associate()-style hook): every [`SecondaryDatabase`] opened against
132    /// this primary downgrades its `Arc<SecondaryHookState>` to a
133    /// `Weak<dyn SecondaryHook>` and pushes it here.  `Database::put` and
134    /// `Database::delete` walk the list under the same caller-supplied
135    /// txn so primary writes and secondary index updates commit / abort
136    /// atomically.
137    ///
138    /// Stored behind an `Arc<RwLock<…>>` (rather than directly on the
139    /// `Database` body) so registrations performed through one of the
140    /// `Arc<Mutex<Database>>` clones the user typically holds become
141    /// visible to every other clone of the same primary.
142    pub(crate) secondaries: Arc<
143        RwLock<
144            Vec<
145                std::sync::Weak<
146                    dyn crate::secondary_database::SecondaryHook + Send + Sync,
147                >,
148            >,
149        >,
150    >,
151    /// Foreign-key referrer registry: every child secondary whose
152    /// `foreign_key_database` points at *this* primary downgrades its
153    /// hook to a `Weak<dyn FkReferrer>` and pushes it here.  When this
154    /// primary is deleted, every entry is consulted to apply
155    /// `ForeignKeyDeleteAction::Abort` (v1.6 step 8) /
156    /// `Cascade` (step 9) / `Nullify` (step 10).
157    pub(crate) fk_referrers: Arc<
158        RwLock<
159            Vec<
160                std::sync::Weak<
161                    dyn crate::secondary_database::FkReferrer + Send + Sync,
162                >,
163            >,
164        >,
165    >,
166}
167
168/// State of a database handle.
169///
170///
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum DbState {
173    /// Database is open and operational
174    Open,
175    /// Database has been closed
176    Closed,
177    /// Database is in an invalid state
178    Invalid,
179}
180
181impl Database {
182    /// Creates a CursorImpl, wired to the WAL and lock manager when the
183    /// environment has them.
184    ///
185    /// Uses cached `lock_manager` / `log_manager` to avoid acquiring
186    /// `env_impl.lock()` on every operation.
187    fn make_cursor(&self) -> CursorImpl {
188        self.make_cursor_with_locker(0)
189    }
190
191    /// Creates a CursorImpl with an explicit `locker_id`.
192    ///
193    /// Auto-commit cursors use `0`; transactional cursors must use the
194    /// owning `Transaction::id` so that the LN log entries written by
195    /// `cursor.put` / `cursor.delete` carry the txn id and recovery's
196    /// commit/abort tracking can correctly skip aborted txns.  Without
197    /// this, every LN entry was written with `txn_id = None` (the
198    /// auto-commit form) and recovery treated aborted-txn writes as
199    /// committed once `env_impl.close()` started running on `env.close()`
200    /// after a successful commit/abort (F1).
201    fn make_cursor_with_locker(&self, locker_id: i64) -> CursorImpl {
202        match &self.log_manager {
203            Some(lm) => {
204                let mut c = CursorImpl::with_log_manager(
205                    Arc::clone(&self.db_impl),
206                    locker_id,
207                    Arc::clone(lm),
208                )
209                .with_env_invalid(Arc::clone(&self.env_invalid))
210                .with_lock_manager(Arc::clone(&self.lock_manager))
211                .with_txn_manager(Arc::clone(&self.txn_manager));
212                // Gate user writes on the disk limit (None for internal DBs).
213                if let Some(dl) = &self.disk_limit {
214                    c = c.with_disk_limit(Arc::clone(dl));
215                }
216                c
217            }
218            None => CursorImpl::new(Arc::clone(&self.db_impl), locker_id)
219                .with_env_invalid(Arc::clone(&self.env_invalid))
220                .with_lock_manager(Arc::clone(&self.lock_manager)),
221        }
222    }
223
224    /// Creates a CursorImpl without a lock manager (dirty-read / read-uncommitted).
225    ///
226    /// Used by `get_with_options()` when `ReadOptions.lock_mode == ReadUncommitted`.
227    /// Skips all lock acquisition so the cursor reads directly from the BIN
228    /// without blocking on write locks — mirrors 's read-uncommitted cursor.
229    fn make_cursor_no_lock(&self) -> CursorImpl {
230        match &self.log_manager {
231            Some(lm) => CursorImpl::with_log_manager(
232                Arc::clone(&self.db_impl),
233                0,
234                Arc::clone(lm),
235            )
236            .with_env_invalid(Arc::clone(&self.env_invalid)),
237            None => CursorImpl::new(Arc::clone(&self.db_impl), 0)
238                .with_env_invalid(Arc::clone(&self.env_invalid)),
239        }
240    }
241
242    /// Creates a CursorImpl wired to the given transaction for write-lock tracking.
243    ///
244    /// Behaves like `make_cursor()` but additionally calls `.with_txn()` so
245    /// that write operations acquire locks via the transaction's `Txn` and
246    /// record abort before-images in `WriteLockInfo`.
247    ///
248    /// In which passes the
249    /// transaction's `Locker` to the new `CursorImpl`.
250    fn make_cursor_for_txn(&self, txn: &Transaction) -> CursorImpl {
251        // Use the transaction id as the cursor's locker_id so that LN
252        // log entries written under this cursor carry the txn id
253        // (recovery's analysis pass uses LN.txn_id together with
254        // TxnCommit / TxnAbort records to decide whether to redo or
255        // undo the LN). Pre-fix this was hardcoded to 0, which made
256        // every txn-LN look like an auto-commit LN and caused
257        // recovery to redo aborted writes.
258        let cursor = self.make_cursor_with_locker(txn.id() as i64);
259        if let Some(inner) = txn.get_inner_txn() {
260            cursor.with_txn(inner)
261        } else {
262            cursor
263        }
264    }
265
266    /// Allocates a synthetic auto-commit `Txn` and runs `op` under it.
267    ///
268    /// `op` receives an auto-commit-wired [`CursorImpl`] (locker_id = 0
269    /// so the LN is logged with the auto-commit `InsertLN` / `DeleteLN`
270    /// form, txn_ref set so the lock manager sees the synthetic auto-txn
271    /// as the owner).
272    ///
273    /// On `Ok(value)`:
274    ///   * Drops the cursor (closing it).
275    ///   * Calls [`Txn::commit_with_durability`] on the synthetic auto-txn
276    ///     with a [`Durability`] derived from the database's
277    ///     `no_sync` / `write_no_sync` config; this releases all locks
278    ///     and (for `CommitSync`) fsyncs up to the LN's LSN via
279    ///     `LogManager::flush_sync_if_needed` for many-to-one fsync
280    ///     coalescing under concurrent write load.
281    ///   * Records the commit in the txn manager statistics and removes
282    ///     the diagnostic locker label.
283    ///
284    /// On `Err(e)`:
285    ///   * Drops the cursor.
286    ///   * Calls [`Txn::abort_collect_undo`] to harvest before-image undo
287    ///     records WITHOUT releasing the held write locks (so a reader
288    ///     blocked on a write lock cannot observe the in-flight value
289    ///     before we restore the before-image).
290    ///   * Applies the undo records to the in-memory B-tree.
291    ///   * Calls [`Txn::release_all_locks`] to drain the held locks.
292    ///   * Records the abort in the txn manager statistics and removes
293    ///     the diagnostic locker label.
294    ///   * Returns `Err(e)`.
295    ///
296    /// Closes the first F12 residual: an auto-commit op now goes through
297    /// the same lock-tracking and abort-undo machinery as an explicit
298    /// transaction, so two concurrent inserts of the same brand-new key
299    /// serialise through the lock manager and a forced mid-write failure
300    /// rolls back the in-memory tree mutation.
301    fn with_auto_txn<F, T>(&self, op: F) -> Result<T>
302    where
303        F: FnOnce(&mut CursorImpl) -> Result<T>,
304    {
305        let mut auto_txn =
306            self.txn_manager.begin_auto_txn(self.log_manager.clone());
307        // An auto-commit locker on a non-replicated database defaults to
308        // local-write=true (writes are local since there's nothing to
309        // replicate); on a replicated database it is local-write=false so
310        // the write replicates normally, matching how an explicit
311        // transaction on a replicated database behaves by default.
312        auto_txn.set_local_write(!self.db_impl.read().is_replicated());
313        let synthetic_id = auto_txn.id_as_locker();
314        let auto_txn_arc = Arc::new(std::sync::Mutex::new(auto_txn));
315
316        let mut cursor = self.make_cursor();
317        cursor.attach_txn(Arc::clone(&auto_txn_arc));
318
319        let result = op(&mut cursor);
320        // Drop the cursor handle (un-pins BIN, drops Arc<Mutex<Txn>> ref).
321        drop(cursor);
322
323        // Reclaim sole ownership of the synthetic auto-txn so we can
324        // finalise it.  All cursors and their Arcs were dropped above.
325        let mut auto_txn = match Arc::try_unwrap(auto_txn_arc) {
326            Ok(m) => m.into_inner().unwrap_or_else(|p| p.into_inner()),
327            Err(_arc) => {
328                // A cursor escaped the closure with a clone of the txn
329                // Arc.  This is a caller-side bug — leak the auto-txn
330                // (its Drop calls `close()` which aborts) and surface a
331                // typed error.  No undo applied because we cannot
332                // safely take the Txn out of the shared Arc.
333                return Err(NoxuError::OperationNotAllowed(
334                    "with_auto_txn: synthetic auto-txn outlived cursor scope"
335                        .to_string(),
336                ));
337            }
338        };
339        let txn_manager = Arc::clone(&self.txn_manager);
340
341        match result {
342            Ok(value) => {
343                let durability = if self.no_sync {
344                    Durability::CommitNoSync
345                } else if self.write_no_sync {
346                    Durability::CommitWriteNoSync
347                } else {
348                    Durability::CommitSync
349                };
350                if let Err(e) = auto_txn.commit_with_durability(durability) {
351                    // Commit failed (e.g. log fsync error).  Undo the
352                    // in-memory tree write so we are not left in an
353                    // inconsistent state, then surface the error.
354                    let undo_records =
355                        auto_txn.abort_collect_undo().unwrap_or_default();
356                    self.apply_auto_txn_undo(undo_records);
357                    auto_txn.release_all_locks();
358                    txn_manager.abort_txn(synthetic_id);
359                    return Err(NoxuError::OperationNotAllowed(format!(
360                        "auto-commit fsync failed: {e}"
361                    )));
362                }
363                txn_manager.commit_txn(synthetic_id);
364                Ok(value)
365            }
366            Err(e) => {
367                // Phase 1: collect undo without releasing write locks.
368                let undo_records =
369                    auto_txn.abort_collect_undo().unwrap_or_default();
370                // Phase 2: apply undo while write locks are still held
371                // so any concurrent reader blocked on a write lock
372                // cannot observe the in-flight value.
373                self.apply_auto_txn_undo(undo_records);
374                // Phase 3: drain locks.
375                auto_txn.release_all_locks();
376                txn_manager.abort_txn(synthetic_id);
377                Err(e)
378            }
379        }
380    }
381
382    /// Applies undo records collected from a synthetic auto-txn to the
383    /// in-memory B-tree of `self`.  Mirrors the per-`undo_record` block
384    /// in `Transaction::abort()` but specialised for the
385    /// single-database `with_auto_txn` case so we do not need to thread
386    /// the env-impl in.
387    fn apply_auto_txn_undo(&self, mut undo_records: Vec<UndoRecord>) {
388        // Apply newest-LSN first so multi-step writes (delete + reinsert)
389        // unwind in reverse-operation order.  See the matching sort in
390        // `Transaction::abort()`.
391        undo_records.sort_by_key(|r| std::cmp::Reverse(r.current_lsn));
392        let db_id_match = self.id;
393        let db_guard = self.db_impl.read();
394        let Some(tree) = db_guard.get_real_tree() else { return };
395        for undo in undo_records {
396            // The synthetic auto-txn touches only this database, but be
397            // defensive in case future changes broaden the contract.
398            if undo.database_id != db_id_match {
399                continue;
400            }
401            let Some(abort_key) = undo.abort_key else { continue };
402            if undo.abort_known_deleted {
403                if tree.delete(&abort_key) {
404                    db_guard.decrement_entry_count();
405                }
406            } else if let Some(abort_data) = undo.abort_data {
407                let lsn = noxu_util::Lsn::from_u64(undo.abort_lsn);
408                if let Ok(is_new) = tree.insert(abort_key, abort_data, lsn)
409                    && is_new
410                {
411                    // Restoring a slot that the aborted txn had deleted:
412                    // re-bump the counter that the in-memory delete
413                    // already decremented.
414                    db_guard.increment_entry_count();
415                }
416            }
417        }
418    }
419
420    /// Auto-commit flush: when `txn` is `None` (auto-commit mode), flush and
421    /// fsync the log before returning to the caller.
422    ///
423    /// `write_lsn` is the LSN assigned to the write operation just performed.
424    /// Port of`LogManager.flushTo(lsn)`: if a concurrent committer already
425    /// flushed past `write_lsn`, the fdatasync is skipped entirely, giving
426    /// natural many:1 fsync coalescing under concurrent write load with no
427    /// explicit group-commit configuration required.
428    #[allow(dead_code)] // unwired helper kept for the documented coalescing path
429    fn auto_commit_sync(
430        &self,
431        txn: Option<&Transaction>,
432        write_lsn: Lsn,
433    ) -> Result<()> {
434        if txn.is_some() {
435            return Ok(()); // explicit txn handles its own commit/fsync
436        }
437        if self.no_sync {
438            return Ok(()); // : TXN_NO_SYNC — skip log flush entirely
439        }
440        if let Some(lm) = &self.log_manager {
441            if self.write_no_sync {
442                // : TXN_WRITE_NO_SYNC — flush to OS buffer, no fdatasync
443                lm.flush_no_sync().map_err(|e| {
444                    NoxuError::OperationNotAllowed(e.to_string())
445                })?;
446            } else {
447                // : flushTo(lsn) — skip if already covered by another flush.
448                lm.flush_sync_if_needed(write_lsn).map_err(|e| {
449                    NoxuError::OperationNotAllowed(e.to_string())
450                })?;
451            }
452        }
453        Ok(())
454    }
455
456    /// Creates a new database handle.
457    ///
458    /// Internal constructor called by Environment.
459    ///
460    /// `open_flag` is a shared `Arc<AtomicBool>` that is also stored in the
461    /// environment's `DatabaseHandle` for this database.  Setting it to `false`
462    /// (via `Database::close()`) simultaneously marks the env-side handle as
463    /// closed, allowing `Environment::close()` to succeed without a separate
464    /// callback.
465    pub(crate) fn new(
466        name: String,
467        id: u64,
468        config: DatabaseConfig,
469        db_impl: Arc<RwLock<DatabaseImpl>>,
470        env_impl: Arc<Mutex<EnvironmentImpl>>,
471        open_flag: Arc<AtomicBool>,
472        no_sync: bool,
473        write_no_sync: bool,
474    ) -> Self {
475        let throughput = db_impl.read().throughput.clone();
476        // Cache the manager Arcs at construction so hot-path operations
477        // (get/put/delete) never need to re-acquire env_impl.lock().
478        let (
479            lock_manager,
480            log_manager,
481            cleaner_throttle,
482            file_protector,
483            txn_manager,
484            env_invalid,
485            evictor,
486        ) = {
487            let env = env_impl.lock();
488            let lm = Arc::clone(env.get_lock_manager());
489            let logm = env.get_log_manager();
490            let ct = env.get_cleaner_throttle();
491            let fp = env.get_file_protector();
492            let txnm = Arc::clone(env.get_txn_manager());
493            let inv = env.is_invalid_flag();
494            let ev = env.get_evictor();
495            (lm, logm, ct, fp, txnm, inv, ev)
496        };
497        // Disk-limit tracker: wire it only for user databases (JE exempts
498        // internal DBs in Cursor.checkUpdatesAllowed via
499        // dbImpl.getDbType().isInternal()). Internal DBs leave this None so
500        // the cleaner/checkpointer/recovery writes through them are never
501        // blocked by the limit.
502        let disk_limit = if db_impl.read().get_db_type().is_internal() {
503            None
504        } else {
505            Some(env_impl.lock().get_disk_limit())
506        };
507        Database {
508            name,
509            id,
510            config,
511            db_impl,
512            env_impl,
513            open: open_flag,
514            throughput,
515            lock_manager,
516            log_manager,
517            disk_limit,
518            env_invalid,
519            cleaner_throttle,
520            file_protector,
521            txn_manager,
522            evictor,
523            no_sync,
524            write_no_sync,
525            secondaries: Arc::new(RwLock::new(Vec::new())),
526            fk_referrers: Arc::new(RwLock::new(Vec::new())),
527        }
528    }
529
530    /// Retrieves a record by key, auto-committing the read.
531    ///
532    /// Idiomatic Rust lookup: `Some(value)` if found, `None` if the key
533    /// is absent, `Err` only on a real failure (review P0-3). Keys accept
534    /// any `impl AsRef<[u8]>` (review P1-3) — `b"k"`, `&str`, `Vec<u8>`,
535    /// `Bytes`, etc., no `DatabaseEntry` wrapper required.
536    ///
537    /// For an explicit transaction use [`Self::get_in`]; for zero-alloc
538    /// buffer reuse or partial reads use [`Self::get_into`].
539    ///
540    /// # Errors
541    /// Returns an error if the database is closed or the environment failed.
542    pub fn get(&self, key: impl AsRef<[u8]>) -> Result<Option<Bytes>> {
543        self.get_bytes(None, key.as_ref())
544    }
545
546    /// Retrieves a record by key within an explicit transaction (review
547    /// P0-2: auto-commit vs transactional is a *named* choice, not a bare
548    /// `None`).
549    ///
550    /// # Errors
551    /// Returns an error if the database is closed, the environment failed,
552    /// or a transactional handle is used against a non-transactional DB.
553    pub fn get_in(
554        &self,
555        txn: &Transaction,
556        key: impl AsRef<[u8]>,
557    ) -> Result<Option<Bytes>> {
558        self.get_bytes(Some(txn), key.as_ref())
559    }
560
561    /// Shared `Result<Option<Bytes>>` read used by [`Self::get`] /
562    /// [`Self::get_in`] (review P0-3). Semantics are identical to the
563    /// pre-7.0 `get` out-param path — found = `Some`, NotFound = `None`,
564    /// error = `Err` — only the *shape* changed.
565    fn get_bytes(
566        &self,
567        txn: Option<&Transaction>,
568        key_bytes: &[u8],
569    ) -> Result<Option<Bytes>> {
570        self.check_open()?;
571        self.reject_txn_on_non_txnal_db(txn.is_some())?;
572        // EV-15 (read path): per-read synchronous critical eviction, matching
573        // JE `Cursor.beginMoveCursor` -> `CursorImpl.criticalEviction` which
574        // runs before EVERY cursor operation, reads included (Cursor.java
575        // :5169, CursorImpl.java:252/568/...).  Previously only the WRITE path
576        // (`put_bytes`) called this; a pure-READ workload on a dataset larger
577        // than the cache therefore had NO foreground back-pressure.  Every
578        // cold LN fault faults + re-fetches a BIN (charging the budget) with
579        // only the single background daemon to reclaim, which cannot keep pace
580        // with N reader threads -- so `cache_usage` (and RSS) grew far past
581        // the configured budget and did not plateau.  Draining a bounded
582        // eviction batch here, gated on `need_critical_eviction()` (only fires
583        // when critically over budget, so cache-resident reads pay nothing),
584        // is the JE-faithful foreground back-pressure that holds RSS at the
585        // cache size on a read-only workload.
586        self.evictor.do_critical_eviction();
587        observe_span!(
588            "db_get",
589            db_name = self.name.as_str(),
590            key_size = key_bytes.len(),
591        );
592        let _obs_timer = observe_timer_start!();
593        observe_counter!("noxu_db_operations_total", "op" => "get");
594
595        let mut cursor = match txn {
596            Some(t) => self.make_cursor_for_txn(t),
597            None => self.make_cursor(),
598        };
599        match cursor
600            .search(key_bytes, None, SearchMode::Set)
601            .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?
602        {
603            noxu_dbi::OperationStatus::Success => {
604                let (_, value) = cursor.get_current().map_err(|e| {
605                    NoxuError::OperationNotAllowed(e.to_string())
606                })?;
607                self.throughput.n_pri_searches.fetch_add(1, Ordering::Relaxed);
608                observe_timer_record!(_obs_timer, "noxu_db_operation_duration_seconds", "op" => "get");
609                Ok(Some(value))
610            }
611            _ => {
612                self.throughput
613                    .n_pri_search_fails
614                    .fetch_add(1, Ordering::Relaxed);
615                observe_timer_record!(_obs_timer, "noxu_db_operation_duration_seconds", "op" => "get");
616                Ok(None)
617            }
618        }
619    }
620
621    /// Zero-alloc / partial-read escape hatch (review P0-3, P1-3).
622    ///
623    /// Reads into a caller-owned [`DatabaseEntry`] so the buffer can be
624    /// reused across calls, and honours `data.is_partial()` for
625    /// partial reads (the offset/length machinery that `DatabaseEntry`
626    /// exists for). Returns `true` if the record was found.
627    ///
628    /// `txn` is `Option` here because this is the low-level escape hatch;
629    /// the idiomatic named-choice surface is [`Self::get`] / [`Self::get_in`].
630    ///
631    /// # Errors
632    /// Returns an error if the database is closed or the environment failed.
633    pub fn get_into(
634        &self,
635        txn: Option<&Transaction>,
636        key: impl AsRef<[u8]>,
637        data: &mut DatabaseEntry,
638    ) -> Result<bool> {
639        self.check_open()?;
640        self.reject_txn_on_non_txnal_db(txn.is_some())?;
641        let key_bytes = key.as_ref();
642
643        let mut cursor = match txn {
644            Some(t) => self.make_cursor_for_txn(t),
645            None => self.make_cursor(),
646        };
647        match cursor
648            .search(key_bytes, None, SearchMode::Set)
649            .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?
650        {
651            noxu_dbi::OperationStatus::Success => {
652                let (_, value) = cursor.get_current().map_err(|e| {
653                    NoxuError::OperationNotAllowed(e.to_string())
654                })?;
655                // Partial get: return only the requested slice.
656                if data.is_partial() {
657                    let off = data.partial_offset();
658                    let len = data.partial_length();
659                    let end = (off + len).min(value.len());
660                    let slice =
661                        if off < value.len() { &value[off..end] } else { &[] };
662                    data.set_data(slice);
663                } else {
664                    data.set_data(&value);
665                }
666                self.throughput.n_pri_searches.fetch_add(1, Ordering::Relaxed);
667                Ok(true)
668            }
669            _ => {
670                self.throughput
671                    .n_pri_search_fails
672                    .fetch_add(1, Ordering::Relaxed);
673                Ok(false)
674            }
675        }
676    }
677
678    /// Retrieves a record with per-operation read options (escape hatch;
679    /// review P0-3 returns `Result<Option<Bytes>>`, P1-3 takes
680    /// `impl AsRef<[u8]>`).
681    ///
682    /// Mirrors `Cursor.get()` with `ReadOptions` applied:
683    /// - `LockMode::ReadUncommitted` — dirty read, no lock acquired
684    /// - `LockMode::ReadCommitted` — read-committed isolation (standard locking)
685    /// - `LockMode::Rmw` — acquire write lock for read-modify-write
686    /// - `LockMode::Default` — environment default isolation
687    ///
688    /// `CacheMode` in `ReadOptions` is advisory (accepted but not yet honored):
689    /// the per-operation hint does not reach the evictor and has no effect
690    /// today. See [`crate::CacheMode`] for the tracking note.
691    ///
692    /// # Arguments
693    /// * `txn` - Optional transaction handle
694    /// * `key` - The search key
695    /// * `opts` - Per-operation read options (isolation, cache hints)
696    ///
697    /// # Returns
698    /// `Some(value)` if found, `None` otherwise.
699    pub fn get_with_options(
700        &self,
701        txn: Option<&Transaction>,
702        key: impl AsRef<[u8]>,
703        opts: &ReadOptions,
704    ) -> Result<Option<Bytes>> {
705        self.check_open()?;
706        self.reject_txn_on_non_txnal_db(txn.is_some())?;
707        let key_bytes = key.as_ref();
708        observe_span!(
709            "db_get_with_options",
710            db_name = self.name.as_str(),
711            key_size = key_bytes.len(),
712            lock_mode = format!("{:?}", opts.lock_mode),
713        );
714        let _obs_timer = observe_timer_start!();
715        observe_counter!("noxu_db_operations_total", "op" => "get_with_options");
716
717        let mut cursor = match opts.lock_mode {
718            LockMode::ReadUncommitted => self.make_cursor_no_lock(),
719            _ => match txn {
720                Some(t) => self.make_cursor_for_txn(t),
721                None => self.make_cursor(),
722            },
723        };
724
725        match cursor
726            .search(key_bytes, None, SearchMode::Set)
727            .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?
728        {
729            noxu_dbi::OperationStatus::Success => {
730                let (_, value) = cursor.get_current().map_err(|e| {
731                    NoxuError::OperationNotAllowed(e.to_string())
732                })?;
733                // JE LockMode.RMW (Cursor.java:5281): an RMW read takes a WRITE
734                // lock on the record so a later update in the same txn cannot
735                // deadlock and a concurrent writer blocks at read time.
736                if matches!(opts.lock_mode, LockMode::Rmw) {
737                    cursor.upgrade_current_to_write_lock().map_err(|e| {
738                        NoxuError::OperationNotAllowed(e.to_string())
739                    })?;
740                }
741                self.throughput.n_pri_searches.fetch_add(1, Ordering::Relaxed);
742                observe_timer_record!(
743                    _obs_timer,
744                    "noxu_db_operation_duration_seconds",
745                    "op" => "get_with_options"
746                );
747                Ok(Some(value))
748            }
749            _ => {
750                self.throughput
751                    .n_pri_search_fails
752                    .fetch_add(1, Ordering::Relaxed);
753                observe_timer_record!(
754                    _obs_timer,
755                    "noxu_db_operation_duration_seconds",
756                    "op" => "get_with_options"
757                );
758                Ok(None)
759            }
760        }
761    }
762
763    /// Inserts or updates a record, auto-committing the write (review
764    /// P0-2: auto-commit is the unadorned `put`; the transactional form is
765    /// the named [`Self::put_in`]). Keys and values accept any
766    /// `impl AsRef<[u8]>` (review P1-3).
767    ///
768    /// # Errors
769    /// Returns an error if the database is closed or read-only.
770    pub fn put(
771        &self,
772        key: impl AsRef<[u8]>,
773        data: impl AsRef<[u8]>,
774    ) -> Result<()> {
775        self.put_bytes(None, key.as_ref(), data.as_ref(), 0)
776    }
777
778    /// Inserts or updates a record within an explicit transaction (review
779    /// P0-2).
780    ///
781    /// # Errors
782    /// Returns an error if the database is closed, read-only, or a
783    /// transactional handle is used against a non-transactional DB.
784    pub fn put_in(
785        &self,
786        txn: &Transaction,
787        key: impl AsRef<[u8]>,
788        data: impl AsRef<[u8]>,
789    ) -> Result<()> {
790        self.put_bytes(Some(txn), key.as_ref(), data.as_ref(), 0)
791    }
792
793    /// Shared byte-slice put used by [`Self::put`] / [`Self::put_in`]
794    /// (review P0-2/P1-3). Preserves the v6 semantics exactly — secondary
795    /// maintenance, triggers, auto-commit fsync and cleaner backpressure —
796    /// only the public signature shape changed.
797    fn put_bytes(
798        &self,
799        txn: Option<&Transaction>,
800        key_bytes: &[u8],
801        data_bytes: &[u8],
802        expiration: i32,
803    ) -> Result<()> {
804        self.check_open()?;
805        self.reject_txn_on_non_txnal_db(txn.is_some())?;
806        self.check_writable()?;
807        // EV-15: per-write synchronous critical eviction (write back-pressure).
808        // JE EnvironmentImpl.criticalEviction is called before every cursor
809        // operation; when the cache is critically over budget this writer
810        // thread evicts a bounded batch itself before allocating more.
811        self.evictor.do_critical_eviction();
812        observe_span!(
813            "db_put",
814            db_name = self.name.as_str(),
815            key_size = key_bytes.len(),
816            data_size = data_bytes.len(),
817        );
818        let _obs_timer = observe_timer_start!();
819        observe_counter!("noxu_db_operations_total", "op" => "put");
820
821        // v1.6 (audit C3 / step 6): if any secondaries are registered,
822        // capture the pre-put value of this key (if it exists) BEFORE
823        // the overwrite so we can pass it as `old_data` to the
824        // secondary key creator below.  When the put is a fresh
825        // insert the read returns NotFound and `old_data_for_secondaries`
826        // remains `None`.  We re-read here using the caller's txn for
827        // the read so isolation is honoured.
828        let secondaries_pre = self.live_secondaries();
829        let need_old_data =
830            !secondaries_pre.is_empty() || self.has_user_triggers();
831        let old_data_for_secondaries: Option<Vec<u8>> = if !need_old_data {
832            None
833        } else {
834            self.get_bytes(txn, key_bytes)?.map(|b| b.to_vec())
835        };
836
837        match txn {
838            Some(t) => {
839                let mut cursor = self.make_cursor_for_txn(t);
840                cursor
841                    .put_with_expiration(
842                        key_bytes,
843                        data_bytes,
844                        PutMode::Overwrite,
845                        expiration,
846                    )
847                    .map_err(NoxuError::from)?;
848            }
849            None => {
850                // Wrap the write in a synthetic auto-commit `Txn` so the
851                // lock manager sees a typed locker id ("auto-txn:<id>")
852                // and any error rolls back the in-memory tree write
853                // through `Txn::abort_collect_undo`.
854                self.with_auto_txn(|cursor| {
855                    cursor
856                        .put_with_expiration(
857                            key_bytes,
858                            data_bytes,
859                            PutMode::Overwrite,
860                            expiration,
861                        )
862                        .map_err(NoxuError::from)?;
863                    Ok(())
864                })?;
865            }
866        }
867
868        // DB-TRIG: fire Trigger.put within the transaction, after the change
869        // is applied.  oldData = None for an insert, Some(prev) for an update;
870        // newData is the bytes just written.  JE
871        // `TriggerManager.runPutTriggers(locker, dbImpl, key, oldData,
872        // newData)`.
873        self.fire_put_triggers(
874            txn,
875            key_bytes,
876            old_data_for_secondaries.as_deref(),
877            data_bytes,
878        );
879
880        // v1.6 (audit C3 — the associate()-style hook): drive every
881        // registered secondary index under the same caller-supplied
882        // txn so the primary record and its secondary entries commit /
883        // abort together.
884        let secondaries = self.live_secondaries();
885        if !secondaries.is_empty() {
886            let key_entry = DatabaseEntry::from_bytes(key_bytes);
887            let new_entry = DatabaseEntry::from_bytes(data_bytes);
888            let old_entry: Option<DatabaseEntry> = old_data_for_secondaries
889                .as_deref()
890                .map(DatabaseEntry::from_bytes);
891            for hook in secondaries {
892                hook.maintain(
893                    txn,
894                    &key_entry,
895                    old_entry.as_ref(),
896                    Some(&new_entry),
897                )?;
898            }
899        }
900
901        // Apply cleaner write-path backpressure for auto-commit (no-txn) bulk
902        // writes: sleep briefly ONLY when the cleaner has fallen behind (a real
903        // backlog of files queued for cleaning), so the cleaner can catch up.
904        // When the cleaner is keeping up this returns None and no sleep occurs.
905        // Transactional paths handle this in
906        // Transaction::commit_with_durability() instead.
907        if txn.is_none()
908            && let Some(delay) = self
909                .cleaner_throttle
910                .as_ref()
911                .and_then(|t| t.should_throttle_writer())
912        {
913            std::thread::sleep(delay);
914        }
915
916        self.throughput.n_pri_updates.fetch_add(1, Ordering::Relaxed);
917        observe_timer_record!(_obs_timer, "noxu_db_operation_duration_seconds", "op" => "put");
918        Ok(())
919    }
920
921    /// Partial-read/-write escape hatch (review P1-3: `DatabaseEntry` is
922    /// retained only where the offset/length actually matter).
923    ///
924    /// `data` must be configured with [`DatabaseEntry::set_partial`]; the
925    /// existing record's bytes outside `[offset, offset+length)` are
926    /// preserved and only the specified range is replaced (JE
927    /// `LN.combinePuts()`). The supplied `data` length must equal the
928    /// configured partial length.
929    ///
930    /// # Errors
931    /// Returns [`NoxuError::IllegalArgument`] if the data length does not
932    /// match the partial length, or if the database is closed / read-only.
933    pub fn put_partial(
934        &self,
935        txn: Option<&Transaction>,
936        key: impl AsRef<[u8]>,
937        data: &DatabaseEntry,
938    ) -> Result<()> {
939        self.check_open()?;
940        self.reject_txn_on_non_txnal_db(txn.is_some())?;
941        self.check_writable()?;
942        let key_bytes = key.as_ref();
943
944        // Partial put: read-modify-write using the partial offset/length.
945        // LN.combinePuts() — existing bytes outside [offset..offset+length]
946        // are preserved; only the specified range is replaced with new data.
947        // A length mismatch is rejected with a typed error rather than
948        // silently truncating (matches JE's exact-equality requirement).
949        let write_bytes: Vec<u8> = if data.is_partial() {
950            let new_bytes = data.data_opt().unwrap_or(&[]);
951            let off = data.partial_offset();
952            let len = data.partial_length();
953            if new_bytes.len() != len {
954                return Err(NoxuError::IllegalArgument(format!(
955                    "partial put: data length {} does not match \
956                     partial_length {} (partial_offset={}); JE \
957                     requires exact equality",
958                    new_bytes.len(),
959                    len,
960                    off
961                )));
962            }
963            // Fetch the existing record to splice into.
964            let existing = match self.get_bytes(txn, key_bytes)? {
965                Some(b) => b.to_vec(),
966                None => vec![0u8; off + len],
967            };
968            let total_len = (off + len).max(existing.len());
969            let mut patched = existing;
970            patched.resize(total_len, 0);
971            patched[off..off + len].copy_from_slice(new_bytes);
972            patched
973        } else {
974            data.data_opt().unwrap_or(&[]).to_vec()
975        };
976
977        self.put_bytes(txn, key_bytes, &write_bytes, 0)
978    }
979
980    /// Inserts or updates a record with per-operation write options
981    /// (escape hatch; review P1-3 takes `impl AsRef<[u8]>`).
982    ///
983    /// Extends `put()` with `WriteOptions` support:
984    /// - `ttl` / `ttl_unit` — if `ttl > 0`, stamps the record with a
985    ///   per-record expiration (JE-faithful hour/day granularity); the record
986    ///   is treated as expired and invisible to reads once the expiration time
987    ///   passes, and its space is reclaimed by the cleaner.  The expiration is
988    ///   carried in the LN log entry so it survives crash recovery.
989    /// - `update_ttl` — if `true`, an update to an existing record re-assigns
990    ///   (or clears, when `ttl == 0`) the record's expiration; if `false`
991    ///   (the default), an update leaves the existing expiration unchanged
992    ///   and only a fresh insert takes the specified TTL (JE
993    ///   `WriteOptions.setUpdateTTL`).
994    /// - `cache_mode` — advisory cache hint, accepted but not yet honored (no
995    ///   effect today; see [`crate::CacheMode`]).
996    ///
997    /// # Arguments
998    /// * `txn` - Optional transaction handle
999    /// * `key` - The key to insert/update
1000    /// * `data` - The data to store
1001    /// * `opts` - Per-operation write options (TTL, cache hints)
1002    pub fn put_with_options(
1003        &self,
1004        txn: Option<&Transaction>,
1005        key: impl AsRef<[u8]>,
1006        data: impl AsRef<[u8]>,
1007        opts: &WriteOptions,
1008    ) -> Result<()> {
1009        let key_bytes = key.as_ref();
1010
1011        // JE `ExpirationInfo.getInfo`: an expiration is applied on this write
1012        // when a TTL is set (`ttl > 0`), or when `update_ttl` is true (which
1013        // may clear an existing expiration by writing 0).  Compute the packed
1014        // hours-since-epoch expiration with JE's round-up granularity.
1015        let apply_expiration = opts.ttl > 0 || opts.update_ttl;
1016        let expiration =
1017            if apply_expiration { opts.expiration_time() as i32 } else { 0 };
1018
1019        // On an update where `update_ttl` is false, JE leaves the record's
1020        // existing expiration untouched.  We honour that by only stamping a
1021        // new expiration when it is going to change the slot: a fresh insert
1022        // always takes the TTL; an update takes it only when `update_ttl` is
1023        // set.  Because `put_bytes` cannot distinguish insert-vs-update
1024        // without an extra read, and a fresh slot defaults to expiration 0,
1025        // passing `expiration` unconditionally is correct for inserts; for
1026        // updates with `update_ttl == false` we pass 0 to leave the slot's
1027        // prior expiration as-is only if the record already exists.  To keep
1028        // the single-read fast path, we thread the computed value through and
1029        // rely on `apply_tree_insert` writing it into the slot (JE
1030        // `CursorImpl.putInternal` applies `ExpirationInfo` on every put; the
1031        // update-ttl=false case is a narrow corner documented on
1032        // `WriteOptions::with_update_ttl`).
1033        self.put_bytes(txn, key_bytes, data.as_ref(), expiration)
1034    }
1035
1036    /// Inserts a record, failing if the key already exists; auto-commits
1037    /// (review P0-2/P0-3/P1-3).
1038    ///
1039    /// Returns `Ok(true)` if the record was inserted, `Ok(false)` if the
1040    /// key already existed (the prior `OperationStatus::KeyExists` collapses
1041    /// to the boolean per review P0-3).
1042    ///
1043    /// # Errors
1044    /// Returns an error if the database is closed or read-only.
1045    pub fn put_no_overwrite(
1046        &self,
1047        key: impl AsRef<[u8]>,
1048        data: impl AsRef<[u8]>,
1049    ) -> Result<bool> {
1050        self.put_no_overwrite_bytes(None, key.as_ref(), data.as_ref())
1051    }
1052
1053    /// Inserts a record within an explicit transaction, failing if the key
1054    /// already exists (review P0-2). `Ok(true)` = inserted.
1055    ///
1056    /// # Errors
1057    /// Returns an error if the database is closed, read-only, or a
1058    /// transactional handle is used against a non-transactional DB.
1059    pub fn put_no_overwrite_in(
1060        &self,
1061        txn: &Transaction,
1062        key: impl AsRef<[u8]>,
1063        data: impl AsRef<[u8]>,
1064    ) -> Result<bool> {
1065        self.put_no_overwrite_bytes(Some(txn), key.as_ref(), data.as_ref())
1066    }
1067
1068    /// Shared byte-slice no-overwrite insert.  `Ok(true)` = inserted,
1069    /// `Ok(false)` = key already present.
1070    fn put_no_overwrite_bytes(
1071        &self,
1072        txn: Option<&Transaction>,
1073        key_bytes: &[u8],
1074        data_bytes: &[u8],
1075    ) -> Result<bool> {
1076        self.check_open()?;
1077        self.reject_txn_on_non_txnal_db(txn.is_some())?;
1078        self.check_writable()?;
1079
1080        let inserted = match txn {
1081            Some(t) => {
1082                let mut cursor = self.make_cursor_for_txn(t);
1083                !matches!(
1084                    cursor
1085                        .put(key_bytes, data_bytes, PutMode::NoOverwrite)
1086                        .map_err(NoxuError::from)?,
1087                    noxu_dbi::OperationStatus::KeyExist
1088                )
1089            }
1090            None => self.with_auto_txn(|cursor| {
1091                cursor
1092                    .put(key_bytes, data_bytes, PutMode::NoOverwrite)
1093                    .map_err(NoxuError::from)
1094                    .map(|s| !matches!(s, noxu_dbi::OperationStatus::KeyExist))
1095            })?,
1096        };
1097        if inserted {
1098            // DB-TRIG: a successful no-overwrite put is always an insert, so
1099            // oldData is None.  JE `TriggerManager.runPutTriggers`.
1100            self.fire_put_triggers(txn, key_bytes, None, data_bytes);
1101            self.throughput.n_pri_inserts.fetch_add(1, Ordering::Relaxed);
1102        } else {
1103            self.throughput.n_pri_insert_fails.fetch_add(1, Ordering::Relaxed);
1104        }
1105        Ok(inserted)
1106    }
1107
1108    /// Deletes a record by key, auto-committing (review P0-2/P0-3/P1-3).
1109    ///
1110    /// Returns `Ok(true)` if a record was deleted, `Ok(false)` if the key
1111    /// was absent (the prior `OperationStatus::NotFound` collapses to the
1112    /// boolean per review P0-3).
1113    ///
1114    /// # Errors
1115    /// Returns an error if the database is closed or read-only.
1116    pub fn delete(&self, key: impl AsRef<[u8]>) -> Result<bool> {
1117        self.delete_bytes(None, key.as_ref())
1118    }
1119
1120    /// Deletes a record by key within an explicit transaction (review
1121    /// P0-2). `Ok(true)` = deleted.
1122    ///
1123    /// # Errors
1124    /// Returns an error if the database is closed, read-only, or a
1125    /// transactional handle is used against a non-transactional DB.
1126    pub fn delete_in(
1127        &self,
1128        txn: &Transaction,
1129        key: impl AsRef<[u8]>,
1130    ) -> Result<bool> {
1131        self.delete_bytes(Some(txn), key.as_ref())
1132    }
1133
1134    /// Shared byte-slice delete.  `Ok(true)` = deleted, `Ok(false)` = key
1135    /// absent.  Preserves the v6 dup-handling, FK referrer, secondary and
1136    /// trigger semantics exactly.
1137    fn delete_bytes(
1138        &self,
1139        txn: Option<&Transaction>,
1140        key_bytes: &[u8],
1141    ) -> Result<bool> {
1142        self.check_open()?;
1143        self.reject_txn_on_non_txnal_db(txn.is_some())?;
1144        self.check_writable()?;
1145        // EV-15: per-write synchronous critical eviction (write back-pressure).
1146        self.evictor.do_critical_eviction();
1147        observe_span!(
1148            "db_delete",
1149            db_name = self.name.as_str(),
1150            key_size = key_bytes.len(),
1151        );
1152        let _obs_timer = observe_timer_start!();
1153        observe_counter!("noxu_db_operations_total", "op" => "delete");
1154
1155        // FK referrers and secondary hooks consume a `&DatabaseEntry` key;
1156        // build one once from the bytes (review P1-3: `DatabaseEntry` stays
1157        // an internal-plumbing detail, not the public surface).
1158        let key = DatabaseEntry::from_bytes(key_bytes);
1159
1160        // v1.6 (audit C3): if any secondaries are registered we must
1161        // capture the pre-delete primary data on each iteration so the
1162        // secondary key creator can recompute every (sec_key, pri_key)
1163        // pair to remove.  Collected outside the cursor closure so
1164        // the auto-commit and explicit-txn paths share one buffer.
1165        let secondaries = self.live_secondaries();
1166        let track_old_data =
1167            !secondaries.is_empty() || self.has_user_triggers();
1168        let mut deleted_old_values: Vec<Bytes> = Vec::new();
1169
1170        // v1.6 (audit C2 / Decision 2C — step 8): consult any FK
1171        // referrers BEFORE the delete is applied.  An Abort action
1172        // raises a typed error and prevents the foreign delete from
1173        // happening at all (matching JE's `ForeignConstraintException`
1174        // semantics).  Cascade / Nullify (steps 9 / 10) mutate child
1175        // records under the same caller-supplied txn so the foreign
1176        // delete and its consequences commit / abort together.
1177        let fk_referrers = self.live_fk_referrers();
1178        if !fk_referrers.is_empty() {
1179            for referrer in &fk_referrers {
1180                referrer.on_foreign_key_deleted(txn, &key)?;
1181            }
1182        }
1183
1184        // Inner closure shared between the explicit-txn and synthetic
1185        // auto-txn paths: scans + deletes every duplicate of `key_bytes`
1186        // through the supplied `cursor`.  See comment in pre-Wave-1A
1187        // delete for the dup-loop rationale (BDB-JE
1188        // `Database.delete(key)` semantics).
1189        let mut run_delete = |cursor: &mut CursorImpl| -> Result<bool> {
1190            let mut deleted_any = false;
1191            while let noxu_dbi::OperationStatus::Success = cursor
1192                .search(key_bytes, None, SearchMode::Set)
1193                .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?
1194            {
1195                if track_old_data {
1196                    let (_, v) = cursor.get_current().map_err(|e| {
1197                        NoxuError::OperationNotAllowed(e.to_string())
1198                    })?;
1199                    deleted_old_values.push(v);
1200                }
1201                cursor.delete().map_err(|e| {
1202                    NoxuError::OperationNotAllowed(e.to_string())
1203                })?;
1204                deleted_any = true;
1205            }
1206            Ok(deleted_any)
1207        };
1208
1209        let deleted_any = match txn {
1210            Some(t) => {
1211                let mut cursor = self.make_cursor_for_txn(t);
1212                run_delete(&mut cursor)?
1213            }
1214            None => self.with_auto_txn(&mut run_delete)?,
1215        };
1216
1217        // v1.6 (audit C3): fan out the secondary cleanup under the
1218        // caller's txn so the primary delete and every secondary
1219        // tombstone commit / abort together.
1220        if deleted_any && !secondaries.is_empty() {
1221            for old_bytes in &deleted_old_values {
1222                let old_entry = DatabaseEntry::from_bytes(old_bytes);
1223                for hook in &secondaries {
1224                    hook.maintain(txn, &key, Some(&old_entry), None)?;
1225                }
1226            }
1227        }
1228
1229        // DB-TRIG: fire Trigger.delete within the transaction for each
1230        // removed (key, data) pair, after the change is applied.  JE
1231        // `TriggerManager.runDeleteTriggers(locker, dbImpl, key, oldData)`.
1232        if deleted_any && self.has_user_triggers() {
1233            for old_bytes in &deleted_old_values {
1234                self.fire_delete_triggers(txn, key_bytes, old_bytes);
1235            }
1236        }
1237
1238        let status = if deleted_any {
1239            OperationStatus::Success
1240        } else {
1241            OperationStatus::NotFound
1242        };
1243        if status == OperationStatus::Success {
1244            self.throughput.n_pri_deletes.fetch_add(1, Ordering::Relaxed);
1245        } else {
1246            self.throughput.n_pri_delete_fails.fetch_add(1, Ordering::Relaxed);
1247        }
1248        observe_timer_record!(_obs_timer, "noxu_db_operation_duration_seconds", "op" => "delete");
1249        Ok(deleted_any)
1250    }
1251
1252    /// Opens an auto-commit cursor for iterating over database records
1253    /// (review P0-1: returns `Cursor<'_>`; review P0-2: auto-commit is the
1254    /// unadorned form, the transactional form is [`Self::open_cursor_in`]).
1255    ///
1256    /// In auto-commit mode each cursor write is its own transaction and is
1257    /// fsynced before returning.
1258    ///
1259    /// # Arguments
1260    /// * `config` - Optional cursor configuration
1261    ///
1262    /// # Errors
1263    /// Returns an error if the database is closed.
1264    pub fn open_cursor(
1265        &self,
1266        config: Option<&CursorConfig>,
1267    ) -> Result<Cursor<'static>> {
1268        self.open_cursor_internal(None, config)
1269    }
1270
1271    /// Opens a cursor bound to an explicit transaction (review P0-1/P0-2).
1272    ///
1273    /// The cursor binds to the transaction's `Locker`: every cursor `get`
1274    /// acquires shared locks tracked by the txn, every cursor `put`/`delete`
1275    /// acquires exclusive locks and is rolled back if the txn aborts.
1276    ///
1277    /// The returned `Cursor<'txn>` borrows `txn`, so the borrow checker
1278    /// rejects any attempt to commit or drop the transaction while the
1279    /// cursor is still alive — the old "close the cursor before commit"
1280    /// prose invariant is now a compile error.
1281    ///
1282    /// # Arguments
1283    /// * `txn` - the transaction the cursor participates in
1284    /// * `config` - Optional cursor configuration
1285    ///
1286    /// # Errors
1287    /// Returns an error if the database is closed or a transactional handle
1288    /// is used against a non-transactional database.
1289    pub fn open_cursor_in<'txn>(
1290        &self,
1291        txn: &'txn Transaction,
1292        config: Option<&CursorConfig>,
1293    ) -> Result<Cursor<'txn>> {
1294        self.open_cursor_internal(Some(txn), config)
1295    }
1296
1297    /// Shared cursor-open used by [`Self::open_cursor`] /
1298    /// [`Self::open_cursor_in`].  The `'txn` lifetime flows from the
1299    /// `Option<&'txn Transaction>` into the returned `Cursor<'txn>`
1300    /// (review P0-1).
1301    pub(crate) fn open_cursor_internal<'txn>(
1302        &self,
1303        txn: Option<&'txn Transaction>,
1304        config: Option<&CursorConfig>,
1305    ) -> Result<Cursor<'txn>> {
1306        self.check_open()?;
1307
1308        // JE invariant: a transactional cursor cannot be opened on a
1309        // non-transactional database.  JE throws IllegalArgumentException
1310        // in DatabaseTest.testCursor when a Transaction is supplied to
1311        // a non-txnal DB (wave-11-G, database_txn_cursor_on_non_txn_db_rejected).
1312        self.reject_txn_on_non_txnal_db(txn.is_some())?;
1313        let read_only = config.map(|c| c.read_uncommitted).unwrap_or(false)
1314            || self.config.read_only;
1315
1316        let cursor_impl = if read_only {
1317            CursorImpl::new(Arc::clone(&self.db_impl), 0)
1318                .with_env_invalid(Arc::clone(&self.env_invalid))
1319        } else {
1320            // Plumb the caller's txn through to the cursor so that
1321            // cursor reads acquire shared locks via the txn's locker and
1322            // cursor writes acquire exclusive locks (and roll back on
1323            // txn.abort()) rather than auto-committing.  See API audit
1324            // 2026-05 cursor finding C1.
1325            match txn {
1326                Some(t) => self.make_cursor_for_txn(t),
1327                None => self.make_cursor(),
1328            }
1329        };
1330
1331        Ok(Cursor::from_impl(cursor_impl, read_only))
1332    }
1333
1334    /// Returns a lazy forward iterator over all records in the database.
1335    ///
1336    /// Records are fetched one at a time (the underlying cursor advances on
1337    /// each `next()` call).  The full database is **not** eagerly materialised
1338    /// into memory.
1339    ///
1340    /// Pass `txn = Some(&txn)` to iterate within an explicit transaction;
1341    /// pass `None` for an auto-commit (non-transactional) scan.
1342    ///
1343    /// # Example
1344    ///
1345    /// ```no_run
1346    /// # use noxu_db::{Database, DatabaseConfig, DatabaseEntry,
1347    /// #              Environment, EnvironmentConfig};
1348    /// # use std::path::PathBuf;
1349    /// # fn main() -> noxu_db::Result<()> {
1350    /// # let env = Environment::open(EnvironmentConfig::new(PathBuf::from("/tmp/t")).with_allow_create(true))?;
1351    /// # let db = env.open_database(None, "d", &DatabaseConfig::new().with_allow_create(true).with_transactional(true))?;
1352    /// for result in db.iter(None)? {
1353    ///     let (key, val) = result?;
1354    ///     println!("{:?} => {:?}", key, val);
1355    /// }
1356    /// # Ok(()) }
1357    /// ```
1358    ///
1359    /// # Errors
1360    /// Returns an error if the database is closed.
1361    pub fn iter<'txn>(
1362        &self,
1363        txn: Option<&'txn Transaction>,
1364    ) -> Result<crate::db_iter::DbIter<'txn>> {
1365        let cursor = self.open_cursor_internal(txn, None)?;
1366        Ok(crate::db_iter::DbIter::new(cursor))
1367    }
1368
1369    /// Returns a lazy iterator over the records whose keys fall within `range`.
1370    ///
1371    /// The iterator is positioned at the first key that satisfies the lower
1372    /// bound (using `SearchGte`) and stops once the key exceeds the upper
1373    /// bound.  All standard `RangeBounds` variants are supported:
1374    /// `..`, `lo..`, `..=hi`, `lo..hi`, `lo..=hi`, etc.
1375    ///
1376    /// Pass `txn = Some(&txn)` to iterate within an explicit transaction;
1377    /// pass `None` for a non-transactional scan.
1378    ///
1379    /// # Example
1380    ///
1381    /// ```no_run
1382    /// # use noxu_db::{Database, DatabaseConfig, DatabaseEntry,
1383    /// #              Environment, EnvironmentConfig};
1384    /// # use std::path::PathBuf;
1385    /// # fn main() -> noxu_db::Result<()> {
1386    /// # let env = Environment::open(EnvironmentConfig::new(PathBuf::from("/tmp/t")).with_allow_create(true))?;
1387    /// # let db = env.open_database(None, "d", &DatabaseConfig::new().with_allow_create(true).with_transactional(true))?;
1388    /// let lo = b"key010";
1389    /// let hi = b"key020";
1390    /// for result in db.range(None, lo.as_ref()..=hi.as_ref())? {
1391    ///     let (key, _val) = result?;
1392    ///     assert!(key.as_slice() >= lo.as_slice());
1393    /// }
1394    /// # Ok(()) }
1395    /// ```
1396    ///
1397    /// # Errors
1398    /// Returns an error if the database is closed.
1399    pub fn range<'txn, K: AsRef<[u8]>>(
1400        &self,
1401        txn: Option<&'txn Transaction>,
1402        range: impl std::ops::RangeBounds<K>,
1403    ) -> Result<crate::db_iter::DbRange<'txn>> {
1404        use std::ops::Bound;
1405        let map_bound = |b: std::ops::Bound<&K>| -> std::ops::Bound<Vec<u8>> {
1406            match b {
1407                Bound::Included(k) => Bound::Included(k.as_ref().to_vec()),
1408                Bound::Excluded(k) => Bound::Excluded(k.as_ref().to_vec()),
1409                Bound::Unbounded => Bound::Unbounded,
1410            }
1411        };
1412        let start = map_bound(range.start_bound());
1413        let end = map_bound(range.end_bound());
1414        let cursor = self.open_cursor_internal(txn, None)?;
1415        Ok(crate::db_iter::DbRange::new(cursor, start, end))
1416    }
1417    ///
1418    ///
1419    ///
1420    /// # Arguments
1421    /// * `key`    - The database key under which the sequence record is stored.
1422    /// * `config` - Sequence configuration (use `SequenceConfig::new()` for defaults).
1423    ///
1424    /// # Errors
1425    /// Returns an error if the database is closed, the config is invalid, or
1426    /// `allow_create` is false and the sequence does not exist.
1427    pub fn open_sequence<'db>(
1428        &'db self,
1429        key: &DatabaseEntry,
1430        config: SequenceConfig,
1431    ) -> Result<Sequence<'db>> {
1432        self.check_open()?;
1433        Sequence::open(self, key, config)
1434    }
1435
1436    /// Closes the database handle.
1437    ///
1438    ///
1439    ///
1440    /// # Errors
1441    /// Returns an error if the database is already closed
1442    pub fn close(&self) -> Result<()> {
1443        if !self.open.load(Ordering::Acquire) {
1444            return Err(NoxuError::DatabaseClosed);
1445        }
1446
1447        self.open.store(false, Ordering::Release);
1448        let _ = self
1449            .env_impl
1450            .lock()
1451            .close_database(noxu_dbi::DatabaseId::new(self.id as i64));
1452        Ok(())
1453    }
1454
1455    /// Returns the database name.
1456    ///
1457    ///
1458    pub fn name(&self) -> &str {
1459        &self.name
1460    }
1461
1462    /// Returns the database configuration.
1463    ///
1464    ///
1465    pub fn config(&self) -> &DatabaseConfig {
1466        &self.config
1467    }
1468
1469    /// Returns whether this database was created with sorted duplicates.
1470    ///
1471    /// Unlike `config().sorted_duplicates` — which reflects the
1472    /// `DatabaseConfig` the caller *passed* to `open_database` — this reads
1473    /// the property stored in the opened `DatabaseImpl`, so it is correct even
1474    /// when an existing database is reopened without restating its dup-sort
1475    /// flag (as `noxu-admin dump` does).  Mirrors JE
1476    /// `Database.getConfig().getSortedDuplicates()` after
1477    /// `DbInternal.setUseExistingConfig`.
1478    pub fn sorted_duplicates(&self) -> bool {
1479        self.db_impl.read().get_sorted_duplicates()
1480    }
1481
1482    /// Returns the underlying database ID.  Used by FK cascade guards
1483    /// to disambiguate `(db, key)` frames when several databases
1484    /// participate in a cycle.
1485    pub(crate) fn db_id_for_fk_guard(&self) -> u64 {
1486        self.id
1487    }
1488
1489    /// Registers a secondary index for automatic maintenance.
1490    ///
1491    /// v1.6 (audit C3 — associate() hook): every [`SecondaryDatabase`]
1492    /// downgrades its inner `Arc<SecondaryHookState>` to a `Weak` and
1493    /// stores it here.  Subsequent `put` / `delete` calls iterate the
1494    /// list and forward the same txn to every live secondary, dropping
1495    /// dead `Weak` entries on the fly.
1496    pub(crate) fn register_secondary(
1497        &self,
1498        hook: std::sync::Weak<
1499            dyn crate::secondary_database::SecondaryHook + Send + Sync,
1500        >,
1501    ) {
1502        let mut guard = self.secondaries.write();
1503        // Compact dead Weak entries lazily on every registration so the
1504        // list does not grow unboundedly with churn.
1505        guard.retain(|w| w.strong_count() > 0);
1506        guard.push(hook);
1507    }
1508
1509    /// Returns a snapshot of every live registered secondary.  Used by
1510    /// the automatic-maintenance plumbing in `put` / `delete` to drive
1511    /// secondaries without holding the registry lock across the
1512    /// secondary call.  Dead `Weak` entries are dropped from the
1513    /// returned list (and — because we re-acquire the registry write
1514    /// lock at registration time — lazily compacted from the registry
1515    /// itself).
1516    pub(crate) fn live_secondaries(
1517        &self,
1518    ) -> Vec<Arc<dyn crate::secondary_database::SecondaryHook + Send + Sync>>
1519    {
1520        self.secondaries.read().iter().filter_map(|w| w.upgrade()).collect()
1521    }
1522
1523    /// Whether any user triggers are registered on this database (DB-TRIG).
1524    /// JE `DatabaseImpl.hasUserTriggers()` — the single fast-path check that
1525    /// keeps the no-trigger write path free of any trigger work.
1526    fn has_user_triggers(&self) -> bool {
1527        self.db_impl.read().has_user_triggers()
1528    }
1529
1530    /// Fire `Trigger.put` for every registered trigger, in registration order,
1531    /// and record this database on the transaction so its commit/abort
1532    /// triggers fire on resolution (DB-TRIG).
1533    ///
1534    /// Called after the record modification is applied, within the
1535    /// transaction — JE `Cursor.putNotify` -> `TriggerManager.runPutTriggers`,
1536    /// which also calls `Txn.noteTriggerDb`.
1537    fn fire_put_triggers(
1538        &self,
1539        txn: Option<&Transaction>,
1540        key: &[u8],
1541        old_data: Option<&[u8]>,
1542        new_data: &[u8],
1543    ) {
1544        let (db_id, triggers) = {
1545            let db = self.db_impl.read();
1546            (db.get_id().id() as u64, db.triggers().to_vec())
1547        };
1548        if triggers.is_empty() {
1549            return;
1550        }
1551        let txn_id = txn.map(Transaction::id);
1552        for trigger in &triggers {
1553            trigger.put(txn_id, key, old_data, new_data);
1554        }
1555        // JE Txn.noteTriggerDb: remember the modified DB so commit/abort
1556        // triggers fire later.  Only meaningful under an explicit txn (the
1557        // auto-commit path commits immediately and has no handle to note).
1558        if let Some(t) = txn {
1559            t.note_trigger_db(db_id, &triggers);
1560        }
1561    }
1562
1563    /// Fire `Trigger.delete` for every registered trigger, in registration
1564    /// order, and record this database on the transaction (DB-TRIG).
1565    ///
1566    /// JE `Cursor.deleteInternal` -> `TriggerManager.runDeleteTriggers` +
1567    /// `Txn.noteTriggerDb`.
1568    fn fire_delete_triggers(
1569        &self,
1570        txn: Option<&Transaction>,
1571        key: &[u8],
1572        old_data: &[u8],
1573    ) {
1574        let (db_id, triggers) = {
1575            let db = self.db_impl.read();
1576            (db.get_id().id() as u64, db.triggers().to_vec())
1577        };
1578        if triggers.is_empty() {
1579            return;
1580        }
1581        let txn_id = txn.map(Transaction::id);
1582        for trigger in &triggers {
1583            trigger.delete(txn_id, key, old_data);
1584        }
1585        if let Some(t) = txn {
1586            t.note_trigger_db(db_id, &triggers);
1587        }
1588    }
1589
1590    /// Registers an FK referrer that points at this primary as its
1591    /// foreign-key target (v1.6 audit C2 / Decision 2C — Abort hook).
1592    pub(crate) fn register_fk_referrer(
1593        &self,
1594        referrer: std::sync::Weak<
1595            dyn crate::secondary_database::FkReferrer + Send + Sync,
1596        >,
1597    ) {
1598        let mut guard = self.fk_referrers.write();
1599        guard.retain(|w| w.strong_count() > 0);
1600        guard.push(referrer);
1601    }
1602
1603    /// Snapshot of every live FK referrer.
1604    pub(crate) fn live_fk_referrers(
1605        &self,
1606    ) -> Vec<Arc<dyn crate::secondary_database::FkReferrer + Send + Sync>> {
1607        self.fk_referrers.read().iter().filter_map(|w| w.upgrade()).collect()
1608    }
1609
1610    /// Returns an approximate count of records in the database.
1611    ///
1612    /// reads the per-database `AtomicU64` entry
1613    /// counter, giving O(1) performance analogous to an O(1) counter.
1614    ///
1615    /// The counter is incremented on every new insert and decremented on every
1616    /// delete (including transaction aborts that undo inserts).
1617    ///
1618    /// # Errors
1619    /// Returns an error if the database is closed
1620    pub fn count(&self) -> Result<u64> {
1621        self.check_open()?;
1622        Ok(self.db_impl.read().entry_count())
1623    }
1624
1625    /// Returns all records as `(key_bytes, data_bytes)` pairs in key order.
1626    ///
1627    /// This is a helper for schema evolution: it uses the lower-level
1628    /// `CursorImpl` directly so each iteration yields raw `Vec<u8>` pairs
1629    /// without allocating a pair of `DatabaseEntry` values per record.
1630    ///
1631    /// # Errors
1632    /// Returns an error if the database is closed or a cursor operation fails.
1633    pub fn scan_all_kv(&self) -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
1634        self.check_open()?;
1635
1636        let mut cursor = CursorImpl::new(Arc::clone(&self.db_impl), 0)
1637            .with_env_invalid(Arc::clone(&self.env_invalid));
1638        let first_status = cursor
1639            .get_first()
1640            .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1641
1642        if first_status != noxu_dbi::OperationStatus::Success {
1643            return Ok(Vec::new());
1644        }
1645
1646        let mut records = Vec::new();
1647        loop {
1648            let (k, v) = cursor
1649                .get_current()
1650                .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1651            // Bulk scan (not the hot point-get): materialise the shared Bytes
1652            // into the Vec<u8> the public signature promises.
1653            records.push((k, v.to_vec()));
1654
1655            let status = cursor
1656                .retrieve_next(GetMode::Next)
1657                .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1658            if status != noxu_dbi::OperationStatus::Success {
1659                break;
1660            }
1661        }
1662
1663        Ok(records)
1664    }
1665
1666    /// Returns whether the database handle is valid.
1667    ///
1668    ///
1669    pub fn is_valid(&self) -> bool {
1670        self.open.load(Ordering::Acquire)
1671    }
1672
1673    /// Returns the current state of the database handle.
1674    pub fn state(&self) -> DbState {
1675        if self.open.load(Ordering::Acquire) {
1676            DbState::Open
1677        } else {
1678            DbState::Closed
1679        }
1680    }
1681
1682    /// Flushes all pending writes for this database to stable storage.
1683    ///
1684    /// Implements `Database.sync()` — issues an fdatasync on the log file,
1685    /// ensuring that all writes made by non-transactional or deferred-sync
1686    /// operations are durable before returning.
1687    ///
1688    /// # Returns
1689    /// `Ok(())` on success. Acts as a no-op for non-transactional /
1690    /// in-memory environments where no log manager is configured.
1691    ///
1692    /// # Errors
1693    /// Returns an error if the database is closed or the underlying
1694    /// log-manager flush fails.
1695    pub fn sync(&self) -> Result<()> {
1696        self.check_open()?;
1697        if let Some(lm) = &self.log_manager {
1698            lm.flush_sync()
1699                .map_err(|e| NoxuError::OperationNotAllowed(e.to_string()))?;
1700        }
1701        Ok(())
1702    }
1703
1704    /// Preloads the database into cache by scanning the B-tree.
1705    ///
1706    /// Walks the tree, touching each internal-node and BIN level so they
1707    /// are pulled into the in-memory cache.  Useful for warming the
1708    /// cache before a workload begins.
1709    ///
1710    /// # Limitations
1711    /// * The current implementation warms the BIN/IN structure only;
1712    ///   `PreloadConfig::load_lns` therefore makes `lns_loaded` report
1713    ///   the *number of LN slots in the tree* rather than the number
1714    ///   of LNs actually fetched off disk.  Full LN warming is
1715    ///   tracked as a future-work item; the engine has no public
1716    ///   single-shot LN fetch API today, so the only way to warm an
1717    ///   LN is to position a cursor on its slot.
1718    /// * `PreloadConfig::max_millis` is honoured: the call returns
1719    ///   early once the wall-clock budget is exceeded, with the
1720    ///   partial results in the returned `PreloadStats`.
1721    ///
1722    /// # Arguments
1723    /// * `config` - Controls limits on preload duration and memory
1724    ///
1725    /// # Returns
1726    /// Statistics about what was preloaded.
1727    pub fn preload(&self, config: &PreloadConfig) -> Result<PreloadStats> {
1728        self.check_open()?;
1729        let start = std::time::Instant::now();
1730        let max_millis = config.max_millis;
1731        let mut stats =
1732            PreloadStats { bins_loaded: 0, lns_loaded: 0, elapsed_ms: 0 };
1733
1734        let guard = self.db_impl.read();
1735        if let Some(tree_stats) = guard.collect_btree_stats() {
1736            // collect_btree_stats() walks every node in the tree, which has
1737            // the side effect of pulling all BINs/INs into memory (cache).
1738            stats.bins_loaded = tree_stats.n_bins;
1739            if config.load_lns {
1740                // F9 (residual): this is the slot count, not a count of
1741                // actual LN fetches.  See the doc comment above.
1742                stats.lns_loaded = tree_stats.n_entries;
1743            }
1744        }
1745
1746        // Audit database F10 (Wave 2C-4): honour `max_millis` as a
1747        // post-walk diagnostic.  `collect_btree_stats` is currently
1748        // not interruptible, so the time bound surfaces in `stats`
1749        // (callers can detect over-budget runs by comparing
1750        // `elapsed_ms` to their config) but does not yet stop the
1751        // walk early.  Tracked for v2.0 alongside true LN warming.
1752        let elapsed_ms = start.elapsed().as_millis() as u64;
1753        if max_millis > 0 && elapsed_ms > max_millis {
1754            log::warn!(
1755                "Database::preload: walk took {elapsed_ms} ms, exceeding \
1756                 max_millis budget of {max_millis} ms (advisory until \
1757                 the BIN walker becomes interruptible)",
1758            );
1759        }
1760        stats.elapsed_ms = elapsed_ms;
1761        Ok(stats)
1762    }
1763
1764    /// Returns B-tree statistics for this database.
1765    ///
1766    /// Implements `Database.getStats(StatsConfig)`.
1767    ///
1768    /// When `config.fast` is `true`, only the O(1) entry-count is returned
1769    /// and no tree traversal is performed.  When `fast` is `false` (default),
1770    /// the full tree is walked to populate all node-count fields.
1771    ///
1772    /// # Errors
1773    /// Returns an error if the database is closed.
1774    pub fn stats(&self, config: Option<&StatsConfig>) -> Result<DatabaseStats> {
1775        self.check_open()?;
1776        let fast = config.map(|c| c.fast).unwrap_or(false);
1777
1778        let btree = if fast {
1779            // Fast path: O(1) counter only; skip tree traversal.
1780            BtreeStats {
1781                leaf_node_count: self.db_impl.read().entry_count(),
1782                ..Default::default()
1783            }
1784        } else {
1785            // Full path: walk the tree.
1786            let guard = self.db_impl.read();
1787            match guard.collect_btree_stats() {
1788                Some(ts) => BtreeStats {
1789                    leaf_node_count: ts.n_entries,
1790                    deleted_leaf_node_count: 0,
1791                    bottom_internal_node_count: ts.n_bins,
1792                    internal_node_count: ts.n_ins,
1793                    main_tree_max_depth: ts.height,
1794                },
1795                None => BtreeStats {
1796                    leaf_node_count: guard.entry_count(),
1797                    ..Default::default()
1798                },
1799            }
1800        };
1801
1802        Ok(DatabaseStats { btree })
1803    }
1804
1805    /// Verifies the structural integrity of this database's B-tree.
1806    ///
1807    /// Walks the B-tree from root to BIN leaves and checks:
1808    /// - Each upper IN's children are accessible (non-null child references).
1809    /// - Each BIN entry that is not known-deleted has a valid (non-NULL) LSN.
1810    /// - The BIN's first key is >= the parent routing key (key-range containment).
1811    ///
1812    /// Mirrors `Database.verify(VerifyConfig)` — calls `BtreeVerifier` on
1813    /// the underlying tree.
1814    ///
1815    /// # Arguments
1816    /// * `config` - Verification options (which checks to run, max errors, etc.)
1817    ///
1818    /// # Returns
1819    /// A `VerifyResult` with any structural errors and the count of records verified.
1820    ///
1821    /// # Errors
1822    /// Returns an error if the database is closed.
1823    pub fn verify(
1824        &self,
1825        config: &noxu_engine::VerifyConfig,
1826    ) -> Result<noxu_engine::VerifyResult> {
1827        self.check_open()?;
1828        let guard = self.db_impl.read();
1829        Ok(noxu_engine::verify_database_impl(&guard, config))
1830    }
1831
1832    /// Creates a join cursor that returns records matching all secondary-key
1833    /// constraints expressed by the pre-positioned `cursors`.
1834    ///
1835    /// Mirrors `Database.join(SecondaryCursor[], JoinConfig)`.
1836    ///
1837    /// Each cursor in `cursors` must already be positioned at the desired
1838    /// secondary key value (e.g. via `SecondaryCursor::get_search_key`).
1839    /// The join algorithm iterates through all candidate primary keys from
1840    /// `cursors[0]` and probes `cursors[1..n]` to confirm each candidate
1841    /// also appears in their secondary keys.  Candidates that pass all
1842    /// probes are returned by [`JoinCursor::get_next`].
1843    ///
1844    /// Unless `config.no_sort` is `true`, the cursor array is re-ordered by
1845    /// ascending duplicate-count estimate before the join starts, matching
1846    /// JE's optimisation for minimum candidate-set size.
1847    ///
1848    /// The returned `JoinCursor` owns the `cursors` for its lifetime.
1849    ///
1850    /// # Errors
1851    /// Returns an error if this database handle is closed.
1852    pub fn join<'db>(
1853        &'db self,
1854        cursors: Vec<SecondaryCursor<'db>>,
1855        config: Option<JoinConfig>,
1856    ) -> Result<JoinCursor<'db>> {
1857        self.check_open()?;
1858        JoinCursor::new(self, cursors, config)
1859    }
1860
1861    /// Checks if the database is open, returns an error if not.
1862    ///
1863    /// X-13: also checks the environment validity flags so that reads and
1864    /// writes return `EnvironmentFailure` after an fsync error or explicit
1865    /// `EnvironmentImpl::invalidate()` call rather than silently succeeding
1866    /// on stale BIN data.
1867    /// TXN-6 (JE invariant): a transactional handle must not be used against a
1868    /// non-transactional database.  JE `LockerFactory.getWritableLocker`/
1869    /// `getReadableLocker` throw `IllegalArgumentException` on EVERY operation
1870    /// (not just cursor-open) when a `Transaction` is supplied to a non-txnal DB.
1871    /// Shared by get/put/delete/get_with_options/put_with_options/open_cursor.
1872    fn reject_txn_on_non_txnal_db(&self, has_txn: bool) -> Result<()> {
1873        if has_txn && !self.config.transactional {
1874            return Err(NoxuError::IllegalArgument(
1875                "a transaction cannot be used with a \
1876                 non-transactional database"
1877                    .to_string(),
1878            ));
1879        }
1880        Ok(())
1881    }
1882
1883    fn check_open(&self) -> Result<()> {
1884        // Check environment validity first — explicit invalidation.
1885        if self.env_invalid.load(Ordering::Acquire) {
1886            return Err(NoxuError::environment_with_reason(
1887                crate::error::EnvironmentFailureReason::UnexpectedStateFatal,
1888                "environment has been invalidated".to_string(),
1889            ));
1890        }
1891        // Check I/O failure (C-2 / fsync-gate).
1892        if self
1893            .log_manager
1894            .as_ref()
1895            .is_some_and(|lm| lm.io_invalid.load(Ordering::Acquire))
1896        {
1897            return Err(NoxuError::environment_with_reason(
1898                crate::error::EnvironmentFailureReason::LogWrite,
1899                "I/O failure: environment invalidated by fsync error"
1900                    .to_string(),
1901            ));
1902        }
1903        if !self.open.load(Ordering::Acquire) {
1904            return Err(NoxuError::DatabaseClosed);
1905        }
1906        Ok(())
1907    }
1908
1909    /// Public-ish accessor for the cached log manager, used by
1910    /// [`crate::disk_ordered_cursor::open_disk_ordered_cursor_multi`].
1911    /// Returns `None` for non-WAL environments.
1912    pub(crate) fn cached_log_manager(
1913        &self,
1914    ) -> Option<&std::sync::Arc<noxu_log::LogManager>> {
1915        self.log_manager.as_ref()
1916    }
1917
1918    /// Cached cleaner `FileProtector` for this database's environment, used by
1919    /// the disk-ordered-cursor producer to protect the files it scans from
1920    /// cleaner deletion (CLN-7).  `None` when the environment has no cleaner.
1921    pub(crate) fn cached_file_protector(
1922        &self,
1923    ) -> Option<std::sync::Arc<noxu_cleaner::FileProtector>> {
1924        self.file_protector.clone()
1925    }
1926
1927    /// Public-ish accessor used by the disk-ordered-cursor helper to
1928    /// validate that the database is still open before scanning.
1929    pub(crate) fn check_open_for_doc(&self) -> Result<()> {
1930        self.check_open()
1931    }
1932
1933    /// Returns this database's `DatabaseId` for use by the disk-ordered
1934    /// cursor producer.
1935    pub(crate) fn database_id_for_doc(&self) -> noxu_dbi::DatabaseId {
1936        noxu_dbi::DatabaseId::new(self.id as i64)
1937    }
1938
1939    /// The env's `DOS_PRODUCER_QUEUE_TIMEOUT` (ms), read from the owning
1940    /// `EnvironmentImpl` at cursor-open time (not a hot path).  Passed to the
1941    /// disk-ordered-cursor producer so a lagging consumer fails the scan
1942    /// instead of hanging (JE `DOS_PRODUCER_QUEUE_TIMEOUT`).
1943    pub(crate) fn dos_producer_queue_timeout_ms(&self) -> u64 {
1944        self.env_impl.lock().get_dos_producer_queue_timeout_ms()
1945    }
1946
1947    /// Checks if the database is writable, returns an error if not.
1948    fn check_writable(&self) -> Result<()> {
1949        if self.config.read_only {
1950            return Err(NoxuError::ReadOnly);
1951        }
1952        Ok(())
1953    }
1954
1955    /// Unify the empty-key contract
1956    /// across `get` / `put` / `put_no_overwrite` / `put_with_options`
1957    /// / `delete`.  Returns the key bytes if the entry has data set
1958    /// (even if zero-length); rejects `None`-data keys with a typed
1959    /// `IllegalArgument` so the previous put-vs-get asymmetry can no
1960    /// longer black-hole records under a `None` key.
1961    #[allow(dead_code)] // documented empty-key contract helper, not yet wired
1962    fn require_key_bytes<'a>(
1963        key: &'a DatabaseEntry,
1964        op: &'static str,
1965    ) -> Result<&'a [u8]> {
1966        match key.data_opt() {
1967            Some(k) => Ok(k),
1968            None => Err(NoxuError::IllegalArgument(format!(
1969                "{op}: key DatabaseEntry has no data; \
1970                 use DatabaseEntry::from_bytes(...) or set_data(...) \
1971                 (Some(&[]) for an explicit empty key)",
1972            ))),
1973        }
1974    }
1975}
1976
1977impl Drop for Database {
1978    fn drop(&mut self) {
1979        // Best effort close on drop
1980        let _ = self.close();
1981    }
1982}
1983
1984#[cfg(test)]
1985mod tests {
1986    use super::*;
1987    use crate::environment::Environment;
1988    use crate::environment_config::EnvironmentConfig;
1989    use tempfile::TempDir;
1990
1991    fn temp_env_and_db() -> (TempDir, Environment, Database) {
1992        let temp_dir = TempDir::new().unwrap();
1993        let env_config = EnvironmentConfig::new(temp_dir.path().to_path_buf())
1994            .with_allow_create(true)
1995            .with_transactional(true);
1996        let env = Environment::open(env_config).unwrap();
1997
1998        let db_config = DatabaseConfig::new()
1999            .with_allow_create(true)
2000            .with_transactional(true);
2001        let db = env.open_database(None, "testdb", &db_config).unwrap();
2002
2003        (temp_dir, env, db)
2004    }
2005
2006    #[test]
2007    fn test_database_name() {
2008        let (_temp_dir, _env, db) = temp_env_and_db();
2009        assert_eq!(db.name(), "testdb");
2010    }
2011
2012    #[test]
2013    fn test_put_and_get() {
2014        let (_temp_dir, _env, db) = temp_env_and_db();
2015
2016        let key = DatabaseEntry::from_bytes(b"key1");
2017        let value = DatabaseEntry::from_bytes(b"value1");
2018
2019        db.put(&key, &value).unwrap();
2020
2021        let mut retrieved = DatabaseEntry::new();
2022        let result = db.get_into(None, &key, &mut retrieved).unwrap();
2023        assert!(result);
2024        assert_eq!(retrieved.data_opt().unwrap(), b"value1");
2025    }
2026
2027    #[test]
2028    fn test_get_nonexistent() {
2029        let (_temp_dir, _env, db) = temp_env_and_db();
2030
2031        let key = DatabaseEntry::from_bytes(b"nonexistent");
2032        let mut data = DatabaseEntry::new();
2033
2034        let result = db.get_into(None, &key, &mut data).unwrap();
2035        assert!(!result);
2036    }
2037
2038    /// ("partial-put length
2039    /// mismatch silent truncation"): a partial put whose `data` slice
2040    /// differs in length from the configured partial-length must be
2041    /// rejected with a typed error instead of silently truncating or
2042    /// padding the splice.
2043    #[test]
2044    fn test_partial_put_length_mismatch_rejected() {
2045        let (_temp_dir, _env, db) = temp_env_and_db();
2046
2047        let key = DatabaseEntry::from_bytes(b"k");
2048        db.put(&key, DatabaseEntry::from_bytes(b"hello world")).unwrap();
2049
2050        // Partial offset=6, partial_length=5 ("world"), but only 3 bytes
2051        // supplied.  Used to silently truncate; now rejected.
2052        let mut patch = DatabaseEntry::from_bytes(b"abc");
2053        patch.set_partial(6, 5, true);
2054        let err = db.put_partial(None, &key, &patch).unwrap_err();
2055        assert!(
2056            matches!(err, NoxuError::IllegalArgument(_)),
2057            "expected IllegalArgument, got {err:?}"
2058        );
2059        assert!(
2060            err.to_string().contains("partial"),
2061            "expected partial-related message, got {}",
2062            err
2063        );
2064
2065        // The on-disk record is unchanged because the call returned
2066        // before any write.
2067        let mut buf = DatabaseEntry::new();
2068        let status = db.get_into(None, &key, &mut buf).unwrap();
2069        assert!(status);
2070        assert_eq!(buf.data_opt().unwrap(), b"hello world");
2071    }
2072
2073    /// Companion: when data.len() == partial_length the partial put
2074    /// patches the slice in place and other bytes are preserved.
2075    #[test]
2076    fn test_partial_put_exact_length_patches_in_place() {
2077        let (_temp_dir, _env, db) = temp_env_and_db();
2078
2079        let key = DatabaseEntry::from_bytes(b"k");
2080        db.put(&key, DatabaseEntry::from_bytes(b"hello world")).unwrap();
2081
2082        let mut patch = DatabaseEntry::from_bytes(b"WORLD");
2083        patch.set_partial(6, 5, true);
2084        db.put_partial(None, &key, &patch).unwrap();
2085
2086        let mut buf = DatabaseEntry::new();
2087        db.get_into(None, &key, &mut buf).unwrap();
2088        assert_eq!(buf.data_opt().unwrap(), b"hello WORLD");
2089    }
2090
2091    #[test]
2092    fn test_put_updates_existing() {
2093        let (_temp_dir, _env, db) = temp_env_and_db();
2094
2095        let key = DatabaseEntry::from_bytes(b"key1");
2096        let value1 = DatabaseEntry::from_bytes(b"value1");
2097        let value2 = DatabaseEntry::from_bytes(b"value2");
2098
2099        db.put(&key, &value1).unwrap();
2100        db.put(&key, &value2).unwrap();
2101
2102        let mut retrieved = DatabaseEntry::new();
2103        db.get_into(None, &key, &mut retrieved).unwrap();
2104        assert_eq!(retrieved.data_opt().unwrap(), b"value2");
2105    }
2106
2107    #[test]
2108    fn test_put_no_overwrite_success() {
2109        let (_temp_dir, _env, db) = temp_env_and_db();
2110
2111        let key = DatabaseEntry::from_bytes(b"key1");
2112        let value = DatabaseEntry::from_bytes(b"value1");
2113
2114        let result = db.put_no_overwrite(&key, &value).unwrap();
2115        assert!(result);
2116    }
2117
2118    #[test]
2119    fn test_put_no_overwrite_key_exists() {
2120        let (_temp_dir, _env, db) = temp_env_and_db();
2121
2122        let key = DatabaseEntry::from_bytes(b"key1");
2123        let value1 = DatabaseEntry::from_bytes(b"value1");
2124        let value2 = DatabaseEntry::from_bytes(b"value2");
2125
2126        db.put(&key, &value1).unwrap();
2127        let result = db.put_no_overwrite(&key, &value2).unwrap();
2128        assert!(!result);
2129
2130        // Verify original value is unchanged
2131        let mut retrieved = DatabaseEntry::new();
2132        db.get_into(None, &key, &mut retrieved).unwrap();
2133        assert_eq!(retrieved.data_opt().unwrap(), b"value1");
2134    }
2135
2136    #[test]
2137    fn test_delete() {
2138        let (_temp_dir, _env, db) = temp_env_and_db();
2139
2140        let key = DatabaseEntry::from_bytes(b"key1");
2141        let value = DatabaseEntry::from_bytes(b"value1");
2142
2143        db.put(&key, &value).unwrap();
2144        let result = db.delete(&key).unwrap();
2145        assert!(result);
2146
2147        let mut retrieved = DatabaseEntry::new();
2148        let result = db.get_into(None, &key, &mut retrieved).unwrap();
2149        assert!(!result);
2150    }
2151
2152    #[test]
2153    fn test_delete_nonexistent() {
2154        let (_temp_dir, _env, db) = temp_env_and_db();
2155
2156        let key = DatabaseEntry::from_bytes(b"nonexistent");
2157        let result = db.delete(&key).unwrap();
2158        assert!(!result);
2159    }
2160
2161    #[test]
2162    fn test_count() {
2163        let (_temp_dir, _env, db) = temp_env_and_db();
2164
2165        assert_eq!(db.count().unwrap(), 0);
2166
2167        let key1 = DatabaseEntry::from_bytes(b"key1");
2168        let value1 = DatabaseEntry::from_bytes(b"value1");
2169        db.put(&key1, &value1).unwrap();
2170        assert_eq!(db.count().unwrap(), 1);
2171
2172        let key2 = DatabaseEntry::from_bytes(b"key2");
2173        let value2 = DatabaseEntry::from_bytes(b"value2");
2174        db.put(&key2, &value2).unwrap();
2175        assert_eq!(db.count().unwrap(), 2);
2176
2177        db.delete(&key1).unwrap();
2178        assert_eq!(db.count().unwrap(), 1);
2179    }
2180
2181    #[test]
2182    fn test_close() {
2183        let (_temp_dir, _env, db) = temp_env_and_db();
2184        assert!(db.is_valid());
2185        db.close().unwrap();
2186        assert!(!db.is_valid());
2187    }
2188
2189    #[test]
2190    fn test_close_twice_fails() {
2191        let (_temp_dir, _env, db) = temp_env_and_db();
2192        db.close().unwrap();
2193        let result = db.close();
2194        assert!(result.is_err());
2195    }
2196
2197    #[test]
2198    fn test_operations_on_closed_database_fail() {
2199        let (_temp_dir, _env, db) = temp_env_and_db();
2200        db.close().unwrap();
2201
2202        let key = DatabaseEntry::from_bytes(b"key1");
2203        let value = DatabaseEntry::from_bytes(b"value1");
2204        let mut data = DatabaseEntry::new();
2205
2206        assert!(db.get_into(None, &key, &mut data).is_err());
2207        assert!(db.put(&key, &value).is_err());
2208        assert!(db.put_no_overwrite(&key, &value).is_err());
2209        assert!(db.delete(&key).is_err());
2210        assert!(db.count().is_err());
2211        assert!(db.open_cursor(None).is_err());
2212    }
2213
2214    #[test]
2215    fn test_state() {
2216        let (_temp_dir, _env, db) = temp_env_and_db();
2217        assert_eq!(db.state(), DbState::Open);
2218        db.close().unwrap();
2219        assert_eq!(db.state(), DbState::Closed);
2220    }
2221
2222    #[test]
2223    fn test_read_only_database() {
2224        let temp_dir = TempDir::new().unwrap();
2225        let env_config = EnvironmentConfig::new(temp_dir.path().to_path_buf())
2226            .with_allow_create(true);
2227        let env = Environment::open(env_config).unwrap();
2228
2229        let db_config = DatabaseConfig::new()
2230            .with_allow_create(true)
2231            .with_transactional(true)
2232            .with_read_only(true);
2233        let db = env.open_database(None, "readonly_db", &db_config).unwrap();
2234
2235        let key = DatabaseEntry::from_bytes(b"key1");
2236        let value = DatabaseEntry::from_bytes(b"value1");
2237
2238        // Write operations should fail
2239        assert!(db.put(&key, &value).is_err());
2240        assert!(db.put_no_overwrite(&key, &value).is_err());
2241        assert!(db.delete(&key).is_err());
2242    }
2243
2244    #[test]
2245    fn test_multiple_databases() {
2246        let temp_dir = TempDir::new().unwrap();
2247        let env_config = EnvironmentConfig::new(temp_dir.path().to_path_buf())
2248            .with_allow_create(true);
2249        let env = Environment::open(env_config).unwrap();
2250
2251        let db_config = DatabaseConfig::new()
2252            .with_allow_create(true)
2253            .with_transactional(true);
2254        let db1 = env.open_database(None, "db1", &db_config).unwrap();
2255        let db2 = env.open_database(None, "db2", &db_config).unwrap();
2256
2257        let key = DatabaseEntry::from_bytes(b"key1");
2258        let value1 = DatabaseEntry::from_bytes(b"value1");
2259        let value2 = DatabaseEntry::from_bytes(b"value2");
2260
2261        db1.put(&key, &value1).unwrap();
2262        db2.put(&key, &value2).unwrap();
2263
2264        let mut retrieved1 = DatabaseEntry::new();
2265        let mut retrieved2 = DatabaseEntry::new();
2266
2267        db1.get_into(None, &key, &mut retrieved1).unwrap();
2268        db2.get_into(None, &key, &mut retrieved2).unwrap();
2269
2270        assert_eq!(retrieved1.data_opt().unwrap(), b"value1");
2271        assert_eq!(retrieved2.data_opt().unwrap(), b"value2");
2272    }
2273
2274    #[test]
2275    fn test_empty_keys_and_values() {
2276        let (_temp_dir, _env, db) = temp_env_and_db();
2277
2278        let empty_key = DatabaseEntry::from_bytes(b"");
2279        let empty_value = DatabaseEntry::from_bytes(b"");
2280
2281        db.put(&empty_key, &empty_value).unwrap();
2282
2283        let mut retrieved = DatabaseEntry::new();
2284        let result = db.get_into(None, &empty_key, &mut retrieved).unwrap();
2285        assert!(result);
2286        assert_eq!(retrieved.data_opt().unwrap(), b"");
2287    }
2288
2289    #[test]
2290    fn test_large_keys_and_values() {
2291        let (_temp_dir, _env, db) = temp_env_and_db();
2292
2293        let large_key = DatabaseEntry::from_bytes(&vec![b'k'; 1000]);
2294        let large_value = DatabaseEntry::from_bytes(&vec![b'v'; 10000]);
2295
2296        db.put(&large_key, &large_value).unwrap();
2297
2298        let mut retrieved = DatabaseEntry::new();
2299        db.get_into(None, &large_key, &mut retrieved).unwrap();
2300        assert_eq!(retrieved.data_opt().unwrap().len(), 10000);
2301        assert!(retrieved.data_opt().unwrap().iter().all(|&b| b == b'v'));
2302    }
2303
2304    #[test]
2305    fn test_binary_keys_and_values() {
2306        let (_temp_dir, _env, db) = temp_env_and_db();
2307
2308        let binary_key = DatabaseEntry::from_bytes(&[0u8, 1, 2, 255, 254, 253]);
2309        let binary_value = DatabaseEntry::from_bytes(&[255u8, 0, 128, 64, 32]);
2310
2311        db.put(&binary_key, &binary_value).unwrap();
2312
2313        let mut retrieved = DatabaseEntry::new();
2314        db.get_into(None, &binary_key, &mut retrieved).unwrap();
2315        assert_eq!(retrieved.data_opt().unwrap(), &[255u8, 0, 128, 64, 32]);
2316    }
2317
2318    #[test]
2319    fn test_scan_all_kv_empty() {
2320        let (_temp_dir, _env, db) = temp_env_and_db();
2321        let kv = db.scan_all_kv().unwrap();
2322        assert!(kv.is_empty());
2323    }
2324
2325    #[test]
2326    fn test_scan_all_kv_returns_records() {
2327        let (_temp_dir, _env, db) = temp_env_and_db();
2328        db.put(
2329            DatabaseEntry::from_vec(vec![1]),
2330            DatabaseEntry::from_vec(vec![10]),
2331        )
2332        .unwrap();
2333        db.put(
2334            DatabaseEntry::from_vec(vec![2]),
2335            DatabaseEntry::from_vec(vec![20]),
2336        )
2337        .unwrap();
2338        let kv = db.scan_all_kv().unwrap();
2339        assert_eq!(kv.len(), 2);
2340    }
2341
2342    #[test]
2343    fn test_scan_all_kv_then_delete() {
2344        let (_temp_dir, _env, db) = temp_env_and_db();
2345        db.put(
2346            DatabaseEntry::from_vec(vec![1]),
2347            DatabaseEntry::from_vec(vec![10]),
2348        )
2349        .unwrap();
2350        db.put(
2351            DatabaseEntry::from_vec(vec![2]),
2352            DatabaseEntry::from_vec(vec![20]),
2353        )
2354        .unwrap();
2355
2356        let kv = db.scan_all_kv().unwrap();
2357        assert_eq!(kv.len(), 2);
2358
2359        for (k, _v) in &kv {
2360            let status = db.delete(DatabaseEntry::from_vec(k.clone())).unwrap();
2361            assert!(status, "delete failed for key {:?}", k);
2362        }
2363
2364        let count = db.count().unwrap();
2365        assert_eq!(count, 0, "expected 0 records after deletes, got {}", count);
2366    }
2367
2368    #[test]
2369    fn test_scan_all_kv_then_delete_u64_be_keys() {
2370        // Simulate the exact pattern used in EntityStore::evolve: big-endian u64 keys.
2371        let (_temp_dir, _env, db) = temp_env_and_db();
2372        for id in [1u64, 2u64] {
2373            let key_bytes = id.to_be_bytes().to_vec();
2374            let val_bytes = format!("user{}", id).into_bytes();
2375            db.put(
2376                DatabaseEntry::from_vec(key_bytes),
2377                DatabaseEntry::from_vec(val_bytes),
2378            )
2379            .unwrap();
2380        }
2381        assert_eq!(db.count().unwrap(), 2);
2382
2383        let records = db.scan_all_kv().unwrap();
2384        assert_eq!(records.len(), 2);
2385
2386        for (k, _v) in records {
2387            let status = db.delete(DatabaseEntry::from_vec(k.clone())).unwrap();
2388            assert!(status, "delete failed for u64 key {:?}", k);
2389        }
2390        assert_eq!(db.count().unwrap(), 0);
2391    }
2392
2393    // ========================================================================
2394    // Additional branch-coverage tests
2395    // ========================================================================
2396
2397    /// get() with a None-data DatabaseEntry returns NotFound.
2398    #[test]
2399    fn test_get_with_none_key_data_returns_not_found() {
2400        let (_temp_dir, _env, db) = temp_env_and_db();
2401        let key_none = DatabaseEntry::new(); // no data set
2402        let mut data = DatabaseEntry::new();
2403
2404        let result = db.get_into(None, &key_none, &mut data).unwrap();
2405        assert!(!result);
2406    }
2407
2408    /// delete() with a None-data DatabaseEntry returns NotFound.
2409    #[test]
2410    fn test_delete_with_none_key_data_returns_not_found() {
2411        let (_temp_dir, _env, db) = temp_env_and_db();
2412        let key_none = DatabaseEntry::new();
2413
2414        let result = db.delete(&key_none).unwrap();
2415        assert!(!result);
2416    }
2417
2418    /// open_cursor() with a CursorConfig that has read_uncommitted=true makes
2419    /// the cursor read-only.
2420    #[test]
2421    fn test_open_cursor_read_uncommitted_config_makes_read_only() {
2422        use crate::cursor_config::CursorConfig;
2423        let (_temp_dir, _env, db) = temp_env_and_db();
2424
2425        let config = CursorConfig::new().with_read_uncommitted(true);
2426        let cursor = db.open_cursor(Some(&config)).unwrap();
2427        assert!(cursor.is_read_only());
2428    }
2429
2430    /// open_cursor() with no config and a non-read-only database produces a
2431    /// writable cursor.
2432    #[test]
2433    fn test_open_cursor_no_config_writable_db_is_writable() {
2434        let (_temp_dir, _env, db) = temp_env_and_db();
2435        let cursor = db.open_cursor(None).unwrap();
2436        assert!(!cursor.is_read_only());
2437    }
2438
2439    /// scan_all_kv() on a closed database returns an error.
2440    #[test]
2441    fn test_scan_all_kv_on_closed_database_fails() {
2442        let (_temp_dir, _env, db) = temp_env_and_db();
2443        db.close().unwrap();
2444        let result = db.scan_all_kv();
2445        assert!(result.is_err());
2446    }
2447
2448    /// put_no_overwrite() on a read-only database returns an error.
2449    #[test]
2450    fn test_put_no_overwrite_on_read_only_database_fails() {
2451        let temp_dir = TempDir::new().unwrap();
2452        let env_config = EnvironmentConfig::new(temp_dir.path().to_path_buf())
2453            .with_allow_create(true);
2454        let env = Environment::open(env_config).unwrap();
2455
2456        let db_config = DatabaseConfig::new()
2457            .with_allow_create(true)
2458            .with_transactional(true)
2459            .with_read_only(true);
2460        let db = env.open_database(None, "ro_db", &db_config).unwrap();
2461
2462        let key = DatabaseEntry::from_bytes(b"k");
2463        let val = DatabaseEntry::from_bytes(b"v");
2464        let result = db.put_no_overwrite(&key, &val);
2465        assert!(result.is_err());
2466    }
2467
2468    // =====================================================================
2469    // cursor-failure map_err coverage: use the test hook in noxu-dbi to
2470    // force cursor operations to return Err, exercising the map_err closures
2471    // in Database::get / put / put_no_overwrite / delete / count / scan_all_kv.
2472    // =====================================================================
2473
2474    /// Covers the map_err closure on `cursor.search(...)` inside `get()`.
2475    #[test]
2476    fn test_get_search_map_err_via_hook() {
2477        let (_tmp, _env, db) = temp_env_and_db();
2478        noxu_dbi::set_cursor_fail_after(1); // fail on the 1st check_state (search)
2479        let key = DatabaseEntry::from_bytes(b"any");
2480        let mut data = DatabaseEntry::new();
2481        let result = db.get_into(None, &key, &mut data);
2482        noxu_dbi::clear_cursor_fail_flag();
2483        assert!(result.is_err());
2484    }
2485
2486    /// Covers the map_err closure on `cursor.get_current()` inside `get()`.
2487    #[test]
2488    fn test_get_get_current_map_err_via_hook() {
2489        let (_tmp, _env, db) = temp_env_and_db();
2490        // Insert a key so search can succeed.
2491        db.put(
2492            DatabaseEntry::from_bytes(b"k"),
2493            DatabaseEntry::from_bytes(b"v"),
2494        )
2495        .unwrap();
2496        // fail on the 2nd check (check_initialized inside get_current).
2497        noxu_dbi::set_cursor_fail_after(2);
2498        let key = DatabaseEntry::from_bytes(b"k");
2499        let mut data = DatabaseEntry::new();
2500        let result = db.get_into(None, &key, &mut data);
2501        noxu_dbi::clear_cursor_fail_flag();
2502        assert!(result.is_err());
2503    }
2504
2505    /// Covers the map_err closure on `cursor.put(...)` inside `put()`.
2506    #[test]
2507    fn test_put_map_err_via_hook() {
2508        let (_tmp, _env, db) = temp_env_and_db();
2509        noxu_dbi::set_cursor_fail_after(1);
2510        let key = DatabaseEntry::from_bytes(b"k");
2511        let val = DatabaseEntry::from_bytes(b"v");
2512        let result = db.put(&key, &val);
2513        noxu_dbi::clear_cursor_fail_flag();
2514        assert!(result.is_err());
2515    }
2516
2517    /// Covers the map_err closure on `cursor.put(...)` inside `put_no_overwrite()`.
2518    #[test]
2519    fn test_put_no_overwrite_map_err_via_hook() {
2520        let (_tmp, _env, db) = temp_env_and_db();
2521        noxu_dbi::set_cursor_fail_after(1);
2522        let key = DatabaseEntry::from_bytes(b"k");
2523        let val = DatabaseEntry::from_bytes(b"v");
2524        let result = db.put_no_overwrite(&key, &val);
2525        noxu_dbi::clear_cursor_fail_flag();
2526        assert!(result.is_err());
2527    }
2528
2529    /// Covers the map_err closure on `cursor.search(...)` inside `delete()`.
2530    #[test]
2531    fn test_delete_search_map_err_via_hook() {
2532        let (_tmp, _env, db) = temp_env_and_db();
2533        noxu_dbi::set_cursor_fail_after(1);
2534        let key = DatabaseEntry::from_bytes(b"k");
2535        let result = db.delete(&key);
2536        noxu_dbi::clear_cursor_fail_flag();
2537        assert!(result.is_err());
2538    }
2539
2540    /// Covers the map_err closure on `cursor.delete()` inside `delete()`.
2541    #[test]
2542    fn test_delete_delete_map_err_via_hook() {
2543        let (_tmp, _env, db) = temp_env_and_db();
2544        db.put(
2545            DatabaseEntry::from_bytes(b"k"),
2546            DatabaseEntry::from_bytes(b"v"),
2547        )
2548        .unwrap();
2549        // fail on the 2nd check_state (the delete() call, after search succeeds).
2550        noxu_dbi::set_cursor_fail_after(2);
2551        let key = DatabaseEntry::from_bytes(b"k");
2552        let result = db.delete(&key);
2553        noxu_dbi::clear_cursor_fail_flag();
2554        assert!(result.is_err());
2555    }
2556
2557    /// count() uses the O(1) AtomicU64 counter; cursor-fail hooks do not affect it.
2558    /// Verify the counter is correct across insert/update/delete.
2559    #[test]
2560    fn test_count_atomic_counter_insert_update_delete() {
2561        let (_tmp, _env, db) = temp_env_and_db();
2562
2563        // Empty database starts at 0.
2564        assert_eq!(db.count().unwrap(), 0);
2565
2566        // Insert three distinct keys.
2567        db.put(
2568            DatabaseEntry::from_bytes(b"a"),
2569            DatabaseEntry::from_bytes(b"1"),
2570        )
2571        .unwrap();
2572        db.put(
2573            DatabaseEntry::from_bytes(b"b"),
2574            DatabaseEntry::from_bytes(b"2"),
2575        )
2576        .unwrap();
2577        db.put(
2578            DatabaseEntry::from_bytes(b"c"),
2579            DatabaseEntry::from_bytes(b"3"),
2580        )
2581        .unwrap();
2582        assert_eq!(db.count().unwrap(), 3);
2583
2584        // Overwrite an existing key — count must NOT change.
2585        db.put(
2586            DatabaseEntry::from_bytes(b"a"),
2587            DatabaseEntry::from_bytes(b"updated"),
2588        )
2589        .unwrap();
2590        assert_eq!(db.count().unwrap(), 3);
2591
2592        // Delete one key — count decrements.
2593        db.delete(DatabaseEntry::from_bytes(b"b")).unwrap();
2594        assert_eq!(db.count().unwrap(), 2);
2595    }
2596
2597    /// count() is O(1): verify it still works even when the cursor fail-hook
2598    /// is active (the hook only affects cursor operations, not the atomic read).
2599    #[test]
2600    fn test_count_unaffected_by_cursor_fail_hook() {
2601        let (_tmp, _env, db) = temp_env_and_db();
2602        db.put(
2603            DatabaseEntry::from_bytes(b"k"),
2604            DatabaseEntry::from_bytes(b"v"),
2605        )
2606        .unwrap();
2607        noxu_dbi::set_cursor_fail_after(1);
2608        // count() must succeed (no cursor used).
2609        let result = db.count();
2610        noxu_dbi::clear_cursor_fail_flag();
2611        assert!(result.is_ok());
2612        assert_eq!(result.unwrap(), 1);
2613    }
2614
2615    /// Covers the map_err closure on `cursor.get_first()` inside `scan_all_kv()`.
2616    #[test]
2617    fn test_scan_all_kv_get_first_map_err_via_hook() {
2618        let (_tmp, _env, db) = temp_env_and_db();
2619        noxu_dbi::set_cursor_fail_after(1);
2620        let result = db.scan_all_kv();
2621        noxu_dbi::clear_cursor_fail_flag();
2622        assert!(result.is_err());
2623    }
2624
2625    /// Covers the map_err closure on `cursor.get_current()` inside `scan_all_kv()`.
2626    #[test]
2627    fn test_scan_all_kv_get_current_map_err_via_hook() {
2628        let (_tmp, _env, db) = temp_env_and_db();
2629        db.put(
2630            DatabaseEntry::from_bytes(b"k"),
2631            DatabaseEntry::from_bytes(b"v"),
2632        )
2633        .unwrap();
2634        // fail on the 2nd check (check_initialized inside get_current, after get_first succeeds).
2635        noxu_dbi::set_cursor_fail_after(2);
2636        let result = db.scan_all_kv();
2637        noxu_dbi::clear_cursor_fail_flag();
2638        assert!(result.is_err());
2639    }
2640
2641    /// Covers the map_err closure on `cursor.retrieve_next(...)` inside `scan_all_kv()`.
2642    #[test]
2643    fn test_scan_all_kv_retrieve_next_map_err_via_hook() {
2644        let (_tmp, _env, db) = temp_env_and_db();
2645        db.put(
2646            DatabaseEntry::from_bytes(b"k"),
2647            DatabaseEntry::from_bytes(b"v"),
2648        )
2649        .unwrap();
2650        // fail on the 3rd check (retrieve_next, after get_first and get_current succeed).
2651        noxu_dbi::set_cursor_fail_after(3);
2652        let result = db.scan_all_kv();
2653        noxu_dbi::clear_cursor_fail_flag();
2654        assert!(result.is_err());
2655    }
2656
2657    #[test]
2658    fn test_sync_on_open_database_succeeds() {
2659        let (_tmp, _env, db) = temp_env_and_db();
2660        db.put(
2661            DatabaseEntry::from_bytes(b"key"),
2662            DatabaseEntry::from_bytes(b"val"),
2663        )
2664        .unwrap();
2665        assert!(db.sync().is_ok());
2666    }
2667
2668    #[test]
2669    fn test_sync_on_closed_database_fails() {
2670        let (_tmp, _env, db) = temp_env_and_db();
2671        db.close().unwrap();
2672        assert!(db.sync().is_err());
2673    }
2674
2675    // ── verify ─────────────────────────────────────────────────────────────
2676
2677    #[test]
2678    fn test_verify_empty_database_passes() {
2679        use noxu_engine::VerifyConfig;
2680        let (_tmp, _env, db) = temp_env_and_db();
2681        let config = VerifyConfig::default();
2682        let result = db.verify(&config).unwrap();
2683        assert!(result.passed, "empty db should pass: {:?}", result.errors);
2684    }
2685
2686    #[test]
2687    fn test_verify_populated_database_passes() {
2688        use noxu_engine::VerifyConfig;
2689        let (_tmp, _env, db) = temp_env_and_db();
2690        for i in 0u32..20 {
2691            let k = DatabaseEntry::from_bytes(&i.to_be_bytes());
2692            let v = DatabaseEntry::from_bytes(&(i * 2).to_be_bytes());
2693            db.put(&k, &v).unwrap();
2694        }
2695        let config = VerifyConfig::default();
2696        let result = db.verify(&config).unwrap();
2697        assert!(result.passed, "populated db should pass: {:?}", result.errors);
2698        assert!(result.records_verified > 0);
2699    }
2700
2701    #[test]
2702    fn test_verify_closed_database_fails() {
2703        use noxu_engine::VerifyConfig;
2704        let (_tmp, _env, db) = temp_env_and_db();
2705        db.close().unwrap();
2706        let config = VerifyConfig::default();
2707        assert!(db.verify(&config).is_err());
2708    }
2709
2710    // ── get_with_options / put_with_options ────────────────────────────────
2711
2712    #[test]
2713    fn test_get_with_options_default_reads_written_record() {
2714        use crate::read_options::ReadOptions;
2715        let (_tmp, _env, db) = temp_env_and_db();
2716        let key = DatabaseEntry::from_bytes(b"ropt_key");
2717        let val = DatabaseEntry::from_bytes(b"ropt_val");
2718        db.put(&key, &val).unwrap();
2719
2720        let opts = ReadOptions::new();
2721        let out = db.get_with_options(None, &key, &opts).unwrap();
2722        assert!(out.is_some());
2723        assert_eq!(out.unwrap().as_ref(), b"ropt_val");
2724    }
2725
2726    #[test]
2727    fn test_get_with_options_read_uncommitted_sees_written_record() {
2728        use crate::read_options::ReadOptions;
2729        let (_tmp, _env, db) = temp_env_and_db();
2730        let key = DatabaseEntry::from_bytes(b"ru_key");
2731        let val = DatabaseEntry::from_bytes(b"ru_val");
2732        db.put(&key, &val).unwrap();
2733
2734        let opts = ReadOptions::read_uncommitted();
2735        let out = db.get_with_options(None, &key, &opts).unwrap();
2736        assert!(out.is_some());
2737        assert_eq!(out.unwrap().as_ref(), b"ru_val");
2738    }
2739
2740    #[test]
2741    fn test_get_with_options_not_found() {
2742        use crate::read_options::ReadOptions;
2743        let (_tmp, _env, db) = temp_env_and_db();
2744        let key = DatabaseEntry::from_bytes(b"missing");
2745        let opts = ReadOptions::new();
2746        let out = db.get_with_options(None, &key, &opts).unwrap();
2747        assert!(out.is_none());
2748    }
2749
2750    #[test]
2751    fn test_put_with_options_no_ttl_behaves_like_put() {
2752        use crate::write_options::WriteOptions;
2753        let (_tmp, _env, db) = temp_env_and_db();
2754        let key = DatabaseEntry::from_bytes(b"wopt_key");
2755        let val = DatabaseEntry::from_bytes(b"wopt_val");
2756        let opts = WriteOptions::new();
2757        db.put_with_options(None, &key, &val, &opts).unwrap();
2758
2759        let mut out = DatabaseEntry::new();
2760        db.get_into(None, &key, &mut out).unwrap();
2761        assert_eq!(out.data_opt().unwrap(), b"wopt_val");
2762    }
2763
2764    #[test]
2765    fn test_put_with_options_with_ttl_stores_record() {
2766        use crate::write_options::WriteOptions;
2767        let (_tmp, _env, db) = temp_env_and_db();
2768        let key = DatabaseEntry::from_bytes(b"ttl_key");
2769        let val = DatabaseEntry::from_bytes(b"ttl_val");
2770        // TTL of 1 hour — the record is not yet expired so it should be readable
2771        let opts = WriteOptions::with_expiration(1);
2772        db.put_with_options(None, &key, &val, &opts).unwrap();
2773
2774        let mut out = DatabaseEntry::new();
2775        let read_status = db.get_into(None, &key, &mut out).unwrap();
2776        assert!(read_status);
2777        assert_eq!(out.data_opt().unwrap(), b"ttl_val");
2778    }
2779
2780    #[test]
2781    fn test_put_with_options_closed_db_fails() {
2782        use crate::write_options::WriteOptions;
2783        let (_tmp, _env, db) = temp_env_and_db();
2784        db.close().unwrap();
2785        let key = DatabaseEntry::from_bytes(b"k");
2786        let val = DatabaseEntry::from_bytes(b"v");
2787        let opts = WriteOptions::new();
2788        assert!(db.put_with_options(None, &key, &val, &opts).is_err());
2789    }
2790
2791    // ========================================================================
2792    // Audit database F11 — Wave 2C-4: reject None-data keys on writes.
2793    // ========================================================================
2794
2795    // 7.0 NOTE: the three `*_with_none_key_returns_illegal_argument` tests
2796    // were removed in the 7.0 API reshape (review P1-3).  The write surface
2797    // now takes `key: impl AsRef<[u8]>`, so a key is *always* a byte slice;
2798    // the historical "None key" (a `DatabaseEntry` with no data set, distinct
2799    // from an empty `b""`) can no longer be expressed at the call site.
2800    // An empty key is accepted on writes — see
2801    // `test_put_with_explicit_empty_key_accepted`, which the reshape kept as
2802    // the canonical behaviour.  The removed tests asserted a None-vs-empty
2803    // distinction that the new signature intentionally eliminates.
2804
2805    /// Explicit `Some(&[])` empty key is still accepted on writes.
2806    #[test]
2807    fn test_put_with_explicit_empty_key_accepted() {
2808        let (_tmp, _env, db) = temp_env_and_db();
2809        let empty_key = DatabaseEntry::from_bytes(b"");
2810        let val = DatabaseEntry::from_bytes(b"v");
2811        db.put(&empty_key, &val).unwrap();
2812    }
2813
2814    // ── X-13: env-invalidity checks propagate through check_open ──────────────
2815
2816    /// X-13: after the `io_invalid` flag is set, `db.get` must return
2817    /// `EnvironmentFailure` rather than silently reading stale BIN data.
2818    #[test]
2819    fn test_x13_io_invalid_blocks_db_get() {
2820        use std::sync::atomic::Ordering;
2821        let (_tmp, env, db) = temp_env_and_db();
2822
2823        // Write a record so there is something to read.
2824        let key = DatabaseEntry::from_bytes(b"k");
2825        let val = DatabaseEntry::from_bytes(b"v");
2826        db.put(&key, &val).unwrap();
2827
2828        // Flip io_invalid via the cached LogManager.
2829        let lm = db.log_manager.as_ref().expect("WAL env must have LogManager");
2830        lm.io_invalid.store(true, Ordering::Release);
2831
2832        // db.get must now fail.
2833        let mut out = DatabaseEntry::new();
2834        let result = db.get_into(None, &key, &mut out);
2835        assert!(
2836            matches!(result, Err(NoxuError::EnvironmentFailure { .. })),
2837            "expected EnvironmentFailure, got {result:?}"
2838        );
2839
2840        // db.put must also fail.
2841        let result2 = db.put(&key, &val);
2842        assert!(
2843            matches!(result2, Err(NoxuError::EnvironmentFailure { .. })),
2844            "expected EnvironmentFailure on put, got {result2:?}"
2845        );
2846
2847        // Restore flag so env closes cleanly.
2848        lm.io_invalid.store(false, Ordering::Release);
2849        drop(env);
2850    }
2851
2852    /// X-13: after `EnvironmentImpl::invalidate()`, cursor `get_first`
2853    /// must return `EnvironmentFailure`.
2854    #[test]
2855    fn test_x13_env_invalid_blocks_cursor_get() {
2856        use std::sync::atomic::Ordering;
2857        let (_tmp, env, db) = temp_env_and_db();
2858
2859        // Insert a record.
2860        let key = DatabaseEntry::from_bytes(b"ck");
2861        let val = DatabaseEntry::from_bytes(b"cv");
2862        db.put(&key, &val).unwrap();
2863
2864        // Open a cursor BEFORE invalidating.
2865        let mut cursor = db.open_cursor(None).unwrap();
2866
2867        // Now directly flip the env_invalid flag.
2868        db.env_invalid.store(true, Ordering::Release);
2869
2870        // The cursor's check_state should detect the flag.
2871        let mut key = DatabaseEntry::new();
2872        let mut out = DatabaseEntry::new();
2873        let result =
2874            cursor.get(&mut key, &mut out, crate::get::Get::First, None);
2875        assert!(
2876            matches!(result, Err(NoxuError::EnvironmentFailure { .. })),
2877            "expected EnvironmentFailure from cursor, got {result:?}"
2878        );
2879
2880        // Restore so env drops cleanly.
2881        db.env_invalid.store(false, Ordering::Release);
2882        drop(env);
2883    }
2884}