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}