chisel/lib.rs
1// lib.rs — Chisel: a transactional slot-based storage engine.
2//
3// Role in system: top of the dependency graph. This file is a thin surface
4// over `TransactionManager`; it owns no storage logic of its own. Its job is
5// to (a) present a stable public API, (b) translate `Options` into the right
6// open/create path, and (c) re-export error and result types. All real work
7// lives below in `transaction.rs` and further down.
8//
9// Concurrency model: a `Chisel` value is NOT `Sync` and is intended for
10// single-threaded use. All mutating methods take `&mut self`, which by
11// construction serializes access through the borrow checker. There is no
12// internal locking beyond that.
13//
14// Process model: `PageIo` acquires an exclusive advisory `flock` on open, so
15// at most one `Chisel` (in any process, on the same host/filesystem) can hold
16// a given database file at a time. A second `open()` on the same path returns
17// `LockFailed` rather than blocking.
18//
19// Durability model: see `transaction.rs`. Commits are shadow-paged and
20// finalized by a superblock swap; there is no WAL and no background writer.
21
22// I124: enforce `# Errors` rustdoc on every public fallible method. The lint
23// only fires on truly-public items, and the entire public API lives in this
24// file, so internal `pub(crate)` modules are unaffected. CI runs clippy with
25// `-D warnings`, which promotes this to a hard error — a new public `-> Result`
26// method without an `# Errors` section fails the build.
27#![warn(clippy::missing_errors_doc)]
28
29// I35 (ISSUES.md, 2026-05-22): every storage-internals module is
30// pub(crate). The supported public surface is the curated re-export
31// list further down (Chisel, Options, DrainInsertion, ChiselError,
32// Result, Stats, ChiselCounters, DefragOptions, DefragStats, PAGE_SIZE,
33// plus the superblock layout constants). Internal types like
34// TransactionManager / PageCache / HandleEntry / Superblock /
35// PageType are NOT part of the API stability contract; reaching for
36// them from a downstream crate requires either a path-dep with
37// #[cfg(test)] access (the bench subcrate does this implicitly
38// through the public API) or copying the relevant logic out.
39pub(crate) mod crypto;
40pub(crate) mod data_page;
41pub(crate) mod defrag;
42pub(crate) mod error;
43pub(crate) mod freemap;
44pub(crate) mod freemap_tree;
45pub(crate) mod handle;
46pub(crate) mod handle_table;
47mod lru;
48pub(crate) mod membership_index;
49pub(crate) mod overflow;
50pub(crate) mod page;
51pub(crate) mod page_cache;
52pub(crate) mod page_io;
53mod spillway;
54pub(crate) mod stats;
55pub(crate) mod superblock;
56pub(crate) mod transaction;
57
58// I35: crash-recovery integration tests need direct access to internal
59// types (Superblock, PageType, page format constants) for corruption
60// injection. The I35 pub→pub(crate) reshape locks these down, so the
61// suite moved from tests/crash_recovery.rs into src/. cfg(test)-only so
62// it adds nothing to release builds.
63#[cfg(test)]
64mod recovery_tests;
65
66pub use error::{ChiselError, Result};
67
68// Re-exports of the curated public surface. The internal modules these
69// items live in are pub(crate) (ISSUES.md I35, landed in PR #11); these
70// re-exports define the supported access paths and keep the documented
71// API at the crate root.
72pub use defrag::{DefragOptions, DefragStats};
73pub use handle::{Handle, Tag, TagDropProgress};
74pub use page::PAGE_SIZE;
75pub use stats::{ChiselCounters, Stats};
76// SlotDefect and SuperblockDefect were public before this branch (pre-existing API).
77pub use superblock::{
78 SlotDefect, SuperblockDefect, DEFAULT_SUPERBLOCK_COUNT, MAX_SUPERBLOCKS, MIN_SUPERBLOCKS,
79 NAMED_ROOT_COUNT, NAMED_ROOT_NAME_LEN,
80};
81// format_major was public before this branch (I29 read-dispatch).
82pub use page::format_major;
83// Key and Argon2Params are public API (callers need them to open encrypted DBs).
84// Crypto internals (PageCipher, CryptoError, raw constants) are pub(crate) in
85// their source modules and not re-exported here.
86pub use crypto::{Argon2Params, Key};
87
88use std::path::Path;
89
90use page_cache::PageCache;
91use page_io::PageIo;
92use transaction::TransactionManager;
93
94/// Open-time options. These are consumed once during `Chisel::open` and not
95/// retained on the live handle; changing them later requires reopening.
96///
97/// `cache_max_bytes` is a strict upper bound on the in-memory page cache, in
98/// bytes. Internally converted to a page count via `bytes / PAGE_SIZE`
99/// (rounded down, clamped to at least one page). Replaces the previous
100/// `cache_size: usize` (page count) field; bytes are user-friendly because
101/// callers think in MB/GB, not 8KB units. Default 8 MiB = 1024 pages
102/// (matches the previous default).
103///
104/// `spillway_max_bytes` is a strict upper bound on the spillway's LIVE
105/// resident set, in bytes (excluding per-slot 16-byte headers). When the cache
106/// is full and dirty, overflow dirty pages are written to the spillway
107/// rather than aborting; exceeding this limit (live spilled pages ×
108/// `PAGE_SIZE`) trips `ChiselError::SpillwayFull`. The cap is charged against
109/// LIVE residency, not cumulative spill volume: a page read back and respilled
110/// within a transaction does not count twice, so the limit is predictable for
111/// long transactions. The physical sidecar FILE may transiently grow past this
112/// cap (the write cursor is monotonic within a transaction); that tail is
113/// reclaimed when the spillway is truncated at commit/rollback. Default
114/// `1024 * cache_max_bytes` (8 GiB at the default cache size). Setting to 0
115/// disables the spillway entirely — overflow then trips
116/// `ChiselError::CacheFull` at the strict cache cap, with no 8× elasticity (the
117/// previous `HARD_CEILING_MULTIPLIER` is removed).
118///
119/// `drain_insertion` controls where commit-drain rehydrated pages land
120/// in the LRU. `LruTail` (default) makes them first eviction candidates
121/// after commit, preserving the pre-transaction warm working set;
122/// `Mru` treats them as just-touched. See spec §"Drain insertion policy".
123///
124/// `read_only` still takes an exclusive `flock` — it only suppresses
125/// writes at the application layer.
126///
127/// `superblock_count` (ISSUES.md R4) controls how many superblock slots a
128/// freshly-created database uses. Default 2 (matches the original layout);
129/// valid range is 2..=16. Higher N trades disk space (N × 8 KB) for
130/// resilience against consecutive torn writes — N=3 survives one torn
131/// commit followed by a torn retry, N=4 survives two retries. This
132/// option is ONLY consulted when creating a new database; reopening an
133/// existing file discovers N from the on-disk superblock itself.
134/// I36 (ISSUES.md, 2026-05-22): `#[non_exhaustive]` so adding a future
135/// field (a tuning knob for cache warmup, an fsync-coalescing hint,
136/// etc.) is not a breaking change. External callers must construct via
137/// `Options { ..Options::default() }` rather than a full struct
138/// literal; `Default` is implemented below.
139#[non_exhaustive]
140#[derive(Debug, Clone)]
141pub struct Options {
142 pub cache_max_bytes: u64,
143 pub spillway_max_bytes: u64,
144 pub drain_insertion: DrainInsertion,
145 pub create_if_missing: bool,
146 pub read_only: bool,
147 pub superblock_count: u32,
148 /// Encryption key for an encrypted database. `None` (default) opens or
149 /// creates a plaintext DB. On create, `Some(key)` makes a new encrypted
150 /// DB sealed under a random DEK wrapped by this key. On reopen, the key
151 /// must unwrap one of the on-disk key slots or `open` returns
152 /// `InvalidEncryptionKey`. Supplying a key to open a plaintext DB returns
153 /// `EncryptionNotSupported`; omitting it on an encrypted DB returns
154 /// `NoEncryptionKey`.
155 pub encryption_key: Option<Key>,
156 /// Argon2id cost parameters used to derive the KEK from a `Key::Passphrase`
157 /// on *create*. `None` uses `Argon2Params::default()` (OWASP: 19 MiB / t=2 /
158 /// p=1). Ignored for `Key::Raw` (HKDF, no cost params) and on reopen (the
159 /// params are read from the key slot the file was written with).
160 pub argon2_params: Option<Argon2Params>,
161}
162
163/// Where commit-drain rehydrated pages are inserted into the LRU.
164///
165/// `LruTail` makes the just-drained pages the first eviction candidates
166/// after commit; preserves any pre-transaction warm pages. The default,
167/// per spec §"Drain insertion policy".
168///
169/// `Mru` treats drained pages as recently touched. Useful when the
170/// caller expects to read them again next transaction.
171///
172/// I36: `#[non_exhaustive]` so a third drain policy (e.g. a hint-based
173/// split between recently-touched and cold) can land without breaking
174/// callers. External `match` arms need a `_ => …` catchall.
175#[non_exhaustive]
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum DrainInsertion {
178 LruTail,
179 Mru,
180}
181
182/// How to open a spillway sidecar. `Path` for file-backed databases
183/// (path is the main db path; spillway will be at `<path>.spillway`),
184/// `InMemory` for memory-backed.
185///
186/// I37 (ISSUES.md, 2026-05-22): pub(crate) because the only legitimate
187/// constructors are inside `Chisel::open` and
188/// `Chisel::open_in_memory_with_options`. External callers route
189/// through those — there's no API path that needs them to construct
190/// a `SpillwayLocation` directly.
191#[derive(Debug, Clone)]
192pub(crate) enum SpillwayLocation {
193 Path(std::path::PathBuf),
194 InMemory,
195}
196
197impl Default for Options {
198 fn default() -> Options {
199 let cache_max_bytes = 8 * 1024 * 1024; // 8 MiB = 1024 × 8 KiB pages
200 Options {
201 cache_max_bytes,
202 // saturating_mul (not `*`) so this default stays total if the
203 // hardcoded cache_max_bytes above is ever edited up near u64::MAX:
204 // it clamps to u64::MAX rather than wrapping to a tiny spillway cap.
205 // Harmless at the current 8 MiB (product is 8 GiB, nowhere near overflow).
206 spillway_max_bytes: cache_max_bytes.saturating_mul(1024),
207 drain_insertion: DrainInsertion::LruTail,
208 create_if_missing: true,
209 read_only: false,
210 superblock_count: superblock::DEFAULT_SUPERBLOCK_COUNT,
211 encryption_key: None,
212 argon2_params: None,
213 }
214 }
215}
216
217// I36: chained setters paired with the #[non_exhaustive] attribute on
218// Options above. External crates can't construct via a struct literal
219// — even with `..Options::default()` — so the supported way to build a
220// customized Options is `Options::default().cache_max_bytes(…)…`. The
221// setters take and return `Self` by value (move semantics) so a chain
222// builds the final value in one expression and never holds a `&mut Options`
223// borrow.
224//
225// Method names match the field names, not `with_*`-prefixed. Rust resolves
226// the field-vs-method ambiguity by context: `o.cache_max_bytes` is field
227// access; `o.cache_max_bytes(N)` is a method call. The unprefixed form
228// is consistent with sqlx, redb, and most modern crates; `with_*` is the
229// older convention and uses more vertical space.
230impl Options {
231 pub fn cache_max_bytes(mut self, bytes: u64) -> Self {
232 self.cache_max_bytes = bytes;
233 self
234 }
235 pub fn spillway_max_bytes(mut self, bytes: u64) -> Self {
236 self.spillway_max_bytes = bytes;
237 self
238 }
239 pub fn drain_insertion(mut self, policy: DrainInsertion) -> Self {
240 self.drain_insertion = policy;
241 self
242 }
243 pub fn create_if_missing(mut self, create: bool) -> Self {
244 self.create_if_missing = create;
245 self
246 }
247 pub fn read_only(mut self, read_only: bool) -> Self {
248 self.read_only = read_only;
249 self
250 }
251 /// Set the number of superblock slots. Valid range is 2..=16; any other
252 /// value is accepted here and rejected at open time with
253 /// `ChiselError::InvalidSuperblockCount`. This is only consulted on
254 /// initial file creation; existing files use the count baked into their
255 /// header.
256 pub fn superblock_count(mut self, count: u32) -> Self {
257 self.superblock_count = count;
258 self
259 }
260
261 /// Set the encryption key. On create, a fresh DEK is generated and
262 /// wrapped into key-slot 0 under a KEK derived from this key; the
263 /// superblock is stamped MAJOR=2. On open, the key is used to unwrap
264 /// the stored DEK from the matching slot. See [`Options::encryption_key`]
265 /// for the full create-vs-reopen semantics.
266 pub fn encryption_key(mut self, key: Key) -> Self {
267 self.encryption_key = Some(key);
268 self
269 }
270 /// Set the Argon2id cost parameters used when deriving a KEK from a
271 /// passphrase on database creation. No effect for raw keys or on reopen
272 /// (the stored slot carries its own params). See [`Options::argon2_params`].
273 pub fn argon2_params(mut self, params: Argon2Params) -> Self {
274 self.argon2_params = Some(params);
275 self
276 }
277}
278
279/// A live handle to an open Chisel database.
280///
281/// Owns (transitively) the page cache and the current in-memory view of
282/// the superblock roots. For file-backed databases it also owns the
283/// exclusive `flock`; memory-backed databases (opened via
284/// `open_in_memory[_with_options]`) have no lock because the `Vec`-backed
285/// `PageIo` is itself the database and cannot be opened twice by
286/// construction. Dropping a `Chisel` releases the page cache and closes
287/// the underlying file, which in turn releases the `flock` on the file
288/// path (the lock is tied to the file descriptor, so drop order is what
289/// matters — not an explicit unlock call).
290///
291/// IMPORTANT: dropping without calling `commit()` on an in-flight transaction
292/// discards that transaction. Shadow paging guarantees the on-disk state is
293/// still the last committed state, not a partial write.
294///
295/// Poison model (see ISSUES.md I1): if any method returns a fatal error
296/// (I/O failure, checksum mismatch, corrupt superblock, commit protocol
297/// failure), the `Chisel` handle becomes *poisoned*. Every subsequent call
298/// — including reads — returns `ChiselError::Poisoned`. The only legal
299/// recovery is to drop this `Chisel` and call `Chisel::open` again; the
300/// shadow-paging crash-recovery path on reopen returns the database to the
301/// last durable state. This mirrors `std::sync::Mutex` poisoning and is
302/// necessary because Linux `fsync` semantics (fsyncgate, 2018) do not
303/// permit safely retrying a failed fsync — the kernel may have discarded
304/// the dirty pages before reporting the error.
305///
306/// # Errors and poisoning
307///
308/// One rule is shared by every fallible method below: once the handle is
309/// poisoned (above), the method returns [`ChiselError::Poisoned`] — reads
310/// included. Each method's own `# Errors` section therefore lists only the
311/// *operational* (recoverable, non-poisoning) errors specific to that call;
312/// it does not re-list `Poisoned` or the fatal I/O and corruption errors,
313/// which are universal and all funnel into the poison model. Operational
314/// errors leave the handle usable: fix the condition (or `rollback`) and
315/// continue. The constructors (`open`, `open_in_memory*`) have no handle to
316/// poison, so their errors are fully enumerated in place.
317// I68 (ISSUES.md, 2026-05-22): `Chisel` has no explicit `Drop` impl
318// because shadow paging guarantees the on-disk state is always the
319// last successfully committed state — whether the value goes out of
320// scope via an explicit `close()`, a panic unwind, or a forgotten
321// `_db` binding at the end of `main`. A reader coming from Postgres
322// or RocksDB might expect `Drop` to fsync or to discard uncommitted
323// work explicitly; here, the COW protocol makes both redundant. The
324// type-level doc below documents the user-facing semantics.
325pub struct Chisel {
326 txm: TransactionManager,
327}
328
329impl Chisel {
330 /// Open or create a Chisel database at `path`.
331 ///
332 /// The "exists" check deliberately treats a zero-length file as
333 /// nonexistent: a freshly-created-but-unwritten file (e.g. from a crash
334 /// between `creat(2)` and the first superblock write, or from a user
335 /// `touch`) has no valid superblock and must go through the
336 /// `create_new` path. Without this, `open_existing` would try to parse
337 /// an empty file and fail with a corruption error.
338 ///
339 /// Acquires an exclusive `flock` on the file before any parsing, so a
340 /// second concurrent `open()` on the same path fails fast with
341 /// `LockFailed` rather than racing on the superblock.
342 ///
343 /// # Errors
344 /// `InvalidSuperblockCount` (the `superblock_count` option is out of
345 /// range), `FileNotFound` (no file at `path` and `create_if_missing` is
346 /// false), or `LockFailed` (another handle holds the exclusive flock).
347 /// For an encrypted database: `NoEncryptionKey` (file is encrypted but
348 /// no `encryption_key` given), `InvalidEncryptionKey` (key unwraps no
349 /// key slot), or `EncryptionNotSupported` (key given for a plaintext
350 /// file). When reopening an existing file, parsing the superblock can
351 /// also yield `UnsupportedFormatVersion`, `CorruptSuperblock`,
352 /// `ChecksumMismatch`, `FileSizeMismatch`, or `IoError`.
353 pub fn open(path: &Path, options: Options) -> Result<Chisel> {
354 // R4: validate superblock_count before touching the file.
355 // Only meaningful on the create path, but we check it always
356 // so a malformed Options is caught up front rather than after
357 // the file has been opened.
358 if options.superblock_count < superblock::MIN_SUPERBLOCKS
359 || options.superblock_count > superblock::MAX_SUPERBLOCKS
360 {
361 return Err(ChiselError::InvalidSuperblockCount {
362 value: options.superblock_count,
363 });
364 }
365
366 let file_exists = path.exists()
367 && std::fs::metadata(path)
368 .map(|m| m.len() > 0)
369 .unwrap_or(false);
370
371 if !file_exists && !options.create_if_missing {
372 return Err(ChiselError::FileNotFound);
373 }
374
375 let io = PageIo::open(path, options.read_only)?;
376 // I143: decide create-vs-open from the file length observed AFTER the
377 // flock is held (page_count() returns the count cached from the post-lock
378 // length), NOT from the pre-lock `file_exists` stat. The pre-lock stat
379 // races a concurrent creator: another process can create + commit +
380 // release the lock between our stat and our lock, and the stale boolean
381 // would then run create_new over its just-committed data. `file_exists`
382 // stays above only for the create_if_missing gate, which must remain
383 // pre-lock so a refused open never materializes an empty file.
384 let existed = io.page_count()? > 0;
385 let cache = PageCache::new(
386 io,
387 options.cache_max_bytes,
388 options.spillway_max_bytes,
389 options.drain_insertion,
390 SpillwayLocation::Path(path.to_path_buf()),
391 );
392
393 let txm = if existed {
394 // Existing database: N is discovered from the on-disk
395 // superblock. options.superblock_count is ignored here.
396 TransactionManager::open_existing(cache, options.encryption_key.clone())?
397 } else {
398 TransactionManager::create_new(
399 cache,
400 options.superblock_count,
401 options.encryption_key.clone(),
402 options.argon2_params,
403 )?
404 };
405
406 Ok(Chisel { txm })
407 }
408
409 /// Open a non-durable, memory-backed Chisel database. Intended for
410 /// benchmark comparisons against SQLite `:memory:` and for tests that
411 /// do not need filesystem persistence. All data is lost when the
412 /// returned `Chisel` is dropped.
413 ///
414 /// Uses default `Options`. For a tuned cache size or superblock count,
415 /// use `open_in_memory_with_options`.
416 ///
417 /// # Errors
418 /// Only a bootstrap `IoError` from the initial superblock write — the
419 /// memory backing makes this practically infallible. See
420 /// [`open_in_memory_with_options`](Self::open_in_memory_with_options).
421 pub fn open_in_memory() -> Result<Chisel> {
422 Self::open_in_memory_with_options(Options::default())
423 }
424
425 /// Open a memory-backed Chisel database with explicit options.
426 ///
427 /// `options.read_only` must be `false`: a fresh memory database must
428 /// be writable for the initial superblock bootstrap, and there is no
429 /// prior file to reopen read-only. `options.create_if_missing` is
430 /// ignored — memory mode always creates a fresh database. All other
431 /// options (cache_max_bytes, spillway_max_bytes, drain_insertion,
432 /// superblock_count) flow through normally.
433 ///
434 /// # Errors
435 /// `ReadOnlyMode` if `options.read_only` is set (a fresh memory database
436 /// must be writable to bootstrap), `InvalidSuperblockCount` if
437 /// `options.superblock_count` is out of range, or a bootstrap `IoError`.
438 pub fn open_in_memory_with_options(options: Options) -> Result<Chisel> {
439 if options.read_only {
440 // Fail fast rather than bootstrapping and then blocking the
441 // superblock write with ReadOnlyMode: the caller almost
442 // certainly passed `read_only: true` by mistake.
443 return Err(ChiselError::ReadOnlyMode);
444 }
445 if options.superblock_count < superblock::MIN_SUPERBLOCKS
446 || options.superblock_count > superblock::MAX_SUPERBLOCKS
447 {
448 return Err(ChiselError::InvalidSuperblockCount {
449 value: options.superblock_count,
450 });
451 }
452
453 let io = PageIo::open_in_memory()?;
454 let cache = PageCache::new(
455 io,
456 options.cache_max_bytes,
457 options.spillway_max_bytes,
458 options.drain_insertion,
459 SpillwayLocation::InMemory,
460 );
461 let txm = TransactionManager::create_new(
462 cache,
463 options.superblock_count,
464 options.encryption_key.clone(),
465 options.argon2_params,
466 )?;
467 Ok(Chisel { txm })
468 }
469
470 /// Explicit close. Exists for API symmetry and so callers can observe a
471 /// `Result` at teardown; functionally identical to letting the value
472 /// drop, since release of the flock and file descriptor happens in
473 /// `Drop`. The `Result` return is currently always `Ok`, but is kept so
474 /// future implementations can surface fsync/close errors without a
475 /// breaking change.
476 ///
477 /// I38 (ISSUES.md, 2026-05-22): `#[must_use]` with a custom message
478 /// so callers who drop the result without explicit `let _ = …` get
479 /// a lint warning. `Result` is already `#[must_use]` by default;
480 /// the custom message adds the human-readable rationale.
481 ///
482 /// # Errors
483 /// Currently never — `close` always returns `Ok`. The `Result` is reserved
484 /// so a future release can surface fsync/close errors without an API break.
485 #[must_use = "Chisel::close may surface fsync/close errors in a future release; \
486 ignore explicitly with `let _ = db.close();` if intentional"]
487 pub fn close(self) -> Result<()> {
488 drop(self);
489 Ok(())
490 }
491
492 /// Begin a new transaction. All mutating operations below require an
493 /// active transaction; `allocate`/`update`/`delete` will return
494 /// `NoActiveTransaction` otherwise. Only one transaction is active at a
495 /// time — there is no nesting beyond savepoints.
496 ///
497 /// # Errors
498 /// `TransactionAlreadyActive` if a transaction is already open.
499 pub fn begin(&mut self) -> Result<()> {
500 self.txm.begin()
501 }
502
503 /// Commit the active transaction. Performs three fsyncs before
504 /// returning — this is the point at which changes become durable:
505 ///
506 /// 1. **I28 pre-drain flush.** `TransactionManager::commit_inner`
507 /// pre-drains the cache before `persist_freemap` to keep
508 /// `CacheFull` off the commit path (see ISSUES.md I28).
509 /// 2. **Main data-pages flush.** `PageCache::flush` phase 2 issues
510 /// one fsync that covers every in-cache write plus every
511 /// drained-batch write.
512 /// 3. **Superblock fsync.** The alternate-slot superblock is
513 /// written and fsynced; this is the linearization point.
514 ///
515 /// A crash before the superblock fsync leaves the previous
516 /// committed state intact — recovery picks the older superblock
517 /// via `Superblock::select` and the partially-written shadow
518 /// pages become unreachable garbage.
519 ///
520 /// The spillway, when engaged, adds zero additional fsyncs to
521 /// this protocol (its content does not need to survive a crash).
522 /// `tests/spillway_integration.rs::no_spill_workload_preserves_two_fsync_commit`
523 /// pins the count to `== 3`; the test name retains the older
524 /// "two_fsync" label from the original spec.
525 ///
526 /// # Errors
527 /// `NoActiveTransaction` if none is open. Operationally, `CacheFull` or
528 /// `SpillwayFull` if the transaction's working set exceeds the cache /
529 /// spillway caps. A failure inside the fsync/superblock protocol is fatal
530 /// and poisons the handle — the previous committed state stays intact.
531 pub fn commit(&mut self) -> Result<()> {
532 self.txm.commit()
533 }
534
535 /// Abort the active transaction. Pages written during the transaction
536 /// become unreachable garbage (they are never linked from a superblock),
537 /// so rollback is effectively free — no undo log to replay.
538 ///
539 /// # Errors
540 /// `NoActiveTransaction` if no transaction is open.
541 pub fn rollback(&mut self) -> Result<()> {
542 self.txm.rollback()
543 }
544
545 // Savepoint API: named marks within the active transaction. Implemented
546 // by snapshotting the in-memory roots — cheap because the on-disk pages
547 // written since the savepoint are simply abandoned on `rollback_to`, the
548 // same way a full rollback abandons the whole transaction.
549
550 /// Create a named savepoint within the active transaction.
551 ///
552 /// # Errors
553 /// `NoActiveTransaction` if no transaction is open; `DuplicateSavepoint` if
554 /// `name` is already a live savepoint in this transaction.
555 pub fn savepoint(&mut self, name: &str) -> Result<()> {
556 self.txm.savepoint(name)
557 }
558
559 /// Roll the active transaction back to a named savepoint, discarding work
560 /// done since (pages written meanwhile are abandoned, as in a full rollback).
561 ///
562 /// # Errors
563 /// `NoActiveTransaction` if no transaction is open; `SavepointNotFound` if
564 /// `name` is not a live savepoint.
565 pub fn rollback_to(&mut self, name: &str) -> Result<()> {
566 self.txm.rollback_to(name)
567 }
568
569 /// Discard a named savepoint without rolling back, folding its scope into
570 /// the surrounding transaction.
571 ///
572 /// # Errors
573 /// `NoActiveTransaction` if no transaction is open; `SavepointNotFound` if
574 /// `name` is not a live savepoint.
575 pub fn release(&mut self, name: &str) -> Result<()> {
576 self.txm.release(name)
577 }
578
579 /// Store `value` and return a freshly minted stable handle. Handles are
580 /// u64 identifiers assigned from a monotonic counter in the superblock;
581 /// they are never reused within a database's lifetime and are stable
582 /// across updates, defrag, and reopens. Physical location may change;
583 /// the handle will not.
584 ///
585 /// Values up to `transaction::MAX_INLINE_VALUE` are packed into a slot
586 /// on a data page (R1 packing — multiple values share a page); larger
587 /// values are written to an overflow chain in `overflow.rs`. The
588 /// caller cannot tell which path was taken except by consulting
589 /// stats; all reads go through the same `read()` entry point.
590 ///
591 /// # Errors
592 /// `NoActiveTransaction` if no transaction is open; `CacheFull` or
593 /// `SpillwayFull` if the value's pages do not fit within the cache /
594 /// spillway caps.
595 pub fn allocate(&mut self, value: &[u8]) -> Result<Handle> {
596 self.txm.allocate(value).map(Handle::from)
597 }
598
599 /// Store `value` tagged with `tag` and return a freshly minted stable handle.
600 /// Like `allocate`, but additionally registers the handle in the reverse
601 /// membership index (tag→handles) so `handles_with_tag(tag)` can enumerate it.
602 /// Tag 0 is the "untagged" sentinel — prefer plain `allocate` for untagged
603 /// values; the membership index is not updated for tag 0.
604 ///
605 /// # Errors
606 /// As [`allocate`](Self::allocate) (`NoActiveTransaction`, `CacheFull`,
607 /// `SpillwayFull`); the reverse membership-index insert is subject to the
608 /// same cap errors.
609 pub fn allocate_tagged(&mut self, value: &[u8], tag: Tag) -> Result<Handle> {
610 self.txm.allocate_tagged(value, tag.get()).map(Handle::from)
611 }
612
613 /// Return the tag stored in the handle-table entry for `handle`.
614 /// Returns 0 for untagged handles. Takes `&self` (F3).
615 ///
616 /// # Errors
617 /// `InvalidHandle` if `handle` is unknown or deleted.
618 pub fn tag(&self, handle: Handle) -> Result<Option<Tag>> {
619 self.txm.tag(handle.get()).map(Tag::new) // stored 0 -> None
620 }
621
622 /// Return the opaque client byte stored in the handle-table entry for
623 /// `handle`. Returns 0 for chunks whose byte was never set (including all
624 /// chunks created before this feature). Chisel never interprets it. Takes
625 /// `&self` (F3).
626 ///
627 /// # Errors
628 /// `InvalidHandle` if `handle` is unknown or deleted.
629 pub fn client_byte(&self, handle: Handle) -> Result<u8> {
630 self.txm.client_byte(handle.get())
631 }
632
633 /// Set the opaque client byte for `handle`. Requires an active
634 /// transaction; durable on commit, reverted on rollback.
635 ///
636 /// # Errors
637 /// `NoActiveTransaction` if no transaction is open; `InvalidHandle` if
638 /// `handle` is unknown or deleted.
639 pub fn set_client_byte(&mut self, handle: Handle, byte: u8) -> Result<()> {
640 self.txm.set_client_byte(handle.get(), byte)
641 }
642
643 /// Enumerate all live handles that carry `tag`. Returns an empty Vec if
644 /// no handles with that tag exist. Tag 0 always returns an empty Vec
645 /// (the membership index is not updated for untagged values). Takes `&self` (F3).
646 ///
647 /// Stability: the same within-session repeatability contract as `handles` —
648 /// repeated calls return an identical `Vec` while the set of live handles
649 /// carrying `tag` is unchanged and no `defrag` has run. The order is
650 /// unspecified and may differ after a reopen or `defrag`.
651 ///
652 /// # Errors
653 /// Only on poisoning — an empty or absent index is not an error and returns
654 /// an empty `Vec`.
655 pub fn handles_with_tag(&self, tag: Tag) -> Result<Vec<Handle>> {
656 Ok(self
657 .txm
658 .handles_with_tag(tag.get())?
659 .into_iter()
660 .map(Handle::from)
661 .collect())
662 }
663
664 /// Read the current value for `handle`. Takes `&self` — the page cache
665 /// is mutated on miss (LRU bookkeeping, page loading) via interior
666 /// mutability (see F3 in ISSUES.md). The returned `Vec<u8>` is a copy;
667 /// the cache retains its own page. Not `Sync` — a `Chisel` is single-
668 /// threaded by design, so this `&self` only enables `&self`-taking
669 /// read APIs in downstream wrappers (e.g. the client's `StorageEngine`
670 /// trait), not cross-thread sharing.
671 ///
672 /// # Errors
673 /// `InvalidHandle` if `handle` is unknown or deleted. A structural
674 /// disagreement between the handle table and the data page surfaces as the
675 /// fatal `CorruptPage`, which poisons the handle.
676 pub fn read(&self, handle: Handle) -> Result<Vec<u8>> {
677 self.txm.read(handle.get())
678 }
679
680 /// Replace the value for `handle`. The handle is preserved; the value
681 /// is written to a new slot (and, if it crosses the inline threshold,
682 /// to a new overflow chain). The handle-table entry is rewritten via
683 /// COW, so the update is invisible until commit.
684 ///
685 /// # Errors
686 /// `NoActiveTransaction` if no transaction is open; `InvalidHandle` if
687 /// `handle` is unknown or deleted; `CacheFull` or `SpillwayFull` if the new
688 /// value's pages do not fit the caps.
689 pub fn update(&mut self, handle: Handle, value: &[u8]) -> Result<()> {
690 self.txm.update(handle.get(), value)
691 }
692
693 /// Remove a handle. The handle itself is retired (not reused); any
694 /// overflow pages it owned are queued for release on commit.
695 ///
696 /// # Errors
697 /// `NoActiveTransaction` if no transaction is open; `InvalidHandle` if
698 /// `handle` is unknown or already deleted.
699 pub fn delete(&mut self, handle: Handle) -> Result<()> {
700 self.txm.delete(handle.get())
701 }
702
703 /// Remove a handle only if its tag equals `tag`. Returns
704 /// `ChiselError::TagMismatch` (leaving the chunk and membership index
705 /// untouched) if the stored tag differs. On success, delegates to
706 /// `delete`, so the membership index is self-maintained.
707 ///
708 /// Use this when the caller wants to assert ownership (a stale or
709 /// mis-directed handle should not silently delete the wrong chunk).
710 /// `delete` remains the unchecked fast path for callers that trust
711 /// their handle provenance.
712 ///
713 /// # Errors
714 /// `NoActiveTransaction` if no transaction is open; `InvalidHandle` if
715 /// `handle` is unknown or deleted; `TagMismatch` if the stored tag differs
716 /// from `tag` (nothing is deleted in that case).
717 pub fn delete_tagged(&mut self, handle: Handle, tag: Tag) -> Result<()> {
718 self.txm.delete_tagged(handle.get(), tag.get())
719 }
720
721 /// Delete up to `max` chunks carrying `tag`, returning the handles dropped
722 /// this pass and whether the tag is now fully drained (`complete`). Loop
723 /// `begin -> delete_with_tag -> commit` until `complete` for an incremental,
724 /// bounded-time relation drop. `max == 0` is a no-op (`complete == false`).
725 ///
726 /// # Errors
727 /// `NoActiveTransaction` if no transaction is open. A mid-pass error returns
728 /// only `Err` — the
729 /// [`TagDropProgress`] is NOT produced, so the set of handles already
730 /// dropped this pass is not reported and is unrecoverable from the return
731 /// value. Each individual delete is atomic (a non-fatal `CacheFull`/
732 /// `SpillwayFull` leaves that one chunk untouched in both the handle table
733 /// and the membership index — see the I-series / shadow-paging invariants),
734 /// so the in-transaction state after the error is always consistent: every
735 /// chunk dropped before the failure is fully tombstoned, the failed one is
736 /// untouched. The caller therefore has two safe recoveries — `rollback()`
737 /// (discard the whole pass) or `commit()` (keep the consistent partial
738 /// drop) — and can simply re-enumerate via `handles_with_tag`/re-run the
739 /// bounded loop to finish. A fatal error additionally poisons the manager
740 /// (drop and reopen). To learn exactly which handles were dropped, commit
741 /// in single-element passes (`max == 1`) and read each success's progress.
742 pub fn delete_with_tag(&mut self, tag: Tag, max: usize) -> Result<TagDropProgress> {
743 let (ids, complete) = self.txm.delete_with_tag(tag.get(), max)?;
744 Ok(TagDropProgress {
745 deleted: ids.into_iter().map(Handle::from).collect(),
746 complete,
747 })
748 }
749
750 /// Delete many handles in one transaction (ISSUES.md F1 / I12).
751 ///
752 /// Motivating use case (from the primary Chisel client): bulk
753 /// operations like `drop_table` / `drop_index_table` need to remove
754 /// large handle sets without leaking pages. This is a convenience
755 /// wrapper around a loop of `delete()` calls inside the caller's
756 /// active transaction — the atomicity guarantee comes from the
757 /// enclosing transaction, not from anything special in this method.
758 ///
759 /// On error, partial progress remains visible in the current
760 /// transaction: rollback or commit to decide whether the half-done
761 /// batch should be kept.
762 ///
763 /// # Errors
764 /// `NoActiveTransaction` if no transaction is open; `InvalidHandle` if any
765 /// handle in `handles` is unknown or already deleted (handles before the
766 /// failure remain deleted in the current transaction).
767 pub fn delete_many(&mut self, handles: &[Handle]) -> Result<()> {
768 // Copy to a raw Vec; bulk delete is far below the fsync floor, so the
769 // allocation is immaterial. (The bench adapter does the zero-copy
770 // reinterpret where it matters; the engine API stays simple here.)
771 let raw: Vec<u64> = handles.iter().map(|h| h.get()).collect();
772 self.txm.delete_many(&raw)
773 }
774
775 /// Bind `name` to `handle` in the named-root table (ISSUES.md F2).
776 /// Names are short mnemonic labels for long-lived handles — typically
777 /// one or two per database (e.g. a meta B-tree root). Requires an
778 /// active transaction; becomes durable on commit, reverts on
779 /// rollback/rollback_to. See `TransactionManager::set_root_name` for
780 /// validation rules and the fixed table-size limit.
781 ///
782 /// # Errors
783 /// `NoActiveTransaction` if no transaction is open; `InvalidRootName` if
784 /// `name` violates the validation rules; `RootNameTableFull` if the
785 /// fixed-size table has no free slot.
786 pub fn set_root_name(&mut self, name: &str, handle: Handle) -> Result<()> {
787 self.txm.set_root_name(name, handle.get())
788 }
789
790 /// Look up a named root. Returns `Ok(None)` if the name is not bound.
791 /// Reads see the transactional view (pending sets/clears are visible
792 /// inside an active transaction). Takes `&self` (F3).
793 ///
794 /// # Errors
795 /// Only on poisoning — an unbound `name` returns `Ok(None)`.
796 pub fn get_root_name(&self, name: &str) -> Result<Option<Handle>> {
797 Ok(self.txm.get_root_name(name)?.map(Handle::from))
798 }
799
800 /// Remove a named root. No-op if the name is not bound. Requires an
801 /// active transaction; becomes durable on commit.
802 ///
803 /// # Errors
804 /// `NoActiveTransaction` if no transaction is open.
805 pub fn clear_root_name(&mut self, name: &str) -> Result<()> {
806 self.txm.clear_root_name(name)
807 }
808
809 /// Enumerate all live handles. Walks the handle-table radix tree; cost
810 /// is proportional to the number of live handles, not to the historical
811 /// maximum. Takes `&self` for the same reason `read` does (F3).
812 ///
813 /// Stability: within a single open instance, repeated calls return an
814 /// identical `Vec` — the same handles in the same order — as long as the
815 /// live set is unchanged between calls (changed only by `allocate*` /
816 /// `delete*`; `read` and `update` do not change it) and no `defrag` has run.
817 /// The order itself is unspecified: it is not sorted, not insertion order,
818 /// and may differ after a reopen or `defrag`, or across Chisel versions.
819 /// Rely on within-session repeatability; do not rely on the order.
820 ///
821 /// # Errors
822 /// Only on poisoning (e.g. a fatal `IoError` while walking the handle table).
823 pub fn handles(&self) -> Result<Vec<Handle>> {
824 Ok(self.txm.handles()?.into_iter().map(Handle::from).collect())
825 }
826
827 /// Summary statistics derived by scanning the current handle table and
828 /// querying the underlying file length. `file_size_bytes` is computed
829 /// from `page_count * PAGE_SIZE` rather than `stat(2)` so it reflects
830 /// the page-aligned view the engine has, not any trailing partial page
831 /// that might exist mid-extend.
832 ///
833 /// # Errors
834 /// Only on poisoning — a fatal `IoError` while scanning the handle table or
835 /// reading the file length poisons the handle.
836 pub fn stats(&self) -> Result<Stats> {
837 // Both calls below route through the poison-aware wrappers on
838 // TransactionManager, so a fatal I/O error in either one will
839 // poison the manager just as if it had come from `read()` or
840 // `commit()`. Takes `&self` (F3) — `stats` is semantically a read.
841 let handles = self.txm.handles()?;
842 let page_count = self.txm.file_page_count()?;
843 // I74 (ISSUES.md, 2026-05-22): spillway capacity peek. Returns
844 // None until the spillway is first opened (lazy construction
845 // on first overflow); Some((logical, max)) otherwise. The
846 // tuple is split into the two Option<u64> fields below.
847 let spillway_cap = self.txm.spillway_capacity()?;
848 Ok(Stats {
849 handle_count: handles.len() as u64,
850 total_pages: page_count,
851 // I47 (ISSUES.md, 2026-05-22): saturating_mul guards against
852 // u64 overflow at the absurd-extreme. The product overflows
853 // at page_count > u64::MAX / 8192 ≈ 2.25 × 10^15 pages (18
854 // EiB), unreachable for any real database — but unannotated
855 // multiplication is a smell. The saturate-to-u64::MAX
856 // behaviour is the right semantic here: "as big as a u64
857 // can represent" is closer to truth than "wrapped to a
858 // small number".
859 file_size_bytes: page_count.saturating_mul(PAGE_SIZE as u64),
860 spillway_logical_bytes: spillway_cap.map(|(logical, _)| logical),
861 spillway_max_bytes: spillway_cap.map(|(_, max)| max),
862 })
863 }
864
865 /// Snapshot the four engine-activity counters (cache hits/misses,
866 /// pages allocated, fsync calls). Cumulative from the most recent
867 /// `open()`; the bench harness reads-subtract-reads to compute
868 /// deltas for individual operations or workloads.
869 ///
870 /// Same `&self` semantic-read shape as `stats()`.
871 ///
872 /// # Errors
873 /// Only on poisoning.
874 pub fn counters(&self) -> Result<ChiselCounters> {
875 self.txm.counters()
876 }
877
878 /// Page-aligned on-disk size of the database, computed as
879 /// `page_count × PAGE_SIZE`. Same number `stats().file_size_bytes`
880 /// returns, but without the handle-table scan that `stats()` does
881 /// to populate `handle_count`.
882 ///
883 /// I53 (ISSUES.md, 2026-05-22): broken out for the bench harness,
884 /// which calls this per measurement cell — `stats()` walks all
885 /// live handles via `handles()` (O(live handles)), which adds
886 /// milliseconds per call at 100K-handle scale. Reading just
887 /// `file_size_bytes` shouldn't pay that cost. `stats()` keeps its
888 /// current shape because the typical caller wants all three
889 /// fields together; this is the dedicated single-field accessor.
890 ///
891 /// # Errors
892 /// Only on poisoning (a fatal `IoError` reading the file length).
893 pub fn file_size_bytes(&self) -> Result<u64> {
894 let page_count = self.txm.file_page_count()?;
895 Ok(page_count.saturating_mul(PAGE_SIZE as u64))
896 }
897
898 /// Returns true if this database handle has been poisoned by a
899 /// previous fatal error. A poisoned handle returns
900 /// `ChiselError::Poisoned` from every operation; the caller must drop
901 /// it and reopen the database to recover. See the type-level docs for
902 /// the full recovery protocol.
903 pub fn is_poisoned(&self) -> bool {
904 self.txm.is_poisoned()
905 }
906
907 /// Run a defragmentation pass. The caller must have an active
908 /// transaction (see `defrag.rs` for why). This method does NOT begin or
909 /// commit one on the caller's behalf — defrag is composable with other
910 /// work in the same transaction and atomic with it on commit.
911 ///
912 /// The freemap crash-orphan sweep (step 7) is SKIPPED while a savepoint is
913 /// active, since the sweep COWs the freemap and `rollback_to` does not
914 /// rewind the structural recycle streams. Run defrag outside any savepoint
915 /// scope to reclaim crash-orphaned freemap pages.
916 ///
917 /// # Errors
918 /// `NoActiveTransaction` if no transaction is open; `CacheFull` if the
919 /// relocation working set exceeds the cache cap.
920 pub fn defrag(&mut self, options: DefragOptions) -> Result<DefragStats> {
921 defrag::defrag(&mut self.txm, &options)
922 }
923
924 /// Resize the in-memory cache cap. Shrinking evicts clean LRU-tail entries
925 /// to fit; growing takes effect on the next allocation. See spec
926 /// §"Runtime mutability".
927 ///
928 /// # Errors
929 /// `TransactionInProgress` if a transaction is active.
930 pub fn set_cache_max_bytes(&mut self, bytes: u64) -> Result<()> {
931 self.txm.set_cache_max_bytes(bytes)
932 }
933
934 /// Resize the spillway cap. Setting to 0 disables the spillway
935 /// (subsequent overflow trips CacheFull at the cache cap).
936 /// Returns `ChiselError::TransactionInProgress` if a transaction
937 /// is active. The spillway is empty between transactions, so
938 /// resize is state-free.
939 ///
940 /// # Errors
941 /// `TransactionInProgress` if a transaction is active.
942 pub fn set_spillway_max_bytes(&mut self, bytes: u64) -> Result<()> {
943 self.txm.set_spillway_max_bytes(bytes)
944 }
945
946 /// Update the drain insertion policy used at the next commit.
947 /// Returns `ChiselError::TransactionInProgress` if a transaction
948 /// is active.
949 ///
950 /// # Errors
951 /// `TransactionInProgress` if a transaction is active.
952 pub fn set_drain_insertion(&mut self, policy: DrainInsertion) -> Result<()> {
953 self.txm.set_drain_insertion(policy)
954 }
955
956 /// Add a second credential that unlocks this database. `existing` must
957 /// already unlock it; `new` is wrapped over the same data key into a free
958 /// key slot. After this returns, either credential opens the database. O(1)
959 /// superblock commit — no page is re-encrypted.
960 ///
961 /// # Errors
962 /// `EncryptionNotSupported` if the database has no encryption;
963 /// `InvalidEncryptionKey` if `existing` unlocks no slot; `NoFreeKeySlot` if
964 /// all 8 key slots are full. An fsync/superblock failure is fatal and poisons
965 /// the handle.
966 pub fn add_key(&mut self, existing: &crypto::Key, new: &crypto::Key) -> Result<()> {
967 self.txm.add_key(existing, new)
968 }
969
970 /// Replace `old` with `new`: `new` is added and `old` is revoked in one
971 /// atomic superblock commit. After this returns, `old` no longer opens the
972 /// database and `new` does. O(1) — the data key is unchanged, no page is
973 /// re-encrypted.
974 ///
975 /// # Errors
976 /// `EncryptionNotSupported` if the database has no encryption;
977 /// `InvalidEncryptionKey` if `old` unlocks no slot; `NoFreeKeySlot` if all 8
978 /// key slots are full (no room to stage `new` before revoking `old`). An
979 /// fsync/superblock failure is fatal and poisons the handle.
980 pub fn rotate_key(&mut self, old: &crypto::Key, new: &crypto::Key) -> Result<()> {
981 self.txm.rotate_key(old, new)
982 }
983
984 /// Revoke the credential `key`. After this returns, `key` no longer opens
985 /// the database; any other credentials are unaffected. Refuses to remove
986 /// the only remaining credential. O(1) — the data key is unchanged, no
987 /// page is re-encrypted.
988 ///
989 /// # Errors
990 /// `EncryptionNotSupported` if the database has no encryption;
991 /// `InvalidEncryptionKey` if `key` unlocks no slot; `LastKeySlot` if
992 /// `key` is the only active credential (removing it would make the database
993 /// permanently unopenable — nothing is changed). An fsync/superblock
994 /// failure is fatal and poisons the handle.
995 pub fn remove_key(&mut self, key: &crypto::Key) -> Result<()> {
996 self.txm.remove_key(key)
997 }
998}
999
1000#[cfg(test)]
1001mod options_encryption_tests {
1002 use super::*;
1003
1004 // The two encryption fields default to None (a plaintext DB) and round-trip
1005 // through the chained-setter builder, preserving #[non_exhaustive] (callers
1006 // can't struct-literal, so the setters are the only construction path).
1007 #[test]
1008 fn encryption_options_default_none_and_set() {
1009 let o = Options::default();
1010 assert!(o.encryption_key.is_none());
1011 assert!(o.argon2_params.is_none());
1012
1013 let raw = Key::Raw(zeroize::Zeroizing::new(vec![0u8; 32]));
1014 let o = Options::default()
1015 .encryption_key(raw)
1016 .argon2_params(Argon2Params {
1017 m_cost: 19456,
1018 t_cost: 2,
1019 p_cost: 1,
1020 });
1021 assert!(matches!(o.encryption_key, Some(Key::Raw(_))));
1022 let p = o.argon2_params.expect("set above");
1023 assert_eq!((p.m_cost, p.t_cost, p.p_cost), (19456, 2, 1));
1024 }
1025}