diff --git a/.kanon-lint-ignore b/.kanon-lint-ignore index c5bc92e..ff9b18b 100644 --- a/.kanon-lint-ignore +++ b/.kanon-lint-ignore @@ -30,10 +30,38 @@ STORAGE/no-migration-checksum:crates/lexis/src/ast.rs STORAGE/no-migration-checksum:crates/lexis/src/lib.rs # ============================================================================= -# crates/{hypomnema,phylaxis,pinax}/ — genuinely empty scaffolds +# crates/pinax/ — Phase 01 landed; false positives, not real defects # ============================================================================= -# WHY: these three crates are empty by design at this phase — lib.rs is +# WHY(forkwright/kanon#2975): same has_ddl bare-substring-match false +# positive as the lexis entries above, same root cause, same upstream fix +# tracked there. `database.rs`'s module doc names "CREATE TABLE" as Phase +# 04 territory this facade does not implement yet (Decision 1: "one file +# per database" today, no table catalog); `row.rs`'s module doc cites +# `lexis`'s own exit-criterion wording, which likewise names "CREATE TABLE" +# only to say the typed-row/`TableDef` format it gates is Phase 4/5 +# territory. Neither file runs a migration or executes DDL. Drop these two +# entries once kanon#2975 lands. +STORAGE/no-migration-checksum:crates/pinax/src/database.rs +STORAGE/no-migration-checksum:crates/pinax/src/row.rs + +# WHY: lib.rs is a module-declaration + public re-export file with no +# behavior of its own — same shape as the lexis/lib.rs entry above, not the +# "empty scaffold" reason the hypomnema/phylaxis entries below still carry. +# Phase 01 landed real behavior in `btree/`, `buffer_pool.rs`, `codec.rs`, +# `database.rs`, `page.rs`, `pager.rs`, and `row.rs`, each with its own +# colocated `#[cfg(test)] mod tests`; the TESTING/no-tests heuristic only +# inspects lib.rs itself and a sibling `tests/` directory (which exists here +# too — `tests/phase01_acceptance.rs` — but the heuristic still cannot see +# module-level coverage), so it cannot see any of that. Drop once the +# heuristic learns to walk sibling submodules. +TESTING/no-tests:crates/pinax/src/lib.rs + +# ============================================================================= +# crates/{hypomnema,phylaxis}/ — genuinely empty scaffolds +# ============================================================================= + +# WHY: these two crates are empty by design at this phase — lib.rs is # module-doc-comment-only, zero functions, zero types, zero logic (see each # crate's [package.metadata.kanon] maturity = "scaffold" in Cargo.toml). A # #[cfg(test)] module here would have no real behavior to assert against; @@ -42,9 +70,7 @@ STORAGE/no-migration-checksum:crates/lexis/src/lib.rs # is worse than an honest, exit-criteria-bound suppression. Each crate's own # Cargo.toml exit-criteria field names the concrete Phase that ends this: # hypomnema Phase 02 (WAL + virtual-WAL trait + causal changelog), phylaxis -# Phase 03/06 (MVCC + encryption), pinax Phase 01 (pager + buffer pool + -# B-tree) — see kanon/projects/pinax/ROADMAP.md. Drop each entry the moment -# its crate gets real behavior and a real test. +# Phase 03/06 (MVCC + encryption) — see kanon/projects/pinax/ROADMAP.md. +# Drop each entry the moment its crate gets real behavior and a real test. TESTING/no-tests:crates/hypomnema/src/lib.rs TESTING/no-tests:crates/phylaxis/src/lib.rs -TESTING/no-tests:crates/pinax/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 7b47fe4..bc4b27f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -173,6 +173,9 @@ dependencies = [ "hypomnema", "lexis", "phylaxis", + "snafu", + "tempfile", + "xxhash-rust", ] [[package]] @@ -422,6 +425,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "zerocopy" version = "0.8.56" diff --git a/Cargo.toml b/Cargo.toml index a3dc3da..ac5534d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,16 @@ proptest = "1" # lets ARCHITECTURE/crate-index-conformance read real `path =` edges out of # each crate's Cargo.toml and cross-check them against CRATE-INDEX.toml. snafu = "0.8" +# WHY: pinax's page-format checksum per PLAN.md Decision 2 — pure-Rust +# XxHash3-64, the same algorithm Turso's page cache uses +# (`turso/core/storage/checksum.rs:81`), chosen over CRC32C (32-bit is +# collision-borderline at millions of pages) and blake3 (256-bit dominates a +# 4 KiB page's overhead budget; cryptographic integrity is the encryption +# layer's job, not corruption detection's). +xxhash-rust = { version = "0.8", features = ["xxh3"] } +# WHY: fixture temp-dirs for pager/buffer-pool/B-tree tests — kanon RUST.md +# § Testing forbids `/tmp/` and `.parent()` off a tempdir. +tempfile = "3" [workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/README.md b/README.md index 2bfd2c2..4ce3791 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ in Rust. The Tier-1 storage primitive: the answer to "what is." Replaces SQLite for fleet consumers whose state is tabular, transactional, and aggregation-heavy. -**Status:** design phase - no code yet. This repo carries the public -specification surface as it lands; the phased roadmap and state tracking -live in the fleet planning home. First implementation phase is the -pager / buffer pool / B-tree tier. +**Status:** Phase 01 (pager / buffer pool / B-tree) implemented - a +checksummed, copy-on-write, integer-keyed B+tree behind an LRU-evicting +buffer pool. The phased roadmap and state tracking live in the fleet +planning home; `lexis` (the strict six-type value system) landed first, +as the vocabulary Phase 01 stores. ## Locked design decisions diff --git a/crates/pinax/CRATE-SHAPE.toml b/crates/pinax/CRATE-SHAPE.toml index 3e57e08..5c3ddb8 100644 --- a/crates/pinax/CRATE-SHAPE.toml +++ b/crates/pinax/CRATE-SHAPE.toml @@ -6,4 +6,4 @@ shape = "unclassified" top_level = "layer" within_crate = "feature" -purpose = "Facade: pager, buffer pool, B-tree, page format, SQL surface, async API, migration runner, CLI (Decision 14). Not yet implemented — reserves the workspace position." +purpose = "Facade: pager, buffer pool, B-tree, page format land Phase 01 (Decision 1, Decision 2). SQL surface, async API, migration runner, CLI (Decision 14) land later phases." diff --git a/crates/pinax/Cargo.toml b/crates/pinax/Cargo.toml index 6609d45..4fd315b 100644 --- a/crates/pinax/Cargo.toml +++ b/crates/pinax/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true homepage.workspace = true repository.workspace = true authors.workspace = true -description = "(scaffold) Relational storage facade for the forkwright fleet: pager, buffer pool, B-tree, SQL surface, async API, migrations, and CLI." +description = "Relational storage facade for the forkwright fleet: page format, pager, buffer pool, and B+tree land Phase 01; SQL surface, async API, migrations, and CLI land later phases." readme = "../../README.md" keywords = ["sql", "database", "storage"] categories = ["database"] @@ -16,17 +16,23 @@ categories = ["database"] hypomnema = { path = "../hypomnema", version = "0.0.2" } lexis = { path = "../lexis", version = "0.0.2" } phylaxis = { path = "../phylaxis", version = "0.0.2" } +snafu = { workspace = true } +xxhash-rust = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } [lints] workspace = true # WHY: maturity flag is read by the kanon substrate registry so consumers can -# tell at a glance which API surfaces are pre-stabilization. This crate is an -# empty scaffold reserving the facade's position in the Decision 14 -# dependency graph; the pager + buffer pool + B-tree land Phase 01. +# tell at a glance which API surfaces are pre-stabilization. Phase 01 lands +# the page format, pager, buffer pool, and B+tree (Decision 1, Decision 2, +# Decision 14); the SQL surface, async API, migration runner, and CLI are +# later phases per ROADMAP.md. [package.metadata.kanon] -maturity = "scaffold" +maturity = "alpha" since = "2026-08-15" phase = "1" -phase-description = "empty crate reserving the workspace position and dependency edges; no implementation yet" -exit-criteria = "Phase 1 pager + buffer pool + B-tree land; see kanon/projects/pinax/ROADMAP.md Phase 01" +phase-description = "page format (checksummed, configurable 4-64 KiB), pager, buffer pool with LRU eviction, and a copy-on-write B+tree keyed by i64 (Decision 1, Decision 2)" +exit-criteria = "Phase 2 adopts this pager under a WAL + transaction boundary; see kanon/projects/pinax/ROADMAP.md Phase 02" diff --git a/crates/pinax/src/btree/layout.rs b/crates/pinax/src/btree/layout.rs new file mode 100644 index 0000000..57c5b0c --- /dev/null +++ b/crates/pinax/src/btree/layout.rs @@ -0,0 +1,262 @@ +//! Slotted-page primitives shared by leaf and interior pages, plus the +//! leaf-cell and interior-cell layouts built on top of them. +//! +//! Layout: `[header][pointer array, ascending][... free ...][cell content +//! area, descending toward the checksum trailer]`. +//! +//! WHY this is its own file: split out of `btree.rs` once Phase 01 pushed +//! that file past `RUST/file-too-long`'s 800-line limit, along the section +//! boundary the file already documented internally. See `super`'s module +//! doc for the full split rationale. + +use super::{ + INTERIOR_CELL_LEN, INTERIOR_HEADER_LEN, LEAF_CELL_FIXED_LEN, LEAF_HEADER_LEN, POINTER_LEN, +}; +use crate::codec::{read_i64, read_u16, read_u32, read_vec, write_u8, write_u16, write_u32}; +use crate::error::PinaxError; +use crate::page::{PAGE_TYPE_INTERIOR, PAGE_TYPE_LEAF}; + +fn init_slotted(buf: &mut [u8], page_type: u8) -> Result<(), PinaxError> { + let usable = u16::try_from(buf.len()).unwrap_or(u16::MAX) - crate::page::checksum_len_u16(); + write_u8(buf, 0, page_type)?; + write_u16(buf, 1, 0)?; + write_u16(buf, 3, usable)?; + Ok(()) +} + +pub(super) fn num_cells(buf: &[u8]) -> Result { + read_u16(buf, 1) +} + +fn set_num_cells(buf: &mut [u8], n: u16) -> Result<(), PinaxError> { + write_u16(buf, 1, n) +} + +fn content_start(buf: &[u8]) -> Result { + read_u16(buf, 3) +} + +fn set_content_start(buf: &mut [u8], v: u16) -> Result<(), PinaxError> { + write_u16(buf, 3, v) +} + +pub(super) fn pointer_at(buf: &[u8], header_len: usize, index: usize) -> Result { + read_u16(buf, header_len + index * POINTER_LEN) +} + +fn set_pointer_at( + buf: &mut [u8], + header_len: usize, + index: usize, + offset: u16, +) -> Result<(), PinaxError> { + write_u16(buf, header_len + index * POINTER_LEN, offset) +} + +pub(super) fn free_space(buf: &[u8], header_len: usize) -> Result { + let n = num_cells(buf)?; + let cs = content_start(buf)?; + let header_len_u16 = u16::try_from(header_len).unwrap_or(u16::MAX); + let used_by_pointers = header_len_u16 + n * u16::try_from(POINTER_LEN).unwrap_or(2); + Ok(cs.saturating_sub(used_by_pointers)) +} + +/// Insert `cell_bytes` as a new cell at pointer-array `index`, shifting +/// later pointers right. Caller must have already verified `free_space` +/// covers `cell_bytes.len() + POINTER_LEN`. +pub(super) fn insert_cell_at( + buf: &mut [u8], + header_len: usize, + index: usize, + cell_bytes: &[u8], +) -> Result<(), PinaxError> { + let n = usize::from(num_cells(buf)?); + let cs = content_start(buf)?; + let cell_len = u16::try_from(cell_bytes.len()).unwrap_or(u16::MAX); + let new_cs = cs - cell_len; + crate::codec::write_bytes(buf, usize::from(new_cs), cell_bytes)?; + for i in (index..n).rev() { + let p = pointer_at(buf, header_len, i)?; + set_pointer_at(buf, header_len, i + 1, p)?; + } + set_pointer_at(buf, header_len, index, new_cs)?; + set_num_cells(buf, u16::try_from(n + 1).unwrap_or(u16::MAX))?; + set_content_start(buf, new_cs)?; + Ok(()) +} + +/// Remove the cell at pointer-array `index`. Returns its former content +/// offset so the caller can read it BEFORE calling this (removal never +/// reclaims content-area space — see module docs on deferred compaction). +pub(super) fn remove_cell_at( + buf: &mut [u8], + header_len: usize, + index: usize, +) -> Result { + let n = usize::from(num_cells(buf)?); + let offset = pointer_at(buf, header_len, index)?; + for i in index..n.saturating_sub(1) { + let p = pointer_at(buf, header_len, i + 1)?; + set_pointer_at(buf, header_len, i, p)?; + } + set_num_cells(buf, u16::try_from(n.saturating_sub(1)).unwrap_or(0))?; + Ok(offset) +} + +// --------------------------------------------------------------------- +// Leaf pages. +// --------------------------------------------------------------------- + +pub(super) fn init_leaf(buf: &mut [u8]) -> Result<(), PinaxError> { + init_slotted(buf, PAGE_TYPE_LEAF) +} + +fn leaf_key_at(buf: &[u8], index: usize) -> Result { + let offset = pointer_at(buf, LEAF_HEADER_LEN, index)?; + read_i64(buf, usize::from(offset)) +} + +pub(super) struct LeafCell { + pub(super) key: i64, + pub(super) payload_len: u32, + pub(super) overflow_first: u32, + pub(super) local: Vec, +} + +fn leaf_local_len(payload_len: u32, max_local: u32) -> u32 { + payload_len.min(max_local) +} + +pub(super) fn leaf_cell_at( + buf: &[u8], + index: usize, + max_local: u32, +) -> Result { + let offset = usize::from(pointer_at(buf, LEAF_HEADER_LEN, index)?); + let key = read_i64(buf, offset)?; + let payload_len = read_u32(buf, offset + 8)?; + let overflow_first = read_u32(buf, offset + 12)?; + let local_len = usize::try_from(leaf_local_len(payload_len, max_local)).unwrap_or(0); + let local = read_vec(buf, offset + LEAF_CELL_FIXED_LEN, local_len)?; + Ok(LeafCell { + key, + payload_len, + overflow_first, + local, + }) +} + +pub(super) fn leaf_cell_byte_len( + buf: &[u8], + index: usize, + max_local: u32, +) -> Result { + let offset = usize::from(pointer_at(buf, LEAF_HEADER_LEN, index)?); + let payload_len = read_u32(buf, offset + 8)?; + let local_len = leaf_local_len(payload_len, max_local); + Ok(u16::try_from(LEAF_CELL_FIXED_LEN).unwrap_or(16) + u16::try_from(local_len).unwrap_or(0)) +} + +pub(super) fn build_leaf_cell( + key: i64, + payload_len: u32, + overflow_first: u32, + local: &[u8], +) -> Vec { + let mut cell = Vec::with_capacity(LEAF_CELL_FIXED_LEN + local.len()); + cell.extend_from_slice(&key.to_be_bytes()); + cell.extend_from_slice(&payload_len.to_be_bytes()); + cell.extend_from_slice(&overflow_first.to_be_bytes()); + cell.extend_from_slice(local); + cell +} + +/// Binary search a leaf's sorted keys for `key`. `Ok(i)` if present at +/// index `i`; `Err(i)` for the sorted insertion point otherwise. +pub(super) fn leaf_search(buf: &[u8], key: i64) -> Result, PinaxError> { + let n = usize::from(num_cells(buf)?); + let mut lo = 0usize; + let mut hi = n; + while lo < hi { + let mid = lo + (hi - lo) / 2; + let mid_key = leaf_key_at(buf, mid)?; + match mid_key.cmp(&key) { + std::cmp::Ordering::Equal => return Ok(Ok(mid)), + std::cmp::Ordering::Less => lo = mid + 1, + std::cmp::Ordering::Greater => hi = mid, + } + } + Ok(Err(lo)) +} + +// --------------------------------------------------------------------- +// Interior pages. +// --------------------------------------------------------------------- + +pub(super) fn init_interior(buf: &mut [u8], rightmost_child: u32) -> Result<(), PinaxError> { + init_slotted(buf, PAGE_TYPE_INTERIOR)?; + write_u32(buf, 5, rightmost_child) +} + +pub(super) fn interior_rightmost(buf: &[u8]) -> Result { + read_u32(buf, 5) +} + +pub(super) fn interior_set_rightmost(buf: &mut [u8], child: u32) -> Result<(), PinaxError> { + write_u32(buf, 5, child) +} + +pub(super) fn interior_key_at(buf: &[u8], index: usize) -> Result { + let offset = pointer_at(buf, INTERIOR_HEADER_LEN, index)?; + read_i64(buf, usize::from(offset)) +} + +pub(super) fn interior_child_at(buf: &[u8], index: usize) -> Result { + let offset = pointer_at(buf, INTERIOR_HEADER_LEN, index)?; + read_u32(buf, usize::from(offset) + 8) +} + +pub(super) fn interior_set_child_at( + buf: &mut [u8], + index: usize, + child: u32, +) -> Result<(), PinaxError> { + let offset = pointer_at(buf, INTERIOR_HEADER_LEN, index)?; + write_u32(buf, usize::from(offset) + 8, child) +} + +pub(super) fn build_interior_cell(key: i64, child: u32) -> Vec { + let mut cell = Vec::with_capacity(INTERIOR_CELL_LEN); + cell.extend_from_slice(&key.to_be_bytes()); + cell.extend_from_slice(&child.to_be_bytes()); + cell +} + +/// Which child of an interior page an id is referenced from. +pub(super) enum ChildSlot { + Cell(usize), + Rightmost, +} + +pub(super) fn interior_find_child_slot(buf: &[u8], child_id: u32) -> Result { + let n = usize::from(num_cells(buf)?); + for i in 0..n { + if interior_child_at(buf, i)? == child_id { + return Ok(ChildSlot::Cell(i)); + } + } + Ok(ChildSlot::Rightmost) +} + +/// Route `key` to the child that should hold it: the first cell whose key +/// exceeds `key`, or the rightmost child if `key` is at least every +/// separator. +pub(super) fn interior_find_child_for_key(buf: &[u8], key: i64) -> Result { + let n = usize::from(num_cells(buf)?); + for i in 0..n { + if key < interior_key_at(buf, i)? { + return interior_child_at(buf, i); + } + } + interior_rightmost(buf) +} diff --git a/crates/pinax/src/btree/mod.rs b/crates/pinax/src/btree/mod.rs new file mode 100644 index 0000000..f252295 --- /dev/null +++ b/crates/pinax/src/btree/mod.rs @@ -0,0 +1,537 @@ +//! The copy-on-write B+tree (PLAN.md Decision 1): slotted leaf and +//! interior pages, overflow chains for cells over `max_local`, and +//! path-copying insert/delete that never mutates a page id already +//! reachable from the committed meta page. +//! +//! WHY path-copying rather than in-place mutation: every page on the +//! root-to-leaf path of a mutation gets a FRESH page id, and only the +//! final `BufferPool::commit` call makes the new root (and therefore the +//! whole new path) visible. This is what makes the crate's crash-safety +//! story (see `pager` module docs) work without a WAL — Phase 02's +//! deliverable, not Phase 01's. +//! +//! WHY no delete-time merge/rebalance: ROADMAP.md Phase 01's acceptance +//! criteria are CRUD correctness, crash safety, checksums, and buffer-pool +//! eviction — none require space-optimal trees under a delete-heavy +//! workload. A leaf/interior page is allowed to underflow after a delete — +//! `delete`'s interior-propagation step only ever repoints an existing +//! child pointer (`apply_result_to_interior`'s `Replaced` arm), it never +//! removes a separator key, so an ancestor's key count is monotonically +//! non-decreasing across its lifetime. `collapse_root_if_needed` handles +//! the one degenerate shape that IS still possible (an interior root with +//! zero separator keys) defensively rather than assuming it cannot occur; +//! nothing in Phase 01's delete path currently produces it. General +//! merge-on-underflow is real, deferred follow-up scope, not silently +//! dropped (#13). +//! +//! WHY split across four files: `RUST/file-too-long` (800-line limit) — +//! Phase 01 pushed the original single-file module past it. `layout` +//! (slotted-page primitives, leaf pages, interior pages), `overflow` +//! (overflow-chain read/write and the row spill/reassemble boundary), and +//! `mutate` (path-copying propagation) split along the section boundaries +//! the file already documented internally; this module keeps the +//! crate-facing public operations (`insert`/`get`/`update`/`delete`/ +//! `scan`) and the page-layout constants every submodule shares. + +mod layout; +mod mutate; +mod overflow; + +use snafu::OptionExt as _; + +use self::layout::{ + free_space, init_leaf, insert_cell_at, leaf_cell_at, leaf_cell_byte_len, leaf_search, + num_cells, pointer_at, remove_cell_at, +}; +use self::mutate::{ + NodeResult, apply_result_to_interior, collapse_root_if_needed, descend_path, finalize_root, + interior_entries, +}; +use self::overflow::{build_row_cell, reassemble}; +use crate::buffer_pool::BufferPool; +use crate::codec::{read_i64, read_u8, read_vec}; +use crate::error::{BufferBoundsSnafu, KeyAlreadyExistsSnafu, KeyNotFoundSnafu, PinaxError}; +use crate::page::{PAGE_TYPE_INTERIOR, PAGE_TYPE_LEAF}; +use crate::pager::Pager; +use crate::row::Row; + +const LEAF_HEADER_LEN: usize = 5; +const INTERIOR_HEADER_LEN: usize = 9; +/// `key(8) + payload_len(4) + overflow_page(4)`; local row bytes follow. +const LEAF_CELL_FIXED_LEN: usize = 16; +/// `key(8) + child(4)`. +const INTERIOR_CELL_LEN: usize = 12; +const OVERFLOW_HEADER_LEN: usize = 5; +const POINTER_LEN: usize = 2; + +/// Insert `row` under `key`. Returns the new root page id to commit. +/// +/// # Errors +/// +/// Returns [`crate::error::PermanentError::KeyAlreadyExists`] if `key` is +/// already present. +pub(crate) fn insert(pool: &mut BufferPool, key: i64, row: &Row) -> Result { + let root = pool.root_page_id(); + + if root == crate::page::EMPTY_TREE_ROOT { + let cell = build_row_cell(pool, key, row)?; + let mut buf = vec![0u8; pool.page_size().bytes_usize()]; + init_leaf(&mut buf)?; + insert_cell_at(&mut buf, LEAF_HEADER_LEN, 0, &cell)?; + let id = pool.allocate_page_id(); + pool.put_new(id, buf)?; + pool.commit(id)?; + return Ok(id); + } + + let path = descend_path(pool, root, key)?; + let leaf_id = *path.last().context(BufferBoundsSnafu { + at: 0usize, + len: 1usize, + buf_len: 0usize, + })?; + let leaf_buf = pool.get(leaf_id)?; + let insert_idx = match leaf_search(&leaf_buf, key)? { + // WHY checked before `build_row_cell` below (which may allocate + // overflow pages for a large row): a duplicate key must fail + // before any work is done for a row that will not be stored — see + // `build_row_cell`'s docs on why encode-before-check would still + // be safe, just wasteful. + // + // WHY `?` rather than `return ....fail();`: `.fail()` builds the + // LEAF error (`PermanentError`), one level below this function's + // `PinaxError` — `?` performs the `From` conversion + // `#[snafu(transparent)]` provides; a bare `return` would need + // that type to already match exactly. + Ok(_found) => KeyAlreadyExistsSnafu { key }.fail()?, + Err(idx) => idx, + }; + + let cell = build_row_cell(pool, key, row)?; + let mut result = leaf_insert_or_split(pool, &leaf_buf, insert_idx, &cell)?; + let mut old_child_id = leaf_id; + for &ancestor_id in path + .get(..path.len().saturating_sub(1)) + .unwrap_or(&[]) + .iter() + .rev() + { + result = apply_result_to_interior(pool, ancestor_id, old_child_id, &result)?; + old_child_id = ancestor_id; + } + + let new_root = finalize_root(pool, result)?; + pool.commit(new_root)?; + Ok(new_root) +} + +fn leaf_insert_or_split( + pool: &mut BufferPool, + leaf_buf: &[u8], + insert_idx: usize, + cell: &[u8], +) -> Result { + let needed = u16::try_from(cell.len() + POINTER_LEN).unwrap_or(u16::MAX); + if free_space(leaf_buf, LEAF_HEADER_LEN)? >= needed { + let mut buf = leaf_buf.to_vec(); + insert_cell_at(&mut buf, LEAF_HEADER_LEN, insert_idx, cell)?; + let id = pool.allocate_page_id(); + pool.put_new(id, buf)?; + return Ok(NodeResult::Replaced(id)); + } + leaf_split_with_new_cell(pool, leaf_buf, insert_idx, cell) +} + +fn leaf_split_with_new_cell( + pool: &mut BufferPool, + leaf_buf: &[u8], + insert_idx: usize, + new_cell: &[u8], +) -> Result { + let page_size = pool.page_size().bytes_usize(); + let max_local = pool.page_size().max_local(); + let n = usize::from(num_cells(leaf_buf)?); + let mut all_cells: Vec> = Vec::with_capacity(n + 1); + for i in 0..n { + let offset = usize::from(pointer_at(leaf_buf, LEAF_HEADER_LEN, i)?); + let len = usize::from(leaf_cell_byte_len(leaf_buf, i, max_local)?); + all_cells.push(read_vec(leaf_buf, offset, len)?); + } + let clamped_idx = insert_idx.min(all_cells.len()); + all_cells.insert(clamped_idx, new_cell.to_vec()); + + let mid = all_cells.len() / 2; + let (left_half, right_half) = all_cells.split_at(mid); + let mut left_buf = vec![0u8; page_size]; + init_leaf(&mut left_buf)?; + for (i, cell) in left_half.iter().enumerate() { + insert_cell_at(&mut left_buf, LEAF_HEADER_LEN, i, cell)?; + } + let mut right_buf = vec![0u8; page_size]; + init_leaf(&mut right_buf)?; + for (i, cell) in right_half.iter().enumerate() { + insert_cell_at(&mut right_buf, LEAF_HEADER_LEN, i, cell)?; + } + let separator_key = read_i64( + right_half.first().context(BufferBoundsSnafu { + at: 0usize, + len: 1usize, + buf_len: 0usize, + })?, + 0, + )?; + + let left_id = pool.allocate_page_id(); + pool.put_new(left_id, left_buf)?; + let right_id = pool.allocate_page_id(); + pool.put_new(right_id, right_buf)?; + Ok(NodeResult::Split { + left: left_id, + right: right_id, + separator_key, + }) +} + +/// Look up `key`. Returns `None` if absent. +pub(crate) fn get(pool: &mut BufferPool, key: i64) -> Result, PinaxError> { + let root = pool.root_page_id(); + if root == crate::page::EMPTY_TREE_ROOT { + return Ok(None); + } + let max_local = pool.page_size().max_local(); + let mut current = root; + loop { + let buf = pool.get(current)?; + let page_type = read_u8(&buf, 0)?; + if page_type == PAGE_TYPE_LEAF { + return match leaf_search(&buf, key)? { + Ok(idx) => { + let cell = leaf_cell_at(&buf, idx, max_local)?; + let full = reassemble(pool, &cell)?; + Ok(Some(Row::decode(&full)?)) + } + Err(_) => Ok(None), + }; + } + Pager::expect_page_type(current, &buf, PAGE_TYPE_INTERIOR, "interior")?; + current = layout::interior_find_child_for_key(&buf, key)?; + } +} + +/// Replace the row stored at `key`. +/// +/// # Errors +/// +/// Returns [`crate::error::PermanentError::KeyNotFound`] if `key` is +/// absent. +pub(crate) fn update(pool: &mut BufferPool, key: i64, row: &Row) -> Result { + let root = pool.root_page_id(); + if root == crate::page::EMPTY_TREE_ROOT { + KeyNotFoundSnafu { key }.fail()?; + } + let path = descend_path(pool, root, key)?; + let leaf_id = *path.last().context(BufferBoundsSnafu { + at: 0usize, + len: 1usize, + buf_len: 0usize, + })?; + let leaf_buf = pool.get(leaf_id)?; + let idx = match leaf_search(&leaf_buf, key)? { + Ok(idx) => idx, + Err(_) => KeyNotFoundSnafu { key }.fail()?, + }; + + let new_cell = build_row_cell(pool, key, row)?; + + let mut buf = leaf_buf.clone(); + remove_cell_at(&mut buf, LEAF_HEADER_LEN, idx)?; + let mut result = leaf_insert_or_split(pool, &buf, idx, &new_cell)?; + let mut old_child_id = leaf_id; + for &ancestor_id in path + .get(..path.len().saturating_sub(1)) + .unwrap_or(&[]) + .iter() + .rev() + { + result = apply_result_to_interior(pool, ancestor_id, old_child_id, &result)?; + old_child_id = ancestor_id; + } + let new_root = finalize_root(pool, result)?; + pool.commit(new_root)?; + Ok(new_root) +} + +/// Delete the row stored at `key`, returning it. +/// +/// # Errors +/// +/// Returns [`crate::error::PermanentError::KeyNotFound`] if `key` is +/// absent. +pub(crate) fn delete(pool: &mut BufferPool, key: i64) -> Result<(u32, Row), PinaxError> { + let root = pool.root_page_id(); + if root == crate::page::EMPTY_TREE_ROOT { + KeyNotFoundSnafu { key }.fail()?; + } + let max_local = pool.page_size().max_local(); + let path = descend_path(pool, root, key)?; + let leaf_id = *path.last().context(BufferBoundsSnafu { + at: 0usize, + len: 1usize, + buf_len: 0usize, + })?; + let leaf_buf = pool.get(leaf_id)?; + let idx = match leaf_search(&leaf_buf, key)? { + Ok(idx) => idx, + Err(_) => KeyNotFoundSnafu { key }.fail()?, + }; + let removed_cell = leaf_cell_at(&leaf_buf, idx, max_local)?; + let removed_full = reassemble(pool, &removed_cell)?; + let removed_row = Row::decode(&removed_full)?; + + let mut buf = leaf_buf.clone(); + remove_cell_at(&mut buf, LEAF_HEADER_LEN, idx)?; + let new_leaf_id = pool.allocate_page_id(); + pool.put_new(new_leaf_id, buf)?; + let mut result = NodeResult::Replaced(new_leaf_id); + let mut old_child_id = leaf_id; + for &ancestor_id in path + .get(..path.len().saturating_sub(1)) + .unwrap_or(&[]) + .iter() + .rev() + { + result = apply_result_to_interior(pool, ancestor_id, old_child_id, &result)?; + old_child_id = ancestor_id; + } + let mut new_root = finalize_root(pool, result)?; + new_root = collapse_root_if_needed(pool, new_root)?; + pool.commit(new_root)?; + Ok((new_root, removed_row)) +} + +/// In-order traversal of every `(key, row)` pair. Recursive over the +/// tree's own height (bounded by page fan-out), not sibling-linked — see +/// module docs on why Phase 01 has no leaf sibling pointers. +pub(crate) fn scan(pool: &mut BufferPool) -> Result, PinaxError> { + let root = pool.root_page_id(); + let mut out = Vec::new(); + if root != crate::page::EMPTY_TREE_ROOT { + scan_node(pool, root, &mut out)?; + } + Ok(out) +} + +fn scan_node(pool: &mut BufferPool, id: u32, out: &mut Vec<(i64, Row)>) -> Result<(), PinaxError> { + let buf = pool.get(id)?; + let page_type = read_u8(&buf, 0)?; + if page_type == PAGE_TYPE_LEAF { + let max_local = pool.page_size().max_local(); + let n = usize::from(num_cells(&buf)?); + for i in 0..n { + let cell = leaf_cell_at(&buf, i, max_local)?; + let full = reassemble(pool, &cell)?; + let row = Row::decode(&full)?; + out.push((cell.key, row)); + } + return Ok(()); + } + Pager::expect_page_type(id, &buf, PAGE_TYPE_INTERIOR, "interior")?; + let (_, children) = interior_entries(&buf)?; + for child in children { + scan_node(pool, child, out)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use lexis::Value; + + use super::*; + use crate::page::PageSize; + use crate::pager::Pager; + + fn pool(dir: &tempfile::TempDir, capacity: usize) -> BufferPool { + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + BufferPool::new(pager, capacity).expect("valid capacity") + } + + fn row(n: i64) -> Row { + Row::new(vec![Value::Integer(n), Value::Text(format!("row-{n}"))]) + } + + #[test] + fn insert_then_get_round_trips() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + insert(&mut pool, 1, &row(1)).expect("insert"); + let got = get(&mut pool, 1).expect("get").expect("present"); + assert_eq!(got, row(1)); + } + + #[test] + fn get_missing_key_is_none() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + assert_eq!(get(&mut pool, 42).expect("get"), None); + } + + #[test] + fn insert_duplicate_key_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + insert(&mut pool, 1, &row(1)).expect("first insert"); + let err = insert(&mut pool, 1, &row(2)).expect_err("duplicate key"); + assert!(matches!( + err, + PinaxError::Permanent { + source: crate::error::PermanentError::KeyAlreadyExists { .. } + } + )); + } + + #[test] + fn update_replaces_row() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + insert(&mut pool, 1, &row(1)).expect("insert"); + update(&mut pool, 1, &row(99)).expect("update"); + assert_eq!(get(&mut pool, 1).expect("get").expect("present"), row(99)); + } + + #[test] + fn update_missing_key_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + let err = update(&mut pool, 1, &row(1)).expect_err("no such key"); + assert!(matches!( + err, + PinaxError::Permanent { + source: crate::error::PermanentError::KeyNotFound { .. } + } + )); + } + + #[test] + fn delete_removes_and_returns_row() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + insert(&mut pool, 1, &row(1)).expect("insert"); + let (_, removed) = delete(&mut pool, 1).expect("delete"); + assert_eq!(removed, row(1)); + assert_eq!(get(&mut pool, 1).expect("get"), None); + } + + #[test] + fn delete_missing_key_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + let err = delete(&mut pool, 1).expect_err("no such key"); + assert!(matches!( + err, + PinaxError::Permanent { + source: crate::error::PermanentError::KeyNotFound { .. } + } + )); + } + + #[test] + fn many_inserts_force_splits_and_all_keys_remain_readable() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 1024); + for i in 0..500i64 { + insert(&mut pool, i, &row(i)).expect("insert"); + } + for i in 0..500i64 { + assert_eq!(get(&mut pool, i).expect("get").expect("present"), row(i)); + } + } + + #[test] + fn insert_out_of_order_keys_stay_sorted_and_readable() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 1024); + let keys: Vec = vec![50, 10, 90, 30, 70, 20, 80, 40, 60, 0]; + for &k in &keys { + insert(&mut pool, k, &row(k)).expect("insert"); + } + for &k in &keys { + assert_eq!(get(&mut pool, k).expect("get").expect("present"), row(k)); + } + let scanned = scan(&mut pool).expect("scan"); + let scanned_keys: Vec = scanned.iter().map(|(k, _)| *k).collect(); + let mut sorted_keys = keys.clone(); + sorted_keys.sort_unstable(); + assert_eq!(scanned_keys, sorted_keys); + } + + #[test] + fn negative_and_extreme_keys_round_trip() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + for &k in &[i64::MIN, -1, 0, 1, i64::MAX] { + insert(&mut pool, k, &row(k)).expect("insert"); + } + for &k in &[i64::MIN, -1, 0, 1, i64::MAX] { + assert_eq!(get(&mut pool, k).expect("get").expect("present"), row(k)); + } + } + + #[test] + fn large_value_spills_to_overflow_and_round_trips() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + let big_text = "x".repeat(20_000); + let big_row = Row::new(vec![Value::Text(big_text.clone())]); + insert(&mut pool, 1, &big_row).expect("insert with overflow"); + let got = get(&mut pool, 1).expect("get").expect("present"); + assert_eq!(got.values(), &[Value::Text(big_text)]); + } + + #[test] + fn delete_then_reinsert_same_key_works() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 64); + insert(&mut pool, 1, &row(1)).expect("insert"); + delete(&mut pool, 1).expect("delete"); + insert(&mut pool, 1, &row(2)).expect("reinsert"); + assert_eq!(get(&mut pool, 1).expect("get").expect("present"), row(2)); + } + + #[test] + fn delete_most_of_a_multi_level_tree_leaves_remaining_keys_readable() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 1024); + for i in 0..300i64 { + insert(&mut pool, i, &row(i)).expect("insert"); + } + for i in 0..250i64 { + delete(&mut pool, i).expect("delete"); + } + for i in 0..250i64 { + assert_eq!(get(&mut pool, i).expect("get"), None); + } + for i in 250..300i64 { + assert_eq!(get(&mut pool, i).expect("get").expect("present"), row(i)); + } + } + + #[test] + fn insert_survives_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + { + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let mut pool = BufferPool::new(pager, 64).expect("valid capacity"); + for i in 0..20i64 { + insert(&mut pool, i, &row(i)).expect("insert"); + } + } + let pager = Pager::open(&path).expect("reopen"); + let mut pool = BufferPool::new(pager, 64).expect("valid capacity"); + for i in 0..20i64 { + assert_eq!(get(&mut pool, i).expect("get").expect("present"), row(i)); + } + } +} diff --git a/crates/pinax/src/btree/mutate.rs b/crates/pinax/src/btree/mutate.rs new file mode 100644 index 0000000..1cc9fb1 --- /dev/null +++ b/crates/pinax/src/btree/mutate.rs @@ -0,0 +1,281 @@ +//! Path-copying mutation result and propagation: descending to a leaf, +//! propagating a split/replace result back up through ancestor interior +//! pages, and finalizing (or collapsing) the new root. +//! +//! WHY this is its own file: split out of `btree.rs` once Phase 01 pushed +//! that file past `RUST/file-too-long`'s 800-line limit, along the section +//! boundary the file already documented internally. See `super`'s module +//! doc for the full split rationale. + +use snafu::OptionExt as _; + +use super::layout::{ + self, ChildSlot, build_interior_cell, init_interior, interior_child_at, + interior_find_child_for_key, interior_find_child_slot, interior_key_at, interior_rightmost, + interior_set_child_at, interior_set_rightmost, num_cells, +}; +use super::{BufferBoundsSnafu, INTERIOR_CELL_LEN, INTERIOR_HEADER_LEN, POINTER_LEN}; +use crate::buffer_pool::BufferPool; +use crate::codec::read_u8; +use crate::error::PinaxError; +use crate::page::{PAGE_TYPE_INTERIOR, PAGE_TYPE_LEAF}; +use crate::pager::Pager; + +// WHY `Copy`: every field is a trivially-copyable primitive (`u32`/`i64`), +// and `finalize_root` below consumes its `NodeResult` argument at each call +// site's last use — `Copy` lets it take that argument by value without +// clippy flagging an avoidable move, matching the by-value idiom Rust +// prefers for small POD-shaped enums. +#[derive(Clone, Copy)] +pub(super) enum NodeResult { + Replaced(u32), + Split { + left: u32, + right: u32, + separator_key: i64, + }, +} + +pub(super) fn descend_path( + pool: &mut BufferPool, + root: u32, + key: i64, +) -> Result, PinaxError> { + let mut path = vec![root]; + let mut current = root; + loop { + let buf = pool.get(current)?; + let page_type = read_u8(&buf, 0)?; + if page_type == PAGE_TYPE_LEAF { + return Ok(path); + } + Pager::expect_page_type(current, &buf, PAGE_TYPE_INTERIOR, "interior")?; + current = interior_find_child_for_key(&buf, key)?; + path.push(current); + } +} + +/// Collect an interior page's keys and children as growable vectors — +/// `children.len() == keys.len() + 1`, with the last entry the rightmost +/// child — so insert-then-split logic can operate uniformly. +pub(super) fn interior_entries(buf: &[u8]) -> Result<(Vec, Vec), PinaxError> { + let n = usize::from(num_cells(buf)?); + let mut keys = Vec::with_capacity(n); + let mut children = Vec::with_capacity(n + 1); + for i in 0..n { + keys.push(interior_key_at(buf, i)?); + children.push(interior_child_at(buf, i)?); + } + children.push(interior_rightmost(buf)?); + Ok((keys, children)) +} + +fn build_interior_page( + page_size: usize, + keys: &[i64], + children: &[u32], +) -> Result, PinaxError> { + let mut buf = vec![0u8; page_size]; + let rightmost = *children.last().unwrap_or(&0); + init_interior(&mut buf, rightmost)?; + for (i, &key) in keys.iter().enumerate() { + let child = *children.get(i).unwrap_or(&0); + let cell = build_interior_cell(key, child); + layout::insert_cell_at(&mut buf, INTERIOR_HEADER_LEN, i, &cell)?; + } + Ok(buf) +} + +/// Insert `(separator_key, left_child)` into `keys`/`children` at the +/// position `old_child_id` used to occupy, replacing that position's +/// child with `right_child` (the standard B+tree "a child split into two" +/// update — see module docs). +fn splice_split_into_entries( + keys: &mut Vec, + children: &mut Vec, + old_child_id: u32, + separator_key: i64, + left_child: u32, + right_child: u32, +) { + let position = children + .iter() + .position(|&c| c == old_child_id) + .unwrap_or(children.len().saturating_sub(1)); + keys.insert(position, separator_key); + children.insert(position, left_child); + if let Some(slot) = children.get_mut(position + 1) { + *slot = right_child; + } +} + +pub(super) fn apply_result_to_interior( + pool: &mut BufferPool, + ancestor_id: u32, + old_child_id: u32, + result: &NodeResult, +) -> Result { + let page_size = pool.page_size().bytes_usize(); + match result { + NodeResult::Replaced(new_child) => { + let mut buf = pool.get(ancestor_id)?; + match interior_find_child_slot(&buf, old_child_id)? { + ChildSlot::Cell(idx) => interior_set_child_at(&mut buf, idx, *new_child)?, + ChildSlot::Rightmost => interior_set_rightmost(&mut buf, *new_child)?, + } + let new_id = pool.allocate_page_id(); + pool.put_new(new_id, buf)?; + Ok(NodeResult::Replaced(new_id)) + } + NodeResult::Split { + left, + right, + separator_key, + } => { + let buf = pool.get(ancestor_id)?; + let (mut keys, mut children) = interior_entries(&buf)?; + splice_split_into_entries( + &mut keys, + &mut children, + old_child_id, + *separator_key, + *left, + *right, + ); + if keys.len() <= max_interior_entries(page_size) { + let rebuilt = build_interior_page(page_size, &keys, &children)?; + let new_id = pool.allocate_page_id(); + pool.put_new(new_id, rebuilt)?; + Ok(NodeResult::Replaced(new_id)) + } else { + split_interior_entries(pool, page_size, &keys, &children) + } + } + } +} + +/// The exact number of fixed-size separator-key cells that fit on one +/// otherwise-empty interior page: `(usable_space - header) / (cell + +/// pointer)`, matching how `free_space` accounts for the same page. +fn max_interior_entries(page_size: usize) -> usize { + let usable = page_size.saturating_sub(8); + let per_cell = INTERIOR_CELL_LEN + POINTER_LEN; + usable.saturating_sub(INTERIOR_HEADER_LEN) / per_cell.max(1) +} + +fn split_interior_entries( + pool: &mut BufferPool, + page_size: usize, + keys: &[i64], + children: &[u32], +) -> Result { + let mid = keys.len() / 2; + let promoted = *keys.get(mid).context(BufferBoundsSnafu { + at: mid, + len: 1usize, + buf_len: keys.len(), + })?; + + let left_keys = keys.get(..mid).unwrap_or(&[]); + let left_children = children.get(..=mid).unwrap_or(&[]); + let right_keys = keys.get(mid + 1..).unwrap_or(&[]); + let right_children = children.get(mid + 1..).unwrap_or(&[]); + + let left_buf = build_interior_page(page_size, left_keys, left_children)?; + let right_buf = build_interior_page(page_size, right_keys, right_children)?; + let left_id = pool.allocate_page_id(); + pool.put_new(left_id, left_buf)?; + let right_id = pool.allocate_page_id(); + pool.put_new(right_id, right_buf)?; + Ok(NodeResult::Split { + left: left_id, + right: right_id, + separator_key: promoted, + }) +} + +pub(super) fn finalize_root(pool: &mut BufferPool, result: NodeResult) -> Result { + match result { + NodeResult::Replaced(id) => Ok(id), + NodeResult::Split { + left, + right, + separator_key, + } => { + let mut buf = vec![0u8; pool.page_size().bytes_usize()]; + init_interior(&mut buf, right)?; + let cell = build_interior_cell(separator_key, left); + layout::insert_cell_at(&mut buf, INTERIOR_HEADER_LEN, 0, &cell)?; + let id = pool.allocate_page_id(); + pool.put_new(id, buf)?; + Ok(id) + } + } +} + +/// Collapse an interior root with zero separator keys to its sole +/// (rightmost) child, defensively — see module docs on why nothing in +/// Phase 01's current delete path actually produces this shape yet. +pub(super) fn collapse_root_if_needed(pool: &mut BufferPool, root: u32) -> Result { + let buf = pool.get(root)?; + if read_u8(&buf, 0)? != PAGE_TYPE_INTERIOR { + return Ok(root); + } + if num_cells(&buf)? == 0 { + return interior_rightmost(&buf); + } + Ok(root) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::page::PageSize; + use crate::pager::Pager; + + fn pool(dir: &TempDir, capacity: usize) -> BufferPool { + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + BufferPool::new(pager, capacity).expect("valid capacity") + } + + #[test] + fn collapse_root_if_needed_collapses_a_zero_key_interior_root() { + // WHY built directly rather than reached through public + // insert/delete: Phase 01's delete path never produces a + // zero-separator-key interior root (see module docs on why an + // ancestor's key count is monotonically non-decreasing) — this + // exercises the defensive branch on its own. + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 16); + + let leaf_id = pool.allocate_page_id(); + let mut leaf_buf = vec![0u8; pool.page_size().bytes_usize()]; + layout::init_leaf(&mut leaf_buf).expect("init leaf"); + pool.put_new(leaf_id, leaf_buf).expect("put leaf"); + + let mut interior_buf = vec![0u8; pool.page_size().bytes_usize()]; + init_interior(&mut interior_buf, leaf_id).expect("init interior with zero keys"); + let interior_id = pool.allocate_page_id(); + pool.put_new(interior_id, interior_buf) + .expect("put interior"); + + let collapsed = collapse_root_if_needed(&mut pool, interior_id).expect("collapse"); + assert_eq!(collapsed, leaf_id); + } + + #[test] + fn collapse_root_if_needed_leaves_a_leaf_root_unchanged() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool(&dir, 16); + let leaf_id = pool.allocate_page_id(); + let mut leaf_buf = vec![0u8; pool.page_size().bytes_usize()]; + layout::init_leaf(&mut leaf_buf).expect("init leaf"); + pool.put_new(leaf_id, leaf_buf).expect("put leaf"); + + let result = collapse_root_if_needed(&mut pool, leaf_id).expect("no-op on a leaf root"); + assert_eq!(result, leaf_id); + } +} diff --git a/crates/pinax/src/btree/overflow.rs b/crates/pinax/src/btree/overflow.rs new file mode 100644 index 0000000..8d67f36 --- /dev/null +++ b/crates/pinax/src/btree/overflow.rs @@ -0,0 +1,146 @@ +//! Overflow-chain read/write, and the row spill/reassemble boundary that +//! decides whether an encoded row payload fits local to its leaf cell or +//! needs an overflow chain. +//! +//! WHY this is its own file: split out of `btree.rs` once Phase 01 pushed +//! that file past `RUST/file-too-long`'s 800-line limit, along the section +//! boundary the file already documented internally. See `super`'s module +//! doc for the full split rationale. + +use snafu::OptionExt as _; + +use super::OVERFLOW_HEADER_LEN; +use super::layout::{LeafCell, build_leaf_cell}; +use crate::buffer_pool::BufferPool; +use crate::codec::{read_u32, read_vec, write_u8, write_u32}; +use crate::error::{BufferBoundsSnafu, PinaxError}; +use crate::page::PAGE_TYPE_OVERFLOW; +use crate::pager::Pager; +use crate::row::Row; + +fn overflow_chunk_cap(page_size_bytes: u32) -> usize { + let usable = page_size_bytes - crate::page::checksum_len_u32(); + usize::try_from(usable) + .unwrap_or(0) + .saturating_sub(OVERFLOW_HEADER_LEN) +} + +fn write_overflow_chain(pool: &mut BufferPool, tail: &[u8]) -> Result { + if tail.is_empty() { + return Ok(0); + } + let chunk_cap = overflow_chunk_cap(pool.page_size().bytes()); + // WHY chunked forward (remainder last), not backward: `read_overflow_chain` + // reads `remaining_needed.min(chunk_cap)` per page and relies on every + // page but the LAST holding a full `chunk_cap` bytes — that invariant + // only holds if the possibly-short remainder chunk is the tail-end + // chunk in byte order, matching how a chunk_cap-then-remainder split + // naturally falls out of walking `tail` front-to-back. + let mut chunks: Vec<&[u8]> = Vec::new(); + let mut start = 0usize; + while start < tail.len() { + let end = (start + chunk_cap).min(tail.len()); + let chunk = tail.get(start..end).context(BufferBoundsSnafu { + at: start, + len: end - start, + buf_len: tail.len(), + })?; + chunks.push(chunk); + start = end; + } + + // Link the chain tail-to-head: write the LAST natural chunk (the + // remainder) first with `next = 0`, and each earlier chunk after it + // pointing at the page just written — so the final `next_id`, returned + // as `overflow_first`, is the page holding `tail[0..chunk_cap]`, and + // reading forward from it visits every chunk in original byte order. + let mut next_id = 0u32; + for chunk in chunks.into_iter().rev() { + let mut buf = vec![0u8; pool.page_size().bytes_usize()]; + write_u8(&mut buf, 0, PAGE_TYPE_OVERFLOW)?; + write_u32(&mut buf, 1, next_id)?; + crate::codec::write_bytes(&mut buf, OVERFLOW_HEADER_LEN, chunk)?; + let id = pool.allocate_page_id(); + pool.put_new(id, buf)?; + next_id = id; + } + Ok(next_id) +} + +fn read_overflow_chain( + pool: &mut BufferPool, + first_id: u32, + total_len: usize, +) -> Result, PinaxError> { + let mut out = Vec::with_capacity(total_len.min(1 << 20)); + let mut current = first_id; + while current != 0 && out.len() < total_len { + let buf = pool.get(current)?; + Pager::expect_page_type(current, &buf, PAGE_TYPE_OVERFLOW, "overflow")?; + let next = read_u32(&buf, 1)?; + let remaining_needed = total_len - out.len(); + let chunk_cap = overflow_chunk_cap(pool.page_size().bytes()); + let take = remaining_needed.min(chunk_cap); + let mut chunk = read_vec(&buf, OVERFLOW_HEADER_LEN, take)?; + out.append(&mut chunk); + current = next; + } + Ok(out) +} + +/// Split `encoded` into (local bytes kept in the leaf cell, first overflow +/// page id or 0) per Decision 2's `max_local` threshold. +fn spill_if_needed(pool: &mut BufferPool, encoded: &[u8]) -> Result<(Vec, u32), PinaxError> { + let max_local = usize::try_from(pool.page_size().max_local()).unwrap_or(0); + if encoded.len() <= max_local { + return Ok((encoded.to_vec(), 0)); + } + let local = encoded.get(..max_local).context(BufferBoundsSnafu { + at: 0usize, + len: max_local, + buf_len: encoded.len(), + })?; + let tail = encoded.get(max_local..).context(BufferBoundsSnafu { + at: max_local, + len: encoded.len() - max_local, + buf_len: encoded.len(), + })?; + let overflow_first = write_overflow_chain(pool, tail)?; + Ok((local.to_vec(), overflow_first)) +} + +/// Encode `row`, spill it past `max_local` if needed (possibly allocating +/// overflow pages — see [`spill_if_needed`]), and build the resulting leaf +/// cell bytes. +/// +/// WHY callers check `leaf_search` for a duplicate/missing key BEFORE +/// calling this rather than after: encoding and spilling a large row is +/// real, possibly page-allocating work. Doing it before the key check +/// would still be crash-safe (an aborted `insert`/`update` just leaves a +/// few page ids allocated-but-unreferenced — see `pager` module docs on +/// why that is harmless), so this ordering is an efficiency choice, not a +/// correctness one. +pub(super) fn build_row_cell( + pool: &mut BufferPool, + key: i64, + row: &Row, +) -> Result, PinaxError> { + let encoded = row.encode(key)?; + let (local, overflow_first) = spill_if_needed(pool, &encoded)?; + let payload_len = u32::try_from(encoded.len()).unwrap_or(u32::MAX); + Ok(build_leaf_cell(key, payload_len, overflow_first, &local)) +} + +/// Reassemble a leaf cell's full encoded payload (local bytes plus any +/// overflow chain). +pub(super) fn reassemble(pool: &mut BufferPool, cell: &LeafCell) -> Result, PinaxError> { + if cell.overflow_first == 0 { + return Ok(cell.local.clone()); + } + let max_local = pool.page_size().max_local(); + let tail_len = usize::try_from(cell.payload_len.saturating_sub(max_local)).unwrap_or(0); + let mut full = cell.local.clone(); + let mut tail = read_overflow_chain(pool, cell.overflow_first, tail_len)?; + full.append(&mut tail); + Ok(full) +} diff --git a/crates/pinax/src/buffer_pool.rs b/crates/pinax/src/buffer_pool.rs new file mode 100644 index 0000000..58dc49d --- /dev/null +++ b/crates/pinax/src/buffer_pool.rs @@ -0,0 +1,340 @@ +//! The buffer pool: a capacity-bounded, LRU-evicting page cache over the +//! pager (ROADMAP.md Phase 01: "buffer pool handles databases larger than +//! RAM"). +//! +//! WHY no "pinning" or checkout/checkin bookkeeping: every mutation in this +//! crate is copy-on-write (`btree::insert`/`delete` never overwrite a page +//! id already reachable from the committed meta page — see `pager`'s +//! module docs). `get` therefore only ever needs to CLONE a page's bytes +//! out to the caller; the cached copy is never invalidated by a caller's +//! in-progress edit, because that edit is building content for a BRAND NEW +//! page id via [`BufferPool::put_new`], not mutating the cached one. This +//! sidesteps the classic buffer-pool aliasing problem (a page checked out +//! for write must not also be evicted mid-edit) entirely — nothing is ever +//! checked out, so nothing can be evicted out from under an in-progress +//! edit. Eviction candidates are exactly `entries`' keys, unconditionally. + +use std::collections::{HashMap, HashSet, VecDeque}; + +use snafu::OptionExt as _; + +use crate::error::{PinaxError, PoolInvariantViolatedSnafu}; +use crate::page::PageSize; +use crate::pager::Pager; + +/// A capacity-bounded, LRU-evicting cache of page buffers sitting over a +/// [`Pager`]. +#[derive(Debug)] +pub(crate) struct BufferPool { + pager: Pager, + capacity: usize, + entries: HashMap>, + dirty: HashSet, + /// LRU order, oldest (front) to newest (back). Kept in exact sync with + /// `entries`'s key set — INVARIANT enforced by every mutation going + /// through `touch`/`remove_cached`, never touching either collection + /// alone. + recency: VecDeque, + next_page_id: u32, +} + +impl BufferPool { + /// Wrap `pager` in a buffer pool holding at most `capacity` pages. + /// + /// # Errors + /// + /// Returns [`PermanentError::InvalidBufferPoolCapacity`] if `capacity` + /// is zero. + pub(crate) fn new(pager: Pager, capacity: usize) -> Result { + snafu::ensure!(capacity >= 1, crate::error::InvalidBufferPoolCapacitySnafu); + let next_page_id = pager.page_count(); + Ok(Self { + pager, + capacity, + entries: HashMap::new(), + dirty: HashSet::new(), + recency: VecDeque::new(), + next_page_id, + }) + } + + pub(crate) fn page_size(&self) -> PageSize { + self.pager.page_size() + } + + pub(crate) fn root_page_id(&self) -> u32 { + self.pager.root_page_id() + } + + /// Allocate a fresh page id for a copy-on-write page. Never reused + /// across the pool's lifetime — see the pager module docs on why + /// Phase 01 has no freelist reclamation. + pub(crate) fn allocate_page_id(&mut self) -> u32 { + let id = self.next_page_id; + self.next_page_id += 1; + id + } + + /// Read page `id`'s content, cache-or-read-through, as an owned clone. + /// + /// # Errors + /// + /// Propagates [`crate::error::FatalError::Corruption`] from the pager + /// on a checksum failure, or [`crate::error::FatalError::Io`] on a + /// filesystem failure. + pub(crate) fn get(&mut self, id: u32) -> Result, PinaxError> { + if let Some(buf) = self.entries.get(&id) { + let buf = buf.clone(); + self.touch(id); + return Ok(buf); + } + let buf = self.pager.read_data_page(id)?; + self.insert_cached(id, buf.clone(), false)?; + Ok(buf) + } + + /// Insert a brand-new (or freshly overwritten) page as dirty, to be + /// flushed by [`Self::flush_all_dirty`] or evicted early — either is + /// safe under copy-on-write (see module docs). + pub(crate) fn put_new(&mut self, id: u32, buf: Vec) -> Result<(), PinaxError> { + self.insert_cached(id, buf, true) + } + + fn insert_cached(&mut self, id: u32, buf: Vec, dirty: bool) -> Result<(), PinaxError> { + if self.entries.remove(&id).is_some() { + self.recency.retain(|&existing| existing != id); + } + while self.entries.len() >= self.capacity { + self.evict_one()?; + } + if dirty { + self.dirty.insert(id); + } + self.entries.insert(id, buf); + self.recency.push_back(id); + Ok(()) + } + + fn touch(&mut self, id: u32) { + self.recency.retain(|&existing| existing != id); + self.recency.push_back(id); + } + + fn evict_one(&mut self) -> Result<(), PinaxError> { + let id = self + .recency + .pop_front() + .context(PoolInvariantViolatedSnafu)?; + let mut buf = self + .entries + .remove(&id) + .context(PoolInvariantViolatedSnafu)?; + if self.dirty.remove(&id) { + self.pager.write_data_page(id, &mut buf)?; + } + Ok(()) + } + + /// Write every currently-dirty cached page to disk (via + /// [`Pager::write_data_page`]) and fsync, without evicting them from + /// the cache. + pub(crate) fn flush_all_dirty(&mut self) -> Result<(), PinaxError> { + let ids: Vec = self.dirty.iter().copied().collect(); + for id in ids { + if let Some(buf) = self.entries.get_mut(&id) { + self.pager.write_data_page(id, buf)?; + } + self.dirty.remove(&id); + } + self.pager.sync_data() + } + + /// Flush every dirty page, then commit `new_root` as the tree's new + /// root at the current allocation frontier (ROADMAP.md Phase 01: + /// "survive crash-and-reopen"). + pub(crate) fn commit(&mut self, new_root: u32) -> Result<(), PinaxError> { + self.flush_all_dirty()?; + self.pager.commit(new_root, self.next_page_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::{FatalError, PermanentError}; + + fn pool_with_capacity(dir: &tempfile::TempDir, capacity: usize) -> BufferPool { + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + BufferPool::new(pager, capacity).expect("valid capacity") + } + + #[test] + fn zero_capacity_is_rejected() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let err = BufferPool::new(pager, 0).expect_err("capacity 0 is invalid"); + assert!(matches!( + err, + PinaxError::Permanent { + source: PermanentError::InvalidBufferPoolCapacity { .. } + } + )); + } + + #[test] + fn put_then_get_round_trips_without_touching_disk() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool_with_capacity(&dir, 4); + let id = pool.allocate_page_id(); + let buf = vec![7u8; pool.page_size().bytes_usize()]; + pool.put_new(id, buf.clone()).expect("put"); + let got = pool.get(id).expect("get"); + // WHY only the first byte, not the whole buffer: `put_new` caches + // the RAW buffer while `get`'s pager-read-through path stamps a + // checksum into the trailing bytes on the way in — this assertion + // only needs to prove the cache round-trips content, not restate + // checksum placement. + assert_eq!(got.first(), buf.first()); + } + + #[test] + fn eviction_flushes_dirty_pages_to_disk() { + const CAPACITY: usize = 2; + const PAGE_COUNT: usize = 5; + + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool_with_capacity(&dir, CAPACITY); + let page_size = pool.page_size().bytes_usize(); + let ids: Vec = (0..PAGE_COUNT).map(|_| pool.allocate_page_id()).collect(); + for &id in &ids { + let mut buf = vec![0u8; page_size]; + if let Some(b) = buf.first_mut() { + *b = 1; + } + pool.put_new(id, buf).expect("put"); + } + // `CAPACITY` distinct ids, inserted via `put_new` alone (no `get` + // reorders recency), evict strictly in insertion order: the first + // `PAGE_COUNT - CAPACITY` ids are pushed out and durably flushed by + // `evict_one`; the last `CAPACITY` ids stay resident in the cache, + // dirty and NOT yet written — reading THOSE through the pager + // directly (bypassing the cache) is exactly what + // `get_reads_through_on_cache_miss` and + // `commit_persists_new_root_across_reopen` cover elsewhere, and + // asserting it here (as an earlier version of this test did) fails + // with an out-of-bounds file read rather than proving eviction + // flushes anything. + let evicted = &ids[..PAGE_COUNT - CAPACITY]; + for &id in evicted { + let read_back = pool + .pager + .read_data_page(id) + .expect("evicted pages are on disk"); + assert_eq!(read_back.first().copied(), Some(1)); + } + } + + #[test] + fn get_reads_through_on_cache_miss() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool_with_capacity(&dir, 1); + let id = pool.allocate_page_id(); + let mut buf = vec![0u8; pool.page_size().bytes_usize()]; + if let Some(b) = buf.first_mut() { + *b = 9; + } + pool.put_new(id, buf).expect("put"); + // Evict it by inserting a second page under capacity 1. + let other = pool.allocate_page_id(); + pool.put_new(other, vec![0u8; pool.page_size().bytes_usize()]) + .expect("put forces eviction of the first page"); + let got = pool.get(id).expect("read-through from disk"); + assert_eq!(got.first().copied(), Some(9)); + } + + #[test] + fn corrupted_page_surfaces_on_get() { + use std::os::unix::fs::FileExt as _; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let mut pool = BufferPool::new(pager, 1).expect("valid capacity"); + let id = pool.allocate_page_id(); + pool.put_new(id, vec![0u8; pool.page_size().bytes_usize()]) + .expect("put"); + pool.flush_all_dirty().expect("flush to disk"); + // Force an eviction so the next `get` must read through the pager + // (cache hits never re-verify the checksum, by design — see + // module docs). + let other = pool.allocate_page_id(); + pool.put_new(other, vec![0u8; pool.page_size().bytes_usize()]) + .expect("evicts id"); + + let offset = crate::page::META_REGION_LEN + + u64::from(id - crate::page::FIRST_DATA_PAGE_ID) * u64::from(pool.page_size().bytes()); + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open for corruption"); + let mut byte = [0u8; 1]; + file.read_exact_at(&mut byte, offset).expect("read byte"); + byte[0] ^= 0xFF; + file.write_all_at(&byte, offset).expect("flip byte"); + + let err = pool + .get(id) + .expect_err("checksum must catch the flipped byte"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::Corruption { .. } + } + )); + } + + #[test] + fn allocate_page_id_is_monotonic_and_never_repeats() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool_with_capacity(&dir, 4); + let a = pool.allocate_page_id(); + let b = pool.allocate_page_id(); + let c = pool.allocate_page_id(); + assert!(a < b && b < c); + } + + #[test] + fn commit_persists_new_root_across_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let mut pool = BufferPool::new(pager, 4).expect("valid capacity"); + let id = pool.allocate_page_id(); + pool.put_new(id, vec![5u8; pool.page_size().bytes_usize()]) + .expect("put"); + pool.commit(id).expect("commit"); + drop(pool); + + let reopened = Pager::open(&path).expect("reopen"); + assert_eq!(reopened.root_page_id(), id); + } + + #[test] + fn evict_one_on_empty_pool_is_reported_not_panicked() { + // WHY this cannot happen through the public API (capacity >= 1 + // guards it, and `insert_cached` only evicts while at/over + // capacity), documented directly rather than left implicit. + let dir = tempfile::tempdir().expect("tempdir"); + let mut pool = pool_with_capacity(&dir, 1); + let err = pool.evict_one().expect_err("recency queue is empty"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::PoolInvariantViolated { .. } + } + )); + } +} diff --git a/crates/pinax/src/codec.rs b/crates/pinax/src/codec.rs new file mode 100644 index 0000000..21fc176 --- /dev/null +++ b/crates/pinax/src/codec.rs @@ -0,0 +1,174 @@ +//! Bounds-checked big-endian byte access for page buffers. +//! +//! WHY this module exists: the conformance bar forbids indexing/slicing a +//! buffer directly (`buf[at]`, `&buf[a..b]`) because that panics on an +//! out-of-bounds offset — exactly the failure mode a corrupt or truncated +//! page produces. Every accessor here goes through `.get()`/`.get_mut()` +//! and returns [`FatalError::BufferBounds`] instead of panicking, so a +//! malformed page turns into a typed error the caller can classify rather +//! than an unwind. + +use snafu::OptionExt as _; + +use crate::error::{BufferBoundsSnafu, PinaxError}; + +/// Read `N` bytes at `at` from `buf` without slicing. +/// +/// WHY safe despite `copy_from_slice`: `buf.get(at..at + n)` returning +/// `Some` guarantees the returned slice's length is exactly `n` (that is +/// what a valid range slice means), so the length precondition +/// `copy_from_slice` requires always holds by construction — the +/// fallible part (the offset being out of bounds) is already handled by +/// the `.get()` call above it. +pub(crate) fn read_bytes(buf: &[u8], at: usize) -> Result<[u8; N], PinaxError> { + let buf_len = buf.len(); + let slice = buf.get(at..at + N).context(BufferBoundsSnafu { + at, + len: N, + buf_len, + })?; + let mut out = [0u8; N]; + out.copy_from_slice(slice); + Ok(out) +} + +/// Write `bytes` into `buf` starting at `at` without slicing. +pub(crate) fn write_bytes(buf: &mut [u8], at: usize, bytes: &[u8]) -> Result<(), PinaxError> { + let buf_len = buf.len(); + let slot = buf + .get_mut(at..at + bytes.len()) + .context(BufferBoundsSnafu { + at, + len: bytes.len(), + buf_len, + })?; + slot.copy_from_slice(bytes); + Ok(()) +} + +/// Read a single byte without indexing. +pub(crate) fn read_u8(buf: &[u8], at: usize) -> Result { + Ok(read_bytes::<1>(buf, at)?[0]) +} + +/// Write a single byte without indexing. +pub(crate) fn write_u8(buf: &mut [u8], at: usize, value: u8) -> Result<(), PinaxError> { + write_bytes(buf, at, &[value]) +} + +/// Read a big-endian `u16` without indexing. +pub(crate) fn read_u16(buf: &[u8], at: usize) -> Result { + Ok(u16::from_be_bytes(read_bytes::<2>(buf, at)?)) +} + +/// Write a big-endian `u16` without indexing. +pub(crate) fn write_u16(buf: &mut [u8], at: usize, value: u16) -> Result<(), PinaxError> { + write_bytes(buf, at, &value.to_be_bytes()) +} + +/// Read a big-endian `u32` without indexing. +pub(crate) fn read_u32(buf: &[u8], at: usize) -> Result { + Ok(u32::from_be_bytes(read_bytes::<4>(buf, at)?)) +} + +/// Write a big-endian `u32` without indexing. +pub(crate) fn write_u32(buf: &mut [u8], at: usize, value: u32) -> Result<(), PinaxError> { + write_bytes(buf, at, &value.to_be_bytes()) +} + +/// Read a big-endian `u64` without indexing. +pub(crate) fn read_u64(buf: &[u8], at: usize) -> Result { + Ok(u64::from_be_bytes(read_bytes::<8>(buf, at)?)) +} + +/// Write a big-endian `u64` without indexing. +pub(crate) fn write_u64(buf: &mut [u8], at: usize, value: u64) -> Result<(), PinaxError> { + write_bytes(buf, at, &value.to_be_bytes()) +} + +/// Read a big-endian `i64` without indexing. +pub(crate) fn read_i64(buf: &[u8], at: usize) -> Result { + Ok(i64::from_be_bytes(read_bytes::<8>(buf, at)?)) +} + +/// Read `len` bytes starting at `at` into an owned, growable `Vec` +/// without slicing — the runtime-length counterpart to +/// [`read_bytes`]'s const-generic fixed length. +pub(crate) fn read_vec(buf: &[u8], at: usize, len: usize) -> Result, PinaxError> { + let buf_len = buf.len(); + let slice = buf + .get(at..at + len) + .context(BufferBoundsSnafu { at, len, buf_len })?; + Ok(slice.to_vec()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::FatalError; + + #[test] + fn u16_round_trips() { + let mut buf = vec![0u8; 8]; + write_u16(&mut buf, 2, 0xABCD).expect("in bounds"); + assert_eq!(read_u16(&buf, 2).expect("in bounds"), 0xABCD); + } + + #[test] + fn u32_round_trips() { + let mut buf = vec![0u8; 8]; + write_u32(&mut buf, 0, 0xDEAD_BEEF).expect("in bounds"); + assert_eq!(read_u32(&buf, 0).expect("in bounds"), 0xDEAD_BEEF); + } + + #[test] + fn u64_round_trips() { + let mut buf = vec![0u8; 8]; + write_u64(&mut buf, 0, 0x0123_4567_89AB_CDEF).expect("in bounds"); + assert_eq!(read_u64(&buf, 0).expect("in bounds"), 0x0123_4567_89AB_CDEF); + } + + #[test] + fn i64_round_trips_negative() { + // WHY built via `extend_from_slice` rather than a `write_i64` + // helper: no page write ever places an `i64` at a fixed offset + // into an existing buffer (row/cell encoding always appends to a + // growing `Vec` — see `row.rs`'s module docs), so `codec` has no + // `write_i64` to call; this test constructs the expected on-disk + // layout the same way production code does. + let mut buf = Vec::new(); + buf.extend_from_slice(&(-42i64).to_be_bytes()); + assert_eq!(read_i64(&buf, 0).expect("in bounds"), -42); + } + + #[test] + fn read_out_of_bounds_errors() { + let buf = vec![0u8; 4]; + let err = read_u64(&buf, 0).expect_err("8 bytes at offset 0 exceeds a 4-byte buffer"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::BufferBounds { .. } + } + )); + } + + #[test] + fn write_out_of_bounds_errors() { + let mut buf = vec![0u8; 4]; + let err = write_u32(&mut buf, 2, 1).expect_err("4 bytes at offset 2 exceeds 4-byte buffer"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::BufferBounds { .. } + } + )); + } + + #[test] + fn u8_round_trips() { + let mut buf = vec![0u8; 2]; + write_u8(&mut buf, 1, 7).expect("in bounds"); + assert_eq!(read_u8(&buf, 1).expect("in bounds"), 7); + } +} diff --git a/crates/pinax/src/database.rs b/crates/pinax/src/database.rs new file mode 100644 index 0000000..3e6adaa --- /dev/null +++ b/crates/pinax/src/database.rs @@ -0,0 +1,218 @@ +//! [`Database`]: the public Phase 01 facade over the pager, buffer pool, +//! and B+tree (ROADMAP.md Phase 01: "open a file, CRUD rows by integer +//! key, survive crash-and-reopen"). +//! +//! WHY one implicit tree per file rather than a multi-table catalog: +//! PLAN.md Decision 1 fixes "one file per database"; a named-table catalog +//! (`CREATE TABLE`) is Phase 04 territory (ROADMAP.md), which is expected +//! to layer a table-name-to-root-page-id registry on top of the same +//! pager/buffer-pool/B+tree engine this phase lands. Every `Database` +//! today owns exactly one anonymous, integer-keyed B+tree. + +use std::path::Path; + +use crate::btree; +use crate::buffer_pool::BufferPool; +use crate::error::PinaxError; +use crate::page::PageSize; +use crate::pager::Pager; +use crate::row::Row; + +/// Default buffer pool capacity in pages: 256 pages (1 MiB at the default +/// 4 KiB page size). Callers with a memory budget smaller than their +/// working set should use [`Database::create_with_capacity`] / +/// [`Database::open_with_capacity`] instead — see those docs and +/// ROADMAP.md Phase 01's "buffer pool handles databases larger than RAM" +/// criterion. +pub const DEFAULT_BUFFER_POOL_CAPACITY: usize = 256; + +/// A pinax database file: one B+tree, keyed by `i64`, storing +/// [`lexis::Value`] tuple rows (Decision 1, Decision 5). +pub struct Database { + pool: BufferPool, +} + +impl Database { + /// Create a fresh database file at `path` with `page_size` and the + /// default buffer pool capacity. + /// + /// # Errors + /// + /// See [`Self::create_with_capacity`]. + pub fn create(path: &Path, page_size: PageSize) -> Result { + Self::create_with_capacity(path, page_size, DEFAULT_BUFFER_POOL_CAPACITY) + } + + /// Create a fresh database file at `path` with an explicit buffer pool + /// capacity (in pages). + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::AlreadyExists`] if a file + /// is already at `path`, [`crate::error::PermanentError::InvalidBufferPoolCapacity`] + /// if `capacity` is zero, or [`crate::error::FatalError::Io`] on any + /// filesystem failure. + pub fn create_with_capacity( + path: &Path, + page_size: PageSize, + capacity: usize, + ) -> Result { + let pager = Pager::create(path, page_size)?; + let pool = BufferPool::new(pager, capacity)?; + Ok(Self { pool }) + } + + /// Open an existing database file with the default buffer pool + /// capacity. + /// + /// # Errors + /// + /// See [`Self::open_with_capacity`]. + pub fn open(path: &Path) -> Result { + Self::open_with_capacity(path, DEFAULT_BUFFER_POOL_CAPACITY) + } + + /// Open an existing database file with an explicit buffer pool + /// capacity (in pages) — deliberately small relative to the on-disk + /// size demonstrates ROADMAP.md Phase 01's "buffer pool handles + /// databases larger than RAM" criterion. + /// + /// # Errors + /// + /// Returns [`crate::error::FatalError::FileTooSmall`] or + /// [`crate::error::FatalError::NoValidMetaPage`] if `path` is not a + /// readable pinax database, [`crate::error::PermanentError::InvalidBufferPoolCapacity`] + /// if `capacity` is zero, or [`crate::error::FatalError::Io`] on any + /// filesystem failure. + pub fn open_with_capacity(path: &Path, capacity: usize) -> Result { + let pager = Pager::open(path)?; + let pool = BufferPool::new(pager, capacity)?; + Ok(Self { pool }) + } + + /// Insert `row` under `key`. + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::KeyAlreadyExists`] if `key` + /// is already present, or a [`crate::error::FatalError`] variant on + /// I/O or corruption. + pub fn insert(&mut self, key: i64, row: &Row) -> Result<(), PinaxError> { + btree::insert(&mut self.pool, key, row)?; + Ok(()) + } + + /// Look up the row stored at `key`. `Ok(None)` if absent — a missing + /// key is not an error condition (Decision 13's classification + /// reserves error variants for conditions the caller must act on). + /// + /// # Errors + /// + /// Returns a [`crate::error::FatalError`] variant on I/O or + /// corruption. + pub fn get(&mut self, key: i64) -> Result, PinaxError> { + btree::get(&mut self.pool, key) + } + + /// Replace the row stored at `key`. + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::KeyNotFound`] if `key` is + /// absent, or a [`crate::error::FatalError`] variant on I/O or + /// corruption. + pub fn update(&mut self, key: i64, row: &Row) -> Result<(), PinaxError> { + btree::update(&mut self.pool, key, row)?; + Ok(()) + } + + /// Delete and return the row stored at `key`. + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::KeyNotFound`] if `key` is + /// absent, or a [`crate::error::FatalError`] variant on I/O or + /// corruption. + pub fn delete(&mut self, key: i64) -> Result { + let (_new_root, row) = btree::delete(&mut self.pool, key)?; + Ok(row) + } + + /// Every `(key, row)` pair in ascending key order. + /// + /// # Errors + /// + /// Returns a [`crate::error::FatalError`] variant on I/O or + /// corruption. + pub fn scan(&mut self) -> Result, PinaxError> { + btree::scan(&mut self.pool) + } +} + +#[cfg(test)] +mod tests { + use lexis::Value; + + use super::*; + + fn row(n: i64) -> Row { + Row::new(vec![Value::Integer(n)]) + } + + // ROADMAP.md Phase 01 criterion: "open a file, CRUD rows by integer + // key". + #[test] + fn full_crud_cycle() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + let mut db = Database::create(&path, PageSize::DEFAULT).expect("create"); + + db.insert(1, &row(1)).expect("insert"); + assert_eq!(db.get(1).expect("get").expect("present"), row(1)); + + db.update(1, &row(2)).expect("update"); + assert_eq!(db.get(1).expect("get").expect("present"), row(2)); + + let removed = db.delete(1).expect("delete"); + assert_eq!(removed, row(2)); + assert_eq!(db.get(1).expect("get"), None); + } + + #[test] + fn create_then_open_reads_back_inserted_rows() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + { + let mut db = Database::create(&path, PageSize::DEFAULT).expect("create"); + db.insert(1, &row(1)).expect("insert"); + db.insert(2, &row(2)).expect("insert"); + } + let mut reopened = Database::open(&path).expect("open"); + assert_eq!(reopened.get(1).expect("get").expect("present"), row(1)); + assert_eq!(reopened.get(2).expect("get").expect("present"), row(2)); + } + + #[test] + fn open_missing_file_errors() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("missing.pinax"); + assert!(Database::open(&path).is_err()); + } + + #[test] + fn scan_returns_rows_in_ascending_key_order() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("db.pinax"); + let mut db = Database::create(&path, PageSize::DEFAULT).expect("create"); + for k in [5, 1, 3, 2, 4] { + db.insert(k, &row(k)).expect("insert"); + } + let scanned: Vec = db + .scan() + .expect("scan") + .into_iter() + .map(|(k, _)| k) + .collect(); + assert_eq!(scanned, vec![1, 2, 3, 4, 5]); + } +} diff --git a/crates/pinax/src/error.rs b/crates/pinax/src/error.rs new file mode 100644 index 0000000..feac449 --- /dev/null +++ b/crates/pinax/src/error.rs @@ -0,0 +1,284 @@ +//! Error types for pinax's pager, buffer pool, and B+tree. +//! +//! WHY the three-tier shape: PLAN.md Decision 13 fixes `PinaxError` as +//! `Transient | Permanent | Fatal` at the public boundary so a consumer can +//! dispatch retry logic on the outer shape without string-matching a +//! display message. Phase 01 has no locking, no WAL, and no MVCC, so no +//! condition in this phase is legitimately retryable — Decision 13's own +//! examples for `Transient` (`Busy`, `WriteWriteConflict`, `Checkpoint`) are +//! all lock-contention, MVCC-validation, and WAL conditions that Phase +//! 02/03 introduce. An empty `Transient` variant would be dead code with +//! nothing to construct it, so it is added when a phase first produces one +//! rather than reserved empty now. + +use std::path::PathBuf; + +/// Errors raised by pinax's page format, pager, buffer pool, and B+tree. +/// +/// WHY `#[non_exhaustive]`: both inner enums grow as later phases add +/// conditions (WAL, MVCC, encryption); a caller matching today must not +/// break when Phase 02 adds `Transient::Busy`. +/// +/// WHY `#[snafu(transparent)]` on both variants: it gives each inner enum +/// (`PermanentError`, `FatalError`) an auto-generated `From` conversion +/// into `PinaxError`, so every call site below can write +/// `.context(SomeLeafSnafu { .. })?` against the LEAF enum's own +/// context selector and let `?` lift it through `PinaxError` in one step, +/// rather than every fallible call needing two explicit `.context()` hops. +#[derive(Debug, snafu::Snafu)] +#[non_exhaustive] +pub enum PinaxError { + /// A caller-correctable condition: bad input, a violated CRUD + /// precondition (key exists / key missing), a bad configuration value. + #[snafu(transparent)] + Permanent { + /// The specific permanent condition. + source: PermanentError, + }, + /// A condition that means the database file, a page, or the process's + /// invariants can no longer be trusted: checksum failure, I/O failure, + /// or an internal invariant violation. + #[snafu(transparent)] + Fatal { + /// The specific fatal condition. + source: FatalError, + }, +} + +/// Caller-correctable errors: bad input or a violated CRUD precondition. +#[derive(Debug, snafu::Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum PermanentError { + /// A page size outside the locked valid set (Decision 2: 4096 / 8192 / + /// 16384 / 32768 / 65536). + #[snafu(display( + "page size {requested} is not one of the valid sizes 4096/8192/16384/32768/65536" + ))] + InvalidPageSize { + /// The rejected page-size value. + requested: u32, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A buffer pool was constructed with zero capacity. + #[snafu(display("buffer pool capacity must be at least 1 page"))] + InvalidBufferPoolCapacity { + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// `Database::create` was called against a path that already contains a + /// file. + #[snafu(display("database file already exists at {path:?}; use Database::open"))] + AlreadyExists { + /// The path that already existed. + path: PathBuf, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// `insert` was called with a key already present in the tree. + #[snafu(display("key {key} already exists; use update"))] + KeyAlreadyExists { + /// The colliding integer key. + key: i64, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// `update` or `delete` was called with a key absent from the tree. + #[snafu(display("key {key} does not exist"))] + KeyNotFound { + /// The missing integer key. + key: i64, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A row's encoded byte length does not fit in the page format's `u32` + /// payload-length field. + #[snafu(display("row for key {key} encodes to {encoded_len} bytes, exceeding u32::MAX"))] + PayloadTooLarge { + /// The row's key. + key: i64, + /// The row's encoded length. + encoded_len: usize, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, +} + +/// Errors that mean the database file, a page, or an internal invariant can +/// no longer be trusted. +#[derive(Debug, snafu::Snafu)] +#[snafu(visibility(pub(crate)))] +#[non_exhaustive] +pub enum FatalError { + /// A page's stored checksum did not match its recomputed checksum + /// (Decision 2: XxHash3-64 in the trailing 8 reserved bytes). + #[snafu(display( + "page {page_id} failed checksum verification: expected {expected:016x}, got {actual:016x}" + ))] + Corruption { + /// The page whose checksum did not verify. + page_id: u32, + /// The checksum stored in the page's trailing reserved bytes. + expected: u64, + /// The checksum recomputed from the page's content. + actual: u64, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Neither meta slot (page 0 nor page 1) verified its checksum on open. + /// + /// WHY distinct from `Corruption`: a single bad page identifies which + /// page is wrong. Both meta slots failing means the file is not a + /// readable pinax database at all (never created, truncated, or wrong + /// file entirely) rather than one corrupt page inside an otherwise + /// valid one. + #[snafu(display("no valid meta page found in {path:?} (checked slots 0 and 1)"))] + NoValidMetaPage { + /// The database file path. + path: PathBuf, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The database file was shorter than the fixed meta-page region. + #[snafu(display("{path:?} is {actual_len} bytes, shorter than the meta region ({min_len})"))] + FileTooSmall { + /// The database file path. + path: PathBuf, + /// The file's actual length in bytes. + actual_len: u64, + /// The minimum length a readable pinax file must have. + min_len: u64, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A page byte offset fell outside the buffer being read or written. + /// + /// WHY this exists rather than a panic: every byte access in `codec` + /// goes through `.get()`/`.get_mut()` (no indexing) precisely so a + /// malformed or corrupt page produces this typed error instead of a + /// panic. Reaching this variant on a page whose checksum verified is an + /// internal encoding bug — the codec and the encoders that call it are + /// expected to keep every offset in bounds by construction. + #[snafu(display("byte range [{at}, {at}+{len}) is out of bounds for a {buf_len}-byte buffer"))] + BufferBounds { + /// The offset the access started at. + at: usize, + /// The number of bytes the access needed. + len: usize, + /// The buffer's actual length. + buf_len: usize, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A page was read whose `page_type` byte did not match any known + /// variant, or did not match the type the caller expected at that + /// position in the tree. + #[snafu(display("page {page_id}: expected page type {expected}, got byte {actual}"))] + UnexpectedPageType { + /// The page whose type byte was wrong. + page_id: u32, + /// The page type the caller expected (as a debug label). + expected: &'static str, + /// The raw type byte actually stored on the page. + actual: u8, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// A row's byte payload, read from a page that already passed checksum + /// verification, did not decode as a value of the on-disk row format. + /// + /// WHY this is `Fatal` and not `Permanent`: a checksum-valid page's + /// bytes came from `Row::encode`, which only ever writes tags and + /// lengths this crate's own decoder understands. Reaching this variant + /// means the checksum passed over bytes the decoder still cannot + /// parse — either an encode/decode mismatch bug, or corruption that + /// happened to preserve the checksum (astronomically unlikely for + /// XxHash3-64, but not the caller's mistake to correct either way). + #[snafu(display("row payload failed to decode: {reason}"))] + InvalidRowEncoding { + /// What specifically failed to decode. + reason: &'static str, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The buffer pool's LRU recency queue was empty while eviction was + /// still required to satisfy a capacity bound. + /// + /// WHY this cannot happen by construction, and is still handled: the + /// pool never removes an entry from `entries` without also removing it + /// from `recency` in the same operation (INVARIANT enforced by + /// `BufferPool::put_new`/`evict_one`). Surfacing this as a typed error + /// rather than a panic keeps the "no unwrap/expect" rule intact if that + /// invariant is ever violated by a future edit. + #[snafu(display("buffer pool recency queue underflowed capacity accounting"))] + PoolInvariantViolated { + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// An I/O operation against the database file failed. + #[snafu(display("I/O error on {path:?}: {source}"))] + Io { + /// The database file path. + path: PathBuf, + /// The underlying I/O error. + source: std::io::Error, + /// Error creation location. + #[snafu(implicit)] + location: snafu::Location, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn permanent_display_wraps_source() { + let err = PinaxError::Permanent { + source: PermanentError::KeyNotFound { + key: 7, + location: snafu::Location::new(file!(), line!(), column!()), + }, + }; + assert_eq!(err.to_string(), "key 7 does not exist"); + } + + #[test] + fn fatal_display_wraps_source() { + let err = PinaxError::Fatal { + source: FatalError::Corruption { + page_id: 3, + expected: 1, + actual: 2, + location: snafu::Location::new(file!(), line!(), column!()), + }, + }; + assert!(err.to_string().contains("page 3 failed checksum")); + } +} diff --git a/crates/pinax/src/lib.rs b/crates/pinax/src/lib.rs index 7c87bde..0e291fb 100644 --- a/crates/pinax/src/lib.rs +++ b/crates/pinax/src/lib.rs @@ -1,11 +1,39 @@ -//! Facade: pager, buffer pool, B-tree, page format, SQL surface -//! (parser/planner/executor), async API, migration runner, CLI -//! (Decision 1, Decision 2, Decision 6, Decision 7, Decision 10, -//! Decision 12, Decision 14). +//! Facade: page format, pager, buffer pool, and B+tree (Decision 1, +//! Decision 2, Decision 14). SQL surface (parser/planner/executor), async +//! API, migration runner, and CLI land in later phases per ROADMAP.md. //! -//! Empty scaffold reserving this crate's position in the locked dependency -//! graph (`lexis -> hypomnema -> phylaxis -> pinax`). Implementation begins -//! in Phase 01 (pager + buffer pool + B-tree) — see -//! `kanon/projects/pinax/ROADMAP.md`. +//! Phase 01 (this phase) lands a durable, checksummed, ordered +//! integer-keyed key/value store: [`Database`] is the entry point. +//! +//! ``` +//! use lexis::Value; +//! use pinax::{Database, PageSize, Row}; +//! +//! # fn main() -> Result<(), pinax::PinaxError> { +//! let dir = tempfile::tempdir().expect("tempdir"); +//! let path = dir.path().join("example.pinax"); +//! let mut db = Database::create(&path, PageSize::DEFAULT)?; +//! db.insert(1, &Row::new(vec![Value::Text("hello".to_owned())]))?; +//! assert!(db.get(1)?.is_some()); +//! # Ok(()) +//! # } +//! ``` #![deny(missing_docs)] +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] + +mod btree; +mod buffer_pool; +mod codec; +mod database; +mod error; +mod page; +mod pager; +mod row; + +pub use database::{DEFAULT_BUFFER_POOL_CAPACITY, Database}; +pub use error::{FatalError, PermanentError, PinaxError}; +pub use page::PageSize; +pub use row::Row; diff --git a/crates/pinax/src/page.rs b/crates/pinax/src/page.rs new file mode 100644 index 0000000..1574075 --- /dev/null +++ b/crates/pinax/src/page.rs @@ -0,0 +1,292 @@ +//! Page format: size validation, layout constants, and the XxHash3-64 +//! checksum (PLAN.md Decision 2). +//! +//! Every page pinax writes — meta, leaf, interior, overflow — is exactly +//! [`PageSize::bytes`] long and carries an 8-byte XxHash3-64 checksum in +//! its trailing reserved region. `reserved_bytes` is fixed at 8 here +//! because Phase 01 has no encryption path (Decision 2's 48-byte reserved +//! region is the encrypted-tablespace case; Phase 06 adds it). + +use snafu::ensure; + +use crate::codec::{read_u64, write_u64}; +use crate::error::{InvalidPageSizeSnafu, PinaxError}; + +/// Trailing bytes on every page reserved for the XxHash3-64 checksum. +/// +/// WHY 8, unconditionally: Decision 2 reserves 8 bytes when the tablespace +/// is unencrypted and 48 when it carries per-page AEAD (32-byte nonce + +/// 16-byte tag, checksum omitted because the AEAD tag already authenticates +/// the page). Phase 01 has no encryption path — `phylaxis` (the crate that +/// owns AEAD, per Decision 14) is still an empty scaffold — so only the +/// 8-byte unencrypted layout exists yet. +pub(crate) const CHECKSUM_LEN: usize = 8; + +/// [`CHECKSUM_LEN`] restated as `u32` for page-size arithmetic. +/// +/// WHY a second constant rather than a cast: the "no `as` casts" rule +/// means every `usize -> u32` conversion needs `try_from`, and doing that +/// at every call site for a value that is always `8` adds noise without +/// adding safety. One `const` restatement keeps both units available +/// without a fallible conversion anywhere. +const CHECKSUM_LEN_U32: u32 = 8; + +/// [`CHECKSUM_LEN`] as `u32`, for `btree`'s page-buffer arithmetic. +pub(crate) fn checksum_len_u32() -> u32 { + CHECKSUM_LEN_U32 +} + +/// [`CHECKSUM_LEN`] restated as `u16`, for the same reason as +/// [`CHECKSUM_LEN_U32`]: `btree`'s slotted-page offsets are `u16` (every +/// offset within one page fits — see [`PageSize::max_local`]'s doc on why +/// `usable_space` never exceeds it). +const CHECKSUM_LEN_U16: u16 = 8; + +/// [`CHECKSUM_LEN`] as `u16`, for `btree`'s slotted-page offset arithmetic. +pub(crate) fn checksum_len_u16() -> u16 { + CHECKSUM_LEN_U16 +} + +/// The smallest page size Decision 2 permits. +/// +/// WHY 4096 and not SQLite's 512: no fleet target runs on 512-byte-sector +/// hardware (PLAN.md Decision 2) — raising the floor to match present SSD +/// and ext4/xfs block granularity eliminates a dead code path rather than +/// preserving compatibility with hardware nothing in the fleet uses. +pub(crate) const MIN_PAGE_SIZE: u32 = 4096; + +/// The largest page size Decision 2 permits, matching SQLite's ceiling. +pub(crate) const MAX_PAGE_SIZE: u32 = 65536; + +/// Fixed size of each meta-page slot's checksummed region, independent of +/// the database's configured [`PageSize`]. +/// +/// WHY fixed rather than `page_size`-sized: `Pager::open` must learn the +/// configured page size FROM the meta page before it can compute any +/// `page_size`-relative file offset. If the meta region's own size (and +/// therefore slot 1's file offset) depended on that not-yet-known value, +/// opening a database would need to guess before it could verify. Pinning +/// the meta region to the format's own floor size — the meta page's actual +/// content (magic, version, page size, txn id, root, page count) is well +/// under 100 bytes regardless of the configured data page size — makes +/// bootstrap independent of the value it discovers. +/// +/// WHY a literal restatement of [`MIN_PAGE_SIZE`] rather than +/// `u64::from(MIN_PAGE_SIZE)`: the widening `u32 -> u64` conversion is +/// lossless, but `From`'s trait method is not yet usable inside a `const` +/// initializer on this toolchain (rust-lang/rust#143874) — the same +/// "restate rather than convert" reasoning [`CHECKSUM_LEN_U32`] and +/// [`CHECKSUM_LEN_U16`] above already apply to sidestep a fallible/`as` +/// conversion for a value that never changes independently of its source. +pub(crate) const META_SLOT_LEN: u64 = 4096; + +/// Total file offset before the first data page begins. +/// +/// WHY two [`META_SLOT_LEN`] slots rather than one: PLAN.md Decision 1 +/// requires the copy-on-write B+tree to "survive crash-and-reopen" +/// (ROADMAP.md Phase 01). A single meta page rewritten in place is exposed +/// to a torn write mid-commit; ping-ponging between two checksummed slots +/// (see `pager::commit`) means a torn write on the slot being written +/// leaves the OTHER slot — still carrying the prior, fully durable +/// commit — as a valid fallback on reopen. +pub(crate) const META_REGION_LEN: u64 = META_SLOT_LEN * 2; + +/// The lowest page id a data (leaf/interior/overflow) page may use. +/// +/// Page ids 0 and 1 are the two meta slots; data pages begin at 2. +pub(crate) const FIRST_DATA_PAGE_ID: u32 = 2; + +/// Sentinel `root_page_id` meaning "the tree is empty". +/// +/// WHY 0 is safe as a sentinel despite page id 0 being real (meta slot A): +/// no data page is ever assigned id 0 or 1 — [`FIRST_DATA_PAGE_ID`] starts +/// the bump allocator at 2 — so a `root_page_id` of 0 can never collide +/// with an actual data page reference. +pub(crate) const EMPTY_TREE_ROOT: u32 = 0; + +/// Page type byte identifying a leaf B+tree page. +pub(crate) const PAGE_TYPE_LEAF: u8 = 1; +/// Page type byte identifying an interior B+tree page. +pub(crate) const PAGE_TYPE_INTERIOR: u8 = 2; +/// Page type byte identifying an overflow chain page. +pub(crate) const PAGE_TYPE_OVERFLOW: u8 = 3; + +/// A validated, database-lifetime-immutable page size (Decision 2). +/// +/// WHY `TryFrom` and not `From`: the value must be one of the five sizes +/// Decision 2 locks (4096/8192/16384/32768/65536) — an arbitrary `u32` +/// invalidates the invariant, so construction is fallible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(transparent)] +pub struct PageSize(u32); + +impl PageSize { + /// The fleet default (Decision 2). + pub const DEFAULT: Self = Self(4096); + + /// The configured page size in bytes. + #[must_use] + pub fn bytes(self) -> u32 { + self.0 + } + + /// [`Self::bytes`] as `usize`, for allocating a page-sized buffer. + /// + /// WHY the fallback is `usize::MAX` and not `0`: every locked page + /// size (Decision 2: 4096..=65536) fits `usize` on any platform this + /// fleet targets (64-bit Linux) — the fallback path is unreachable in + /// practice. `usize::MAX` fails loudly (an allocation of that size + /// aborts) rather than silently producing a zero-length buffer a + /// caller could mistake for a valid empty page. + #[must_use] + pub(crate) fn bytes_usize(self) -> usize { + usize::try_from(self.0).unwrap_or(usize::MAX) + } + + /// Bytes available for page content after the trailing checksum + /// region (Decision 2's `usable_space`). + #[must_use] + pub(crate) fn usable_space(self) -> u32 { + self.0 - CHECKSUM_LEN_U32 + } + + /// The largest locally-stored cell payload before it must spill to an + /// overflow chain (Decision 2: `max_local = usable_space - 35`). + /// + /// WHY 35 is not re-derived: Decision 2 states pinax "copies SQLite's + /// formula" verbatim (citing `turso/core/storage/btree.rs:8194-8220`, + /// a design prior, not vendored code) rather than deriving it from + /// pinax's own cell layout, so 35 is the locked constant, not computed + /// from `LEAF_CELL_FIXED_LEN` or any other pinax-specific figure. + #[must_use] + pub(crate) fn max_local(self) -> u32 { + self.usable_space().saturating_sub(35) + } +} + +impl TryFrom for PageSize { + type Error = PinaxError; + + /// Validate and construct a [`PageSize`]. + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::InvalidPageSize`] unless + /// `value` is exactly one of 4096, 8192, 16384, 32768, or 65536. + fn try_from(value: u32) -> Result { + ensure!( + value.is_power_of_two() && (MIN_PAGE_SIZE..=MAX_PAGE_SIZE).contains(&value), + InvalidPageSizeSnafu { requested: value } + ); + Ok(Self(value)) + } +} + +impl Default for PageSize { + fn default() -> Self { + Self::DEFAULT + } +} + +/// Compute the XxHash3-64 checksum over `buf[..buf.len() - CHECKSUM_LEN]`. +/// +/// WHY infallible: `buf.get(..content_len)` falls back to the whole buffer +/// on a too-short slice rather than failing, and XxHash3 itself has no +/// error path — there is no condition this function could report. +pub(crate) fn compute_checksum(buf: &[u8]) -> u64 { + let content_len = buf.len().saturating_sub(CHECKSUM_LEN); + let content = buf.get(..content_len).unwrap_or(buf); + xxhash_rust::xxh3::xxh3_64(content) +} + +/// Stamp `buf`'s trailing [`CHECKSUM_LEN`] bytes with the checksum of +/// everything before them. +pub(crate) fn stamp_checksum(buf: &mut [u8]) -> Result<(), PinaxError> { + let checksum = compute_checksum(buf); + let at = buf.len().saturating_sub(CHECKSUM_LEN); + write_u64(buf, at, checksum) +} + +/// Verify `buf`'s trailing checksum against its recomputed content +/// checksum. Returns `Ok(())` on match, `Err` describing the mismatch +/// otherwise. The caller attaches the page id. +/// +/// WHY `unwrap_or(0)` below is safe rather than a masked failure: +/// [`compute_checksum`] is infallible; only `read_u64` can fail here, and +/// only on a buffer shorter than [`CHECKSUM_LEN`], which every page-sized +/// buffer in this crate never is. Coalescing that unreachable case to `0` +/// still produces the correct outcome (an undersized buffer reads as a +/// checksum mismatch, not a silent pass) rather than requiring this +/// function to propagate a `PinaxError` for a condition that cannot occur +/// given how every caller constructs its buffers. +pub(crate) fn verify_checksum(buf: &[u8]) -> Result<(), (u64, u64)> { + let expected = read_u64(buf, buf.len().saturating_sub(CHECKSUM_LEN)).unwrap_or(0); + let actual = compute_checksum(buf); + if expected == actual { + Ok(()) + } else { + Err((expected, actual)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_size_accepts_all_locked_values() { + for value in [4096, 8192, 16384, 32768, 65536] { + assert_eq!(PageSize::try_from(value).expect("valid").bytes(), value); + } + } + + #[test] + fn page_size_rejects_non_power_of_two() { + let err = PageSize::try_from(5000).expect_err("not a valid page size"); + assert!(matches!( + err, + PinaxError::Permanent { + source: crate::error::PermanentError::InvalidPageSize { .. } + } + )); + } + + #[test] + fn page_size_rejects_below_minimum() { + assert!(PageSize::try_from(512).is_err()); + } + + #[test] + fn page_size_rejects_above_maximum() { + assert!(PageSize::try_from(131_072).is_err()); + } + + #[test] + fn default_is_4096() { + assert_eq!(PageSize::default().bytes(), 4096); + } + + #[test] + fn max_local_matches_decision_2_formula() { + let page_size = PageSize::DEFAULT; + assert_eq!(page_size.usable_space(), 4096 - 8); + assert_eq!(page_size.max_local(), 4096 - 8 - 35); + } + + #[test] + fn checksum_round_trips() { + let mut buf = vec![0xAB_u8; 4096]; + stamp_checksum(&mut buf).expect("buffer at least CHECKSUM_LEN long"); + assert!(verify_checksum(&buf).is_ok()); + } + + #[test] + fn checksum_detects_flipped_byte() { + let mut buf = vec![0xAB_u8; 4096]; + stamp_checksum(&mut buf).expect("buffer at least CHECKSUM_LEN long"); + if let Some(byte) = buf.get_mut(10) { + *byte ^= 0xFF; + } + assert!(verify_checksum(&buf).is_err()); + } +} diff --git a/crates/pinax/src/pager.rs b/crates/pinax/src/pager.rs new file mode 100644 index 0000000..23d87cd --- /dev/null +++ b/crates/pinax/src/pager.rs @@ -0,0 +1,599 @@ +//! The pager: file-backed, checksummed, copy-on-write page storage +//! (PLAN.md Decision 1, Decision 2). +//! +//! WHY copy-on-write needs no WAL to be crash-safe here: a page id already +//! visible from the committed meta page is NEVER mutated in place — every +//! change targets a freshly allocated id (`allocate_page_id`), and the only +//! write that makes a change visible is [`Pager::commit`], which durably +//! writes the OTHER meta slot (ping-pong between page id 0 and 1) and +//! fsyncs twice: once for the data pages the new meta will reference, once +//! for the meta page itself. A crash at any point before the second fsync +//! leaves the previously-committed meta slot untouched and fully valid — +//! there is nothing to roll back because nothing reachable ever changed. +//! This is the mechanism ROADMAP.md Phase 01's "survive crash-and-reopen" +//! criterion demonstrates; Phase 02 adds the WAL for durability options +//! this scheme does not attempt (e.g. sub-transaction durability points). +//! +//! WHY page ids 0 and 1 (not `page_size`-relative offsets) always locate +//! the two meta slots: see [`crate::page::META_SLOT_LEN`] — bootstrap must +//! learn `page_size` from the meta page before it can compute anything +//! `page_size`-relative, so the meta region's own layout cannot depend on +//! that value. +//! +//! WHY no freelist reclamation in Phase 01: a page retired by this +//! transaction's copy-on-write is still referenced by the CURRENTLY ACTIVE +//! (pre-commit) meta until this transaction's meta write durably lands. +//! Overwriting that page's on-disk content before that point — the +//! prerequisite for a reusable freelist entry — would corrupt the +//! still-authoritative pre-transaction state if the process crashes in +//! between. Reusing an id retired by an EARLIER, already-committed +//! transaction is safe, but Phase 01's `page_count` is a pure bump +//! allocator that never looks at what a prior transaction retired — that is +//! real, deferred scope (tracked at forkwright/pinax, not silently +//! dropped), not a correctness gap: the database simply grows monotonically +//! rather than reusing space, which no Phase 01 acceptance criterion +//! (ROADMAP.md) requires reclaiming. + +use std::fs::{File, OpenOptions}; +use std::os::unix::fs::FileExt as _; +use std::path::{Path, PathBuf}; + +use snafu::{OptionExt as _, ResultExt as _, ensure}; + +use crate::codec::{read_u32, read_u64, write_u32, write_u64}; +use crate::error::{ + AlreadyExistsSnafu, FatalError, FileTooSmallSnafu, IoSnafu, NoValidMetaPageSnafu, PinaxError, + UnexpectedPageTypeSnafu, +}; +use crate::page::{self, META_REGION_LEN, META_SLOT_LEN, PageSize, stamp_checksum}; + +const META_MAGIC: [u8; 4] = *b"PNX1"; +const META_FORMAT_VERSION: u16 = 1; + +const OFFSET_MAGIC: usize = 0; +const OFFSET_VERSION: usize = 4; +const OFFSET_PAGE_SIZE: usize = 6; +const OFFSET_TXN_ID: usize = 10; +const OFFSET_ROOT: usize = 18; +const OFFSET_PAGE_COUNT: usize = 22; +const OFFSET_FREELIST_HEAD: usize = 26; + +/// The decoded content of one meta slot, once its checksum has verified. +struct MetaContent { + page_size: PageSize, + txn_id: u64, + root_page_id: u32, + page_count: u32, +} + +/// Decode a meta slot's content, or `None` if its magic bytes or page-size +/// field are not well-formed. +/// +/// WHY `Option` and not `Result`: the caller (`Pager::open`) only ever +/// wants to know "is this slot usable", already having verified the +/// checksum separately — a malformed magic or page size on a +/// checksum-valid slot cannot happen given `encode_meta_slot` is the only +/// writer, so folding both failure modes into "not usable" rather than a +/// typed error keeps `open`'s two-slot selection logic a plain match on +/// `Option`. +fn decode_meta_slot(buf: &[u8]) -> Option { + let magic = buf.get(OFFSET_MAGIC..OFFSET_MAGIC + 4)?; + if magic != META_MAGIC { + return None; + } + let page_size_raw = read_u32(buf, OFFSET_PAGE_SIZE).ok()?; + let page_size = PageSize::try_from(page_size_raw).ok()?; + let txn_id = read_u64(buf, OFFSET_TXN_ID).ok()?; + let root_page_id = read_u32(buf, OFFSET_ROOT).ok()?; + let page_count = read_u32(buf, OFFSET_PAGE_COUNT).ok()?; + Some(MetaContent { + page_size, + txn_id, + root_page_id, + page_count, + }) +} + +fn encode_meta_slot(content: &MetaContent) -> Result, PinaxError> { + let slot_len = usize::try_from(META_SLOT_LEN).unwrap_or(4096); + let mut buf = vec![0u8; slot_len]; + let magic_slot = + buf.get_mut(OFFSET_MAGIC..OFFSET_MAGIC + 4) + .context(crate::error::BufferBoundsSnafu { + at: OFFSET_MAGIC, + len: 4usize, + buf_len: slot_len, + })?; + magic_slot.copy_from_slice(&META_MAGIC); + crate::codec::write_u16(&mut buf, OFFSET_VERSION, META_FORMAT_VERSION)?; + write_u32(&mut buf, OFFSET_PAGE_SIZE, content.page_size.bytes())?; + write_u64(&mut buf, OFFSET_TXN_ID, content.txn_id)?; + write_u32(&mut buf, OFFSET_ROOT, content.root_page_id)?; + write_u32(&mut buf, OFFSET_PAGE_COUNT, content.page_count)?; + write_u32(&mut buf, OFFSET_FREELIST_HEAD, 0)?; + stamp_checksum(&mut buf)?; + Ok(buf) +} + +/// File-backed, checksummed, copy-on-write page storage. +/// +/// WHY `pub(crate)`: `Database` (in `database.rs`) is the public entry +/// point; the pager is an implementation detail the buffer pool sits on +/// top of. +#[derive(Debug)] +pub(crate) struct Pager { + file: File, + path: PathBuf, + page_size: PageSize, + active_slot: u8, + txn_id: u64, + root_page_id: u32, + page_count: u32, +} + +impl Pager { + /// Create a fresh database file at `path` with the given `page_size`. + /// + /// # Errors + /// + /// Returns [`crate::error::PermanentError::AlreadyExists`] if a file is + /// already there, or [`FatalError::Io`] on any filesystem failure. + pub(crate) fn create(path: &Path, page_size: PageSize) -> Result { + let open_result = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(path); + let file = match open_result { + Ok(file) => file, + // WHY `?` in tail position rather than `return ....fail();`: + // `.fail()`/`.context()` produce the LEAF error type + // (`PermanentError`/`FatalError`), one level below this + // function's declared `PinaxError` — `?` performs the `From` + // conversion `#[snafu(transparent)]` provides; a bare `return` + // would need that type to already match exactly. + Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { + AlreadyExistsSnafu { + path: path.to_path_buf(), + } + .fail()? + } + Err(source) => Err(source).context(IoSnafu { + path: path.to_path_buf(), + })?, + }; + + let content = MetaContent { + page_size, + txn_id: 0, + root_page_id: page::EMPTY_TREE_ROOT, + page_count: page::FIRST_DATA_PAGE_ID, + }; + let slot = encode_meta_slot(&content)?; + file.write_all_at(&slot, 0).context(IoSnafu { + path: path.to_path_buf(), + })?; + // WHY `set_len` to the full meta region: only slot A (the first + // `META_SLOT_LEN` bytes) was just written, so the file is only + // `META_SLOT_LEN` bytes long — one slot short of `open`'s own + // `actual_len >= META_REGION_LEN` precondition, which it must + // check before reading slot B at all. Extending (never truncating, + // since the file is currently shorter) reserves slot B's region as + // a zero-filled hole; it deliberately does NOT verify a checksum, + // so `open`'s existing `decode_meta_slot`/`verify_checksum` path + // correctly reads it back as "not a valid slot" and falls back to + // slot A — the same fallback a corrupted slot B takes. + file.set_len(META_REGION_LEN).context(IoSnafu { + path: path.to_path_buf(), + })?; + file.sync_all().context(IoSnafu { + path: path.to_path_buf(), + })?; + + Ok(Self { + file, + path: path.to_path_buf(), + page_size, + active_slot: 0, + txn_id: 0, + root_page_id: page::EMPTY_TREE_ROOT, + page_count: page::FIRST_DATA_PAGE_ID, + }) + } + + /// Open an existing database file, picking whichever meta slot carries + /// the higher verified `txn_id`. + /// + /// # Errors + /// + /// Returns [`FatalError::FileTooSmall`] if the file is shorter than the + /// meta region, [`FatalError::NoValidMetaPage`] if neither slot's + /// checksum verifies, or [`FatalError::Io`] on any filesystem failure. + pub(crate) fn open(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .open(path) + .context(IoSnafu { + path: path.to_path_buf(), + })?; + let actual_len = file + .metadata() + .context(IoSnafu { + path: path.to_path_buf(), + })? + .len(); + ensure!( + actual_len >= META_REGION_LEN, + FileTooSmallSnafu { + path: path.to_path_buf(), + actual_len, + min_len: META_REGION_LEN, + } + ); + + let slot_len = usize::try_from(META_SLOT_LEN).unwrap_or(4096); + let mut slot_a = vec![0u8; slot_len]; + let mut slot_b = vec![0u8; slot_len]; + file.read_exact_at(&mut slot_a, 0).context(IoSnafu { + path: path.to_path_buf(), + })?; + file.read_exact_at(&mut slot_b, META_SLOT_LEN) + .context(IoSnafu { + path: path.to_path_buf(), + })?; + + let valid_a = page::verify_checksum(&slot_a) + .ok() + .and_then(|()| decode_meta_slot(&slot_a)); + let valid_b = page::verify_checksum(&slot_b) + .ok() + .and_then(|()| decode_meta_slot(&slot_b)); + + let (active_slot, content) = match (valid_a, valid_b) { + (Some(a), Some(b)) if b.txn_id > a.txn_id => (1u8, b), + (Some(a), Some(_) | None) => (0u8, a), + (None, Some(b)) => (1u8, b), + (None, None) => NoValidMetaPageSnafu { + path: path.to_path_buf(), + } + .fail()?, + }; + + Ok(Self { + file, + path: path.to_path_buf(), + page_size: content.page_size, + active_slot, + txn_id: content.txn_id, + root_page_id: content.root_page_id, + page_count: content.page_count, + }) + } + + pub(crate) fn page_size(&self) -> PageSize { + self.page_size + } + + pub(crate) fn root_page_id(&self) -> u32 { + self.root_page_id + } + + pub(crate) fn page_count(&self) -> u32 { + self.page_count + } + + /// Byte offset of data page `id` within the file. + fn file_offset(&self, id: u32) -> u64 { + let index = u64::from(id.saturating_sub(page::FIRST_DATA_PAGE_ID)); + META_REGION_LEN + index * u64::from(self.page_size.bytes()) + } + + /// Read data page `id`, verifying its checksum. + /// + /// # Errors + /// + /// Returns [`FatalError::Corruption`] if the checksum does not verify, + /// or [`FatalError::Io`] on any filesystem failure. + pub(crate) fn read_data_page(&self, id: u32) -> Result, PinaxError> { + let mut buf = vec![0u8; self.page_size.bytes_usize()]; + self.file + .read_exact_at(&mut buf, self.file_offset(id)) + .context(IoSnafu { + path: self.path.clone(), + })?; + page::verify_checksum(&buf).map_err(|(expected, actual)| PinaxError::Fatal { + source: FatalError::Corruption { + page_id: id, + expected, + actual, + location: snafu::Location::new(file!(), line!(), column!()), + }, + })?; + Ok(buf) + } + + /// Stamp `buf`'s checksum and write it to data page `id`. + /// + /// WHY no fsync per call: individual page writes durability is bounded + /// by `sync_data`/`commit`, not by every write; batching the fsync per + /// operation (rather than per page) is what makes group writes cheap. + pub(crate) fn write_data_page(&self, id: u32, buf: &mut [u8]) -> Result<(), PinaxError> { + stamp_checksum(buf)?; + self.file + .write_all_at(buf, self.file_offset(id)) + .context(IoSnafu { + path: self.path.clone(), + })?; + Ok(()) + } + + /// fsync data page writes. Called before a meta commit so the meta + /// page a crash could observe never outruns the pages it references. + pub(crate) fn sync_data(&self) -> Result<(), PinaxError> { + self.file.sync_data().context(IoSnafu { + path: self.path.clone(), + })?; + Ok(()) + } + + /// Commit a new tree state: fsync data, write the inactive meta slot + /// with an incremented `txn_id`, fsync again, then flip which slot is + /// active in memory. + /// + /// # Errors + /// + /// Returns [`FatalError::Io`] on any filesystem failure. A failure here + /// leaves the previously active slot as the durable state on reopen — + /// see the module docs. + pub(crate) fn commit(&mut self, new_root: u32, new_page_count: u32) -> Result<(), PinaxError> { + self.sync_data()?; + + let target_slot = 1 - self.active_slot; + let content = MetaContent { + page_size: self.page_size, + txn_id: self.txn_id + 1, + root_page_id: new_root, + page_count: new_page_count, + }; + let slot_buf = encode_meta_slot(&content)?; + let offset = u64::from(target_slot) * META_SLOT_LEN; + self.file.write_all_at(&slot_buf, offset).context(IoSnafu { + path: self.path.clone(), + })?; + self.file.sync_all().context(IoSnafu { + path: self.path.clone(), + })?; + + self.active_slot = target_slot; + self.txn_id += 1; + self.root_page_id = new_root; + self.page_count = new_page_count; + Ok(()) + } + + /// Verify a data page's `page_type` byte matches `expected`, returning + /// [`FatalError::UnexpectedPageType`] otherwise. + pub(crate) fn expect_page_type( + id: u32, + buf: &[u8], + expected_byte: u8, + expected_label: &'static str, + ) -> Result<(), PinaxError> { + let actual = crate::codec::read_u8(buf, 0)?; + ensure!( + actual == expected_byte, + UnexpectedPageTypeSnafu { + page_id: id, + expected: expected_label, + actual, + } + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_db_path(dir: &tempfile::TempDir, name: &str) -> PathBuf { + dir.path().join(name) + } + + #[test] + fn create_then_open_round_trips_empty_state() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + { + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + assert_eq!(pager.root_page_id(), page::EMPTY_TREE_ROOT); + assert_eq!(pager.page_count(), page::FIRST_DATA_PAGE_ID); + } + let reopened = Pager::open(&path).expect("open"); + assert_eq!(reopened.page_size().bytes(), 4096); + assert_eq!(reopened.root_page_id(), page::EMPTY_TREE_ROOT); + } + + #[test] + fn create_refuses_existing_file() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + Pager::create(&path, PageSize::DEFAULT).expect("first create"); + let err = Pager::create(&path, PageSize::DEFAULT).expect_err("second create must fail"); + assert!(matches!( + err, + PinaxError::Permanent { + source: crate::error::PermanentError::AlreadyExists { .. } + } + )); + } + + #[test] + fn open_missing_file_is_io_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "missing.pinax"); + let err = Pager::open(&path).expect_err("no such file"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::Io { .. } + } + )); + } + + #[test] + fn commit_persists_new_root_and_survives_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + { + let mut pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + pager + .commit(page::FIRST_DATA_PAGE_ID, page::FIRST_DATA_PAGE_ID + 1) + .expect("commit"); + assert_eq!(pager.root_page_id(), page::FIRST_DATA_PAGE_ID); + } + let reopened = Pager::open(&path).expect("reopen"); + assert_eq!(reopened.root_page_id(), page::FIRST_DATA_PAGE_ID); + assert_eq!(reopened.page_count(), page::FIRST_DATA_PAGE_ID + 1); + } + + #[test] + fn commit_alternates_meta_slots() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + let mut pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + assert_eq!(pager.active_slot, 0); + pager + .commit(page::EMPTY_TREE_ROOT, page::FIRST_DATA_PAGE_ID) + .expect("commit 1"); + assert_eq!(pager.active_slot, 1); + pager + .commit(page::EMPTY_TREE_ROOT, page::FIRST_DATA_PAGE_ID) + .expect("commit 2"); + assert_eq!(pager.active_slot, 0); + } + + #[test] + fn crash_before_commit_leaves_prior_state_on_reopen() { + // WHY this models a crash: writing a data page (as any in-flight + // operation does before it calls `commit`) without ever calling + // `commit` is exactly what a process crash mid-operation leaves + // behind — the meta slot on disk never advances past the prior + // txn_id. This is ROADMAP.md Phase 01's "survive crash-and-reopen" + // criterion at the pager layer (`database.rs` tests exercise it + // end-to-end through real B+tree mutations). + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + let pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let mut orphan = vec![0xAA_u8; pager.page_size().bytes_usize()]; + pager + .write_data_page(page::FIRST_DATA_PAGE_ID, &mut orphan) + .expect("write survives even without commit"); + drop(pager); // no commit() call: models an uncommitted crash + + let reopened = Pager::open(&path).expect("reopen after simulated crash"); + assert_eq!(reopened.root_page_id(), page::EMPTY_TREE_ROOT); + assert_eq!(reopened.page_count(), page::FIRST_DATA_PAGE_ID); + } + + #[test] + fn corrupted_data_page_fails_checksum_on_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + let mut pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + let mut buf = vec![0x11_u8; pager.page_size().bytes_usize()]; + pager + .write_data_page(page::FIRST_DATA_PAGE_ID, &mut buf) + .expect("write"); + pager + .commit(page::FIRST_DATA_PAGE_ID, page::FIRST_DATA_PAGE_ID + 1) + .expect("commit"); + + // Flip one byte directly on disk, bypassing the pager (simulates + // storage-level bit rot). + let offset = pager.file_offset(page::FIRST_DATA_PAGE_ID); + let mut byte = [0u8; 1]; + pager + .file + .read_exact_at(&mut byte, offset) + .expect("read byte"); + byte[0] ^= 0xFF; + pager.file.write_all_at(&byte, offset).expect("flip byte"); + + let err = pager + .read_data_page(page::FIRST_DATA_PAGE_ID) + .expect_err("checksum must catch the flipped byte"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::Corruption { .. } + } + )); + } + + #[test] + fn corrupted_active_meta_slot_falls_back_to_prior_slot() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + { + let mut pager = Pager::create(&path, PageSize::DEFAULT).expect("create"); + pager + .commit(page::FIRST_DATA_PAGE_ID, page::FIRST_DATA_PAGE_ID + 1) + .expect("commit txn 1, now active slot 1"); + } + // Corrupt slot 1 (the currently-active slot) directly. + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open for corruption"); + let mut byte = [0u8; 1]; + file.read_exact_at(&mut byte, META_SLOT_LEN) + .expect("read byte of slot 1"); + byte[0] ^= 0xFF; + file.write_all_at(&byte, META_SLOT_LEN) + .expect("flip byte in slot 1"); + + let reopened = Pager::open(&path).expect("falls back to slot 0"); + assert_eq!(reopened.root_page_id(), page::EMPTY_TREE_ROOT); + assert_eq!(reopened.txn_id, 0); + } + + #[test] + fn both_meta_slots_corrupted_is_fatal() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = temp_db_path(&dir, "db.pinax"); + Pager::create(&path, PageSize::DEFAULT).expect("create"); + let file = OpenOptions::new() + .write(true) + .open(&path) + .expect("open for corruption"); + let zeros = vec![0u8; usize::try_from(META_REGION_LEN).unwrap_or(8192)]; + file.write_all_at(&zeros, 0) + .expect("zero the whole meta region"); + + let err = Pager::open(&path).expect_err("neither slot verifies"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::NoValidMetaPage { .. } + } + )); + } + + #[test] + fn expect_page_type_accepts_match_and_rejects_mismatch() { + let mut buf = vec![0u8; 16]; + crate::codec::write_u8(&mut buf, 0, page::PAGE_TYPE_LEAF).expect("in bounds"); + Pager::expect_page_type(2, &buf, page::PAGE_TYPE_LEAF, "leaf").expect("matches"); + let err = Pager::expect_page_type(2, &buf, page::PAGE_TYPE_INTERIOR, "interior") + .expect_err("byte is leaf, not interior"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::UnexpectedPageType { .. } + } + )); + } +} diff --git a/crates/pinax/src/row.rs b/crates/pinax/src/row.rs new file mode 100644 index 0000000..4bd7e78 --- /dev/null +++ b/crates/pinax/src/row.rs @@ -0,0 +1,291 @@ +//! Row encoding: `lexis::Value` tuples to and from the byte payload a +//! B+tree leaf cell carries. +//! +//! WHY `lexis::Value` and not opaque bytes: `lexis`'s own exit criteria +//! (`crates/lexis/Cargo.toml`) names this directly — "Phase 1 pager adopts +//! `lexis::Value` for on-disk row encoding". The full typed-row storage +//! format (per-column type checking against a `TableDef`, `NULL` bitmaps +//! keyed by schema) is Phase 4/5 territory once `CREATE TABLE` exists; this +//! module owns only the byte-level encode/decode of a self-describing +//! value tuple, which is what a schema-less Phase 01 B+tree can use. +//! +//! Format (fixed-width, not SQLite's varints): a `u32` BE column count, +//! then each value as a 1-byte type tag followed by a type-specific +//! payload. WHY fixed-width: Decision 1 says pinax "owns its on-disk +//! encoding" rather than targeting SQLite format compatibility, and +//! fixed-width fields keep this codec's bounds-checked-read discipline +//! simple — a varint reader adds a variable-length-decode loop for no +//! benefit Phase 01 needs. +//! +//! WHY `Row::encode` builds into a growing `Vec` via `extend_from_slice` +//! rather than routing through `codec`'s bounds-checked writers: those +//! writers exist to make a WRITE INTO A FIXED-SIZE PAGE BUFFER fail +//! typed instead of panicking on an out-of-range offset. Appending to a +//! `Vec` has no such offset — `extend_from_slice` cannot go out of bounds — +//! so the bounds-checking discipline has nothing to add on the write side +//! here. The read side still goes through `codec`, because decoding reads +//! at caller-supplied offsets into a buffer whose length is not statically +//! known to be sufficient. + +use lexis::{DateTimeValue, RealValue, Value}; + +use crate::codec::{read_i64, read_u8, read_u32, read_u64, read_vec}; +use crate::error::{FatalError, PermanentError, PinaxError}; + +const TAG_NULL: u8 = 0; +const TAG_INTEGER: u8 = 1; +const TAG_REAL: u8 = 2; +const TAG_TEXT: u8 = 3; +const TAG_BLOB: u8 = 4; +const TAG_BOOLEAN: u8 = 5; +const TAG_DATETIME: u8 = 6; + +/// A B+tree row: an ordered tuple of [`lexis::Value`], keyed externally by +/// the B+tree's `i64` key (Decision 5's typed values, Decision 1's +/// integer-keyed B+tree). +#[derive(Debug, Clone, PartialEq)] +pub struct Row(Vec); + +impl Row { + /// Wrap a value tuple as a [`Row`]. + #[must_use] + pub fn new(values: Vec) -> Self { + Self(values) + } + + /// The row's values in column order. + #[must_use] + pub fn values(&self) -> &[Value] { + &self.0 + } + + /// Encode this row to its on-disk byte payload. + /// + /// # Errors + /// + /// Returns [`PermanentError::PayloadTooLarge`] only in the + /// astronomically unlikely case a `TEXT`/`BLOB` value's byte length, or + /// the row's own column count, does not fit in a `u32`; `key` is + /// carried through to name the offending row in that error. + pub(crate) fn encode(&self, key: i64) -> Result, PinaxError> { + let mut buf = Vec::new(); + let count = too_large_as_u32(self.0.len(), key)?; + buf.extend_from_slice(&count.to_be_bytes()); + for value in &self.0 { + encode_value(&mut buf, value, key)?; + } + Ok(buf) + } + + /// Decode a row from bytes produced by [`Self::encode`]. + /// + /// # Errors + /// + /// Returns [`FatalError::InvalidRowEncoding`] if `buf` does not decode + /// as a value of this module's on-disk format — see that variant's + /// docs for why this indicates a deeper problem than bad input. + pub(crate) fn decode(buf: &[u8]) -> Result { + let mut at = 0usize; + let count = read_u32(buf, at)?; + at += 4; + let mut values = Vec::new(); + for _ in 0..count { + let (value, next) = decode_value(buf, at)?; + values.push(value); + at = next; + } + Ok(Self(values)) + } +} + +fn too_large_as_u32(len: usize, key: i64) -> Result { + u32::try_from(len).map_err(|_source| PinaxError::Permanent { + source: PermanentError::PayloadTooLarge { + key, + encoded_len: len, + location: snafu::Location::new(file!(), line!(), column!()), + }, + }) +} + +fn invalid_encoding(reason: &'static str) -> PinaxError { + PinaxError::Fatal { + source: FatalError::InvalidRowEncoding { + reason, + location: snafu::Location::new(file!(), line!(), column!()), + }, + } +} + +fn encode_value(buf: &mut Vec, value: &Value, key: i64) -> Result<(), PinaxError> { + match value { + Value::Null => buf.push(TAG_NULL), + Value::Integer(v) => { + buf.push(TAG_INTEGER); + buf.extend_from_slice(&v.to_be_bytes()); + } + Value::Real(v) => { + buf.push(TAG_REAL); + buf.extend_from_slice(&v.get().to_bits().to_be_bytes()); + } + Value::Text(s) => { + buf.push(TAG_TEXT); + push_len_prefixed(buf, s.as_bytes(), key)?; + } + Value::Blob(b) => { + buf.push(TAG_BLOB); + push_len_prefixed(buf, b, key)?; + } + Value::Boolean(v) => { + buf.push(TAG_BOOLEAN); + buf.push(u8::from(*v)); + } + Value::Datetime(v) => { + buf.push(TAG_DATETIME); + buf.extend_from_slice(&v.get().to_be_bytes()); + } + // WHY a wildcard at all: lexis::Value is #[non_exhaustive] (Decision + // 5 reserves room for later value types), so this cross-crate match + // must carry one even though every variant lexis currently defines + // is handled above. + _ => unreachable!("lexis::Value gained a variant Row::encode does not handle"), // INVARIANT: every lexis::Value variant defined today is matched above; this arm exists only to satisfy #[non_exhaustive] + } + Ok(()) +} + +fn push_len_prefixed(buf: &mut Vec, bytes: &[u8], key: i64) -> Result<(), PinaxError> { + let len = too_large_as_u32(bytes.len(), key)?; + buf.extend_from_slice(&len.to_be_bytes()); + buf.extend_from_slice(bytes); + Ok(()) +} + +fn decode_value(buf: &[u8], at: usize) -> Result<(Value, usize), PinaxError> { + let tag = read_u8(buf, at)?; + let mut at = at + 1; + let value = match tag { + TAG_NULL => Value::Null, + TAG_INTEGER => { + let v = read_i64(buf, at)?; + at += 8; + Value::Integer(v) + } + TAG_REAL => { + let bits = read_u64(buf, at)?; + at += 8; + let real = RealValue::try_from(f64::from_bits(bits)) + .map_err(|_source| invalid_encoding("REAL payload decoded to NaN"))?; + Value::Real(real) + } + TAG_TEXT => { + let (bytes, next) = read_len_prefixed(buf, at)?; + at = next; + let text = String::from_utf8(bytes) + .map_err(|_source| invalid_encoding("TEXT payload was not valid UTF-8"))?; + Value::Text(text) + } + TAG_BLOB => { + let (bytes, next) = read_len_prefixed(buf, at)?; + at = next; + Value::Blob(bytes) + } + TAG_BOOLEAN => { + let raw = read_u8(buf, at)?; + at += 1; + Value::Boolean(raw != 0) + } + TAG_DATETIME => { + let v = read_i64(buf, at)?; + at += 8; + Value::Datetime(DateTimeValue::from(v)) + } + _ => return Err(invalid_encoding("unrecognized value type tag")), + }; + Ok((value, at)) +} + +fn read_len_prefixed(buf: &[u8], at: usize) -> Result<(Vec, usize), PinaxError> { + let len = read_u32(buf, at)?; + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + let start = at + 4; + let bytes = read_vec(buf, start, len_usize)?; + Ok((bytes, start + len_usize)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_every_type() { + let row = Row::new(vec![ + Value::Null, + Value::Integer(-7), + Value::Real(RealValue::try_from(3.5).expect("finite")), + Value::Text("hello".to_owned()), + Value::Blob(vec![1, 2, 3]), + Value::Boolean(true), + Value::Datetime(DateTimeValue::from(42)), + ]); + let encoded = row.encode(1).expect("encodes"); + let decoded = Row::decode(&encoded).expect("decodes"); + assert_eq!(row, decoded); + } + + #[test] + fn round_trips_empty_row() { + let row = Row::new(vec![]); + let encoded = row.encode(1).expect("encodes"); + let decoded = Row::decode(&encoded).expect("decodes"); + assert_eq!(row, decoded); + } + + #[test] + fn round_trips_empty_text_and_blob() { + let row = Row::new(vec![Value::Text(String::new()), Value::Blob(vec![])]); + let encoded = row.encode(1).expect("encodes"); + let decoded = Row::decode(&encoded).expect("decodes"); + assert_eq!(row, decoded); + } + + #[test] + fn round_trips_large_text() { + let text = "x".repeat(10_000); + let row = Row::new(vec![Value::Text(text.clone())]); + let encoded = row.encode(1).expect("encodes"); + let decoded = Row::decode(&encoded).expect("decodes"); + assert_eq!(decoded.values(), &[Value::Text(text)]); + } + + #[test] + fn decode_rejects_unrecognized_tag() { + let buf = [0u8, 0, 0, 1, 0xFF]; + let err = Row::decode(&buf).expect_err("tag 0xFF is not a known type"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::InvalidRowEncoding { .. } + } + )); + } + + #[test] + fn decode_rejects_invalid_utf8() { + let mut buf = vec![0, 0, 0, 1, TAG_TEXT]; + buf.extend_from_slice(&2u32.to_be_bytes()); + buf.extend_from_slice(&[0xFF, 0xFE]); + let err = Row::decode(&buf).expect_err("0xFF 0xFE is not valid UTF-8"); + assert!(matches!( + err, + PinaxError::Fatal { + source: FatalError::InvalidRowEncoding { .. } + } + )); + } + + #[test] + fn values_accessor_matches_constructor() { + let row = Row::new(vec![Value::Integer(1)]); + assert_eq!(row.values(), &[Value::Integer(1)]); + } +} diff --git a/crates/pinax/tests/phase01_acceptance.rs b/crates/pinax/tests/phase01_acceptance.rs new file mode 100644 index 0000000..440d704 --- /dev/null +++ b/crates/pinax/tests/phase01_acceptance.rs @@ -0,0 +1,200 @@ +//! Phase 01 acceptance tests, one per ROADMAP.md criterion, verbatim: +//! +//! - "Open a file, CRUD rows by integer key, survive crash-and-reopen" +//! - "Page format checksums verified; corruption detected" +//! - "Buffer pool handles databases larger than RAM" +//! +//! WHY a dedicated integration-test file rather than folding these into +//! each module's colocated unit tests: the conformance bar's default is +//! colocated `#[cfg(test)] mod tests` (and every module here has that, for +//! its own internal behavior) — this file exists so the five acceptance +//! criteria stay traceable as a named, standalone set rather than +//! scattered evidence a reader has to reassemble from module-level tests. + +// WHY `expect`/`expect_err` throughout rather than `?`: this file is a +// separate crate root (every file under `tests/` is), so it does not +// inherit `lib.rs`'s `#![cfg_attr(test, allow(clippy::unwrap_used, +// clippy::expect_used))]` escape that covers colocated `#[cfg(test)] mod +// tests` blocks. The workspace `[workspace.lints.clippy]` already sets +// `expect_used` to "warn", not "deny", specifically because "tests +// legitimately use these" (`Cargo.toml`) — this crate-level `expect` +// restates that same intent at the one scope the gate's `-D warnings` +// cannot see through. `unwrap_used` is not listed: this file has no bare +// `.unwrap()` call, and an expectation nothing fires against is itself a +// gate error (`unfulfilled_lint_expectations`). +#![expect( + clippy::expect_used, + reason = "acceptance tests use expect/expect_err as the intended failure mode, matching every other test surface in this workspace" +)] + +use std::fs::OpenOptions; +use std::os::unix::fs::FileExt as _; + +use lexis::Value; +use pinax::{Database, PageSize, PinaxError}; + +fn sample_row(n: i64) -> pinax::Row { + pinax::Row::new(vec![ + Value::Integer(n), + Value::Text(format!("acceptance-row-{n}")), + ]) +} + +/// ROADMAP.md Phase 01: "Open a file, CRUD rows by integer key". +#[test] +fn open_a_file_and_crud_rows_by_integer_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("crud.pinax"); + + // Open (create) a file. + let mut db = Database::create(&path, PageSize::DEFAULT).expect("open a file"); + + // Create. + db.insert(1, &sample_row(1)).expect("create row 1"); + db.insert(2, &sample_row(2)).expect("create row 2"); + + // Read. + assert_eq!(db.get(1).expect("read row 1"), Some(sample_row(1))); + assert_eq!(db.get(2).expect("read row 2"), Some(sample_row(2))); + assert_eq!(db.get(3).expect("read missing row"), None); + + // Update. + db.update(1, &sample_row(100)).expect("update row 1"); + assert_eq!( + db.get(1).expect("read updated row 1"), + Some(sample_row(100)) + ); + + // Delete. + let deleted = db.delete(2).expect("delete row 2"); + assert_eq!(deleted, sample_row(2)); + assert_eq!(db.get(2).expect("read deleted row"), None); + + // Row 1 (updated, not deleted) is still there. + assert_eq!(db.get(1).expect("read row 1 again"), Some(sample_row(100))); +} + +/// ROADMAP.md Phase 01: "survive crash-and-reopen". +/// +/// Simulates a crash by writing committed data, then dropping the +/// `Database` WITHOUT any explicit close/shutdown call (Rust has none to +/// skip — a `Database` binding being dropped at the end of its lexical +/// lifetime, with no flush step beyond what each committed operation +/// already durably wrote, IS the crash model: the process simply stops). +/// Every already-committed insert must still be there on reopen; nothing +/// partial from an interrupted operation should surface, because no +/// operation was left in flight. +#[test] +fn survives_crash_and_reopen() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("crash.pinax"); + + { + let mut db = Database::create(&path, PageSize::DEFAULT).expect("create"); + for i in 0..50i64 { + db.insert(i, &sample_row(i)).expect("insert before crash"); + } + // No explicit close/shutdown: dropping `db` here models the crash. + // Every insert above already committed (each `Database::insert` + // call is its own auto-committed, fsynced transaction — see + // `pager` module docs), so nothing here is left in flight. + } + + let mut reopened = Database::open(&path).expect("reopen after simulated crash"); + for i in 0..50i64 { + assert_eq!( + reopened.get(i).expect("read after reopen"), + Some(sample_row(i)), + "row {i} must survive crash-and-reopen" + ); + } +} + +/// ROADMAP.md Phase 01: "page format checksums verified; corruption +/// detected". The negative-case fixture flips a byte in a page written to +/// disk and asserts the read path detects it — an intact-only test suite +/// would prove nothing about detection, only about the happy path. +#[test] +fn corruption_is_detected_via_checksum() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("corrupt.pinax"); + + { + let mut db = Database::create(&path, PageSize::DEFAULT).expect("create"); + db.insert(1, &sample_row(1)).expect("insert"); + } + + // Flip one byte inside the first data page's region on disk, bypassing + // pinax entirely (models storage-level bit rot, not an API misuse). + let file = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .expect("open db file directly"); + let offset = 8192; // meta region (2 * 4096) ends here; first data page begins here. + let mut byte = [0u8; 1]; + file.read_exact_at(&mut byte, offset) + .expect("read a byte of the data page"); + byte[0] ^= 0xFF; + file.write_all_at(&byte, offset).expect("flip the byte"); + drop(file); + + let mut db = Database::open(&path).expect("meta pages are untouched, so open still succeeds"); + let err = db.get(1).expect_err("checksum must catch the flipped byte"); + assert!( + matches!( + err, + PinaxError::Fatal { + source: pinax::FatalError::Corruption { .. } + } + ), + "expected a Corruption error, got {err:?}" + ); +} + +/// ROADMAP.md Phase 01: "buffer pool handles databases larger than RAM". +/// +/// "RAM" is modeled by the buffer pool's page capacity: a capacity of 8 +/// pages caps resident memory at 8 * page_size regardless of how large the +/// on-disk B+tree grows, exactly the property a real buffer pool provides +/// against physical RAM. Inserting enough rows to produce a tree spanning +/// many times that capacity, then reading every row back correctly, proves +/// the pool's evict-and-reload path preserves data across pages the +/// working set could never hold all at once. +#[test] +fn buffer_pool_handles_a_database_larger_than_its_capacity() { + const CAPACITY_PAGES: usize = 8; + const ROW_COUNT: i64 = 4000; + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("larger-than-ram.pinax"); + + { + let mut db = Database::create_with_capacity(&path, PageSize::DEFAULT, CAPACITY_PAGES) + .expect("create with a deliberately small buffer pool"); + for i in 0..ROW_COUNT { + db.insert(i, &sample_row(i)) + .expect("insert under a small buffer pool"); + } + } + + let capacity_bytes = + u64::try_from(CAPACITY_PAGES).unwrap_or(0) * u64::from(PageSize::DEFAULT.bytes()); + let on_disk_bytes = std::fs::metadata(&path).expect("stat db file").len(); + assert!( + on_disk_bytes > capacity_bytes * 4, + "expected the on-disk database ({on_disk_bytes} bytes) to be several times \ + the buffer pool's capacity ({capacity_bytes} bytes) — otherwise this test \ + does not actually exercise eviction" + ); + + let mut db = Database::open_with_capacity(&path, CAPACITY_PAGES) + .expect("reopen with the same small buffer pool"); + for i in 0..ROW_COUNT { + assert_eq!( + db.get(i).expect("read back under a small buffer pool"), + Some(sample_row(i)), + "row {i} must survive eviction-and-reload cycles" + ); + } +} diff --git a/deny.toml b/deny.toml index 181b516..9d7a07a 100644 --- a/deny.toml +++ b/deny.toml @@ -24,6 +24,13 @@ allow = [ "Unicode-DFS-2016", "CC0-1.0", "Zlib", + # WHY: xxhash-rust (Decision 2's page checksum algorithm, PLAN.md) is + # BSL-1.0 -- OSI-approved and FSF Free/Libre per cargo-deny's own + # license database, permissive in the same class as MIT/Apache-2.0, + # just not one of the handful the heurema-derived template above + # enumerated. Not adding this let `cargo deny check licenses` reject + # pinax's own load-bearing checksum dependency. + "BSL-1.0", # WHY: pinax's own declared workspace licence (Cargo.toml # `license = "LicenseRef-PolyForm-Shield-1.0.0"`). heurema is MPL-2.0 # and never needed this entry; kanon's deny.toml carries the same