From 657c289bc241d6780e897f8ea35169c6c125703d Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 07:55:07 +0530 Subject: [PATCH 1/9] docs(v8): detailed plan for PR 3, covering keys and dictionaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records two deviations from the spec, to be amended in the same PR: ids, value refs and keys are assigned at checkpoint rather than at transact, and tx↓ is the complemented tx_count encoding rather than u64::MAX − tx_count. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- .../plans/2026-10-05-v8-storage-format.md | 181 +++++++++++++++++- 1 file changed, 172 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/plans/2026-10-05-v8-storage-format.md b/docs/superpowers/plans/2026-10-05-v8-storage-format.md index cd05aa37c..0eaaf6ea4 100644 --- a/docs/superpowers/plans/2026-10-05-v8-storage-format.md +++ b/docs/superpowers/plans/2026-10-05-v8-storage-format.md @@ -366,17 +366,180 @@ stays O(N). PR 4 makes it O(change). - [ ] Open the PR into `v3` with `Refs #374 #388 #434`. Own CI until it is green. Ask the user before merging. -# PR 3 — Covering keys and dictionaries (outline) +# PR 3 — Covering keys and dictionaries -Spec §4.3, §5, §6, §9. The format freezes after this PR. +Spec §4.3, §5, §6, §9. Branch `feat/v8-pr3-covering-keys`. The format freezes after +this PR. -- Key codec module: FoundationDB-style integers, value tags, string escaping, long-value prefix + hash + ref, FOREVER byte, `tx↓`. Property tests: memcmp order equals logical order. -- Node format: prefix-compressed leaves with restart points; internal nodes with shortest separators; generic `(key bytes, value bytes)` entries. `LeafCursor` moves to byte keys. -- DICT tree with tags 0x01–0x06. Id assignment at transact time in WAL order (`FactStorage` pending index keyed by encoded keys). `next_eid`/`next_iid` in the meta page. -- Value pages with dedup. `MAX_VALUE_BYTES` replaces `MAX_FACT_BYTES`. -- Covering reads: `CommittedIndexReader` returns facts, translating ids through cached DICT lookups. Delete `FactRef`, fact pages, `CommittedFactReader`. -- Retarget PR 2's v7 migration to covering keys, DICT and value pages; keep its crash tests. -- Audit of result-order assumptions in tests and bindings. +**Outcome:** the four index trees hold whole facts as byte-comparable keys, with +sequential entity ids and ident ids from a DICT tree, and long strings in deduplicated +value pages. Fact pages (0x41), `FactRef` and `CommittedFactReader` are gone. `save()` +is still a full rewrite (O(N)); PR 4 makes it O(change). + +**Shape decisions (from reading the v3 code):** + +- **The query layer reads committed data through four paths only:** every fact + (`get_all_facts`), by entity, by entity and attribute (EAVT), and by attribute (AEVT). + AVET and VAET range scans are never called outside tests, and the matcher's + index-hint lookup only checks for emptiness before scanning every fact either way. + `CommittedFactReader` and `CommittedIndexReader` are therefore replaced by one + `CommittedReader` trait with those four methods, returning `Vec`. AVET and VAET + are still written (spec §5.2, #373 `rebuild_indexes`, #432) and checked in tests. +- **Ids, value refs and keys are assigned at checkpoint, not at transact (deviation from + spec §5.1).** A long value's ref exists only once its value page is written, which + happens at checkpoint, so a key containing it cannot be built earlier. Ids are never + persisted before the commit that also writes the dictionary entries and keys using + them, so assigning them at checkpoint, in pending order (which is WAL order), is + equally deterministic and crash-safe. The transact path does no dictionary I/O. The + pending side stays logical: `Vec` plus `BTreeMap`s over the existing + UUID/string keys, mapping to `usize` positions. #432 merges pending and committed + streams at scan time. Spec §5.1 is amended. +- **`tx↓` is the byte complement of the encoding of `tx_count` (deviation from spec + §6.1).** The integer encoding is prefix-free, so complementing every byte exactly + reverses the order, and a typical `tx_count` costs 2–4 bytes instead of the 9 bytes + of `u64::MAX − tx_count`. Spec §6.1 is amended. +- **DICT 0x06 key is `tag ‖ hash64 ‖ value ref`, with an empty value,** so several + long values can share a hash. Dedup seeks to `tag ‖ hash64` and compares each + candidate's full value. `hash64` is FNV-1a 64: stable, no dependency. A collision + only costs an extra comparison, never a wrong match. +- **DICT 0x03 keys hold the raw ident bytes.** Nothing follows them in the key, so they + need no escaping or terminator. +- **Idents are capped at `MAX_IDENT_BYTES = 1024`.** The parser already caps keywords + at 1024 B. The cap is enforced at WAL write for every path, so a DICT entry always + fits in a node. +- **Size limit:** strings are capped at `MAX_VALUE_BYTES = PAGE_SIZE − 24 − 4 = 4068`. + This replaces `MAX_FACT_BYTES` (crate-private, so not a public API change). WAL-003 + keeps its code; its message now names the value. An index key is at most about + 200 B. +- **`tx_count → tx_id` (DICT 0x05).** Every commit path stamps one `tx_id` per + `tx_count`, and the v1→v2 migration grouped facts by `tx_id`. A checkpoint or + migration that finds two different `tx_id`s for one `tx_count` fails with a new STG + code rather than altering a timestamp. +- **Node format (§4.3).** Leaves hold prefix-compressed entries, + `varint shared ‖ varint suffix_len ‖ suffix ‖ varint value_len ‖ value`, with a + restart point every 16 entries and a `u16` restart array at the end of the page. + Internal nodes hold `rightmost_child u64` at offset 24, then + `varint sep_len ‖ sep ‖ child u64` entries with a `u16` slot array at the end. The + separator between two subtrees is the shortest prefix of the right subtree's first + key that is greater than the left subtree's last key. +- **btree_v6.rs becomes btree.rs** with byte keys: `build_btree`, + `rebuild_btree_incremental` (copy untouched leaves, restamped), `LeafCursor` (with + `seek`), `collect_leaf_pages`. Entries are `(Vec, Vec)`; the index trees use + empty values. +- **Committed reads translate through a per-call `Dict` reader** that memoises + eid→UUID, iid→name, tx_count→tx_id and value-ref→string lookups. Memory is bounded by + one call's results, not by the dictionary. +- **Migration** reuses the checkpoint pipeline from an empty dictionary and empty + trees, with facts sorted stably by `tx_count` (§9 step 2). Value pages and trees + append from the old `page_count`. The free list, backup meta and crash handling stay + as in PR 2. +- **Committed scan order changes** to eid/iid order (§5.3). Tests that compare lists + sort first; §11's order audit covers the bindings. + +## Review Focus + +- memcmp order equals logical order for every component and value type. +- The leaf and internal codecs reject any page overflow, and the separators satisfy + `max(left) < s ≤ min(right)`. +- Every dictionary id is assigned exactly once and never reused, across checkpoints and + migration. +- Committed reads return exactly the facts that were written (property test against + the pending model). + +### Task 1: Key codec — `src/storage/keys.rs` + +- [ ] FDB integers: `encode_uint`/`encode_int`/`decode_*`; FOREVER byte for `vt`; + `tx↓` as the complemented `tx_count` encoding. +- [ ] Value encoding (§6.2): tags in cross-type order; short strings escaped with + `00 FF` and terminated by `00 00`; long strings as escaped 32-byte prefix, + `00 01`, `hash64`, value ref (`page u64 BE`, `slot u16 BE`); keyword = iid; ref = + eid; float transform with NaN canonicalised. +- [ ] Index key builders and decoders for EAVT/AEVT/AVET/VAET over an `EncodedFact` + (`e, a, v, tx_count, vf, vt, op`). DICT key builders for tags 0x01–0x06. +- [ ] `hash64` (FNV-1a), `MAX_VALUE_BYTES`, `MAX_IDENT_BYTES`. +- [ ] Tests: random tuples, sorted by encoding versus by logical order, for every type, + negative/zero/large integers, NaN and ±0.0, strings containing 0x00, strings of 64 + and 65 bytes, FOREVER. Round trip of every component. Complemented `tx` reverses + order. + +### Task 2: Node codecs + +- [ ] Leaf encode/decode with prefix compression and restarts. `lookup` uses binary + search over the restart points, then a scan of at most 16 entries. +- [ ] Internal encode/decode with the slot array; `route(key)`. +- [ ] `shortest_separator(left_last, right_first)`. +- [ ] Tests: random keys round-trip through a leaf; lookup agrees with a linear scan; + separators satisfy the bound; an overflow is INT-049. + +### Task 3: `btree.rs` on byte keys + +- [ ] Port `build_btree`, `build_internal_levels` (infos carry first and last key), + `rebuild_btree_incremental`, `collect_leaf_pages`, `LeafCursor` (`next`, `seek`) and + `range_scan` to `(Vec, Vec)` entries. Equal keys merge to one entry. +- [ ] Port the existing tree and cursor tests (incremental equivalence, splits, + repeated rounds, seek against a fresh descent, page-read counting, cycles, + corruption). + +### Task 4: Value pages — `src/storage/value_pages.rs` + +- [ ] 0x51 page: records end-to-start with a `(offset u16, len u16)` directory. + `ValueWriter` appends pages through the allocator (`alloc_append`); each checkpoint + starts a fresh page (§13). `read_value(ref)`. +- [ ] Tests: round trip; a value of exactly `MAX_VALUE_BYTES`; a bad slot is an error. + +### Task 5: Dictionary — `src/storage/dict.rs` + +- [ ] `Dict` reader over the DICT root: `eid_of(uuid)`, `uuid_of(eid)`, + `iid_of(name)`, `name_of(iid)`, `tx_id_of(tx_count)`, `long_value_refs(hash)`, each + memoised per instance. +- [ ] `DictWriter`: assigns new eids/iids from the meta's counters, records new tx + entries (STG conflict check), deduplicates long values against committed and new + ones, and emits the sorted new DICT entries. +- [ ] Tests: assignment is deterministic and never reuses an id; a keyword used as an + attribute and as a value shares one iid; dedup of a re-asserted and retracted long + value adds no value page; a forced hash collision still finds the right value. + +### Task 6: Encode pipeline and save + +- [ ] `encode_facts(facts, dict_writer, value_writer) -> [Vec; 4]` and the DICT + entries; `save()` and `migrate_v7` use it, then `rebuild_btree_incremental` on all + five trees. The meta gets `dict_root`, `next_eid` and `next_iid`. +- [ ] Delete fact pages from v8: `append_fact_pages`, `pack_facts` for v8 and type 0x41 + (stays reserved). `packed_pages.rs` keeps only the v7 reader. +- [ ] The space-accounting and index-exactness test helpers walk DICT and value refs. + +### Task 7: Covering reads + +- [ ] `CommittedReader` trait (`all_facts`, `facts_for_entity`, + `facts_for_entity_attribute`, `facts_for_attribute`), implemented by + `OnDiskIndexReader` with EAVT/AEVT cursors and a `Dict`. An unknown UUID or attribute + means no committed facts. +- [ ] `FactStorage`: one `committed` reader; pending maps hold `usize` positions; only + EAVT and AEVT pending maps remain. Delete `FactRef`, `CommittedFactReader`, + `resolve_fact_ref`, and the matcher's and optimizer's unused index plumbing. +- [ ] Tests: a counting backend shows no value-page read for facts without long + values; a property test compares committed reads against the in-memory model across + checkpoints. + +### Task 8: Size limits and WAL + +- [ ] WAL write checks `MAX_VALUE_BYTES` for strings and `MAX_IDENT_BYTES` for + attributes and keywords (WAL-003). Update `edge_cases_test`, `multi_value_test` and + `error_codes_wal_test` boundaries. + +### Task 9: Migration retarget and crash tests + +- [ ] `migrate_v7` through the pipeline. Keep the PR 2 migration crash tests; add v7 + fixtures with long strings, keywords, refs and retractions, compared as sets. + +### Task 10: Order audit, docs, PR + +- [ ] Run the whole suite; fix order-dependent tests by sorting. Grep the bindings + (`minigraf-*` repos are separate; record any order assumption found for the cascade). +- [ ] Docs: spec amendments (§5.1, §6.1, §6.2 DICT 0x03/0x06), CHANGELOG (result order, + size limits), CLAUDE.md File Format and module list, ERROR_REFERENCE, TEST_COVERAGE. +- [ ] `cargo fmt`, clippy, `cargo test`; open the PR into `v3` with + `Refs #433 #374 #434 #388`. Own CI until green. Ask before merging. # PR 4 — Copy-on-write insert, allocator, free list (outline) From 7c150c271ec05a617c783bae15dfbe86848a4dc6 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 07:58:58 +0530 Subject: [PATCH 2/9] feat(storage): byte-comparable key codec for v8 covering indexes (#433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FoundationDB-style integers, value tags in cross-type order, escaped short strings, long strings as prefix + hash + value ref, the FOREVER byte, and tx↓ as the complemented tx_count encoding. EAVT/AEVT/AVET/VAET key builders and decoders, and DICT keys for tags 0x01-0x06. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- src/storage/keys.rs | 1023 +++++++++++++++++++++++++++++++++++++++++++ src/storage/mod.rs | 1 + 2 files changed, 1024 insertions(+) create mode 100644 src/storage/keys.rs diff --git a/src/storage/keys.rs b/src/storage/keys.rs new file mode 100644 index 000000000..0b13f6be2 --- /dev/null +++ b/src/storage/keys.rs @@ -0,0 +1,1023 @@ +//! Byte-comparable keys (spec §6). +//! +//! Every component is encoded so that comparing whole keys with memcmp gives +//! the logical order, and every encoding is prefix-free (its own bytes say where +//! it ends). Concatenating components therefore keeps the order of the tuple, +//! and keys are compared without decoding. +//! +//! Index keys (§5.2): +//! +//! | tree | key | +//! |---|---| +//! | EAVT | `e a v tx↓ vf vt op` | +//! | AEVT | `a e v tx↓ vf vt op` | +//! | AVET | `a v e tx↓ vf vt op` | +//! | VAET | `v a e tx↓ vf vt op` (`v` is the target eid; refs only) | +//! +//! DICT keys start with a tag byte (§5.2): 0x01 UUID → eid, 0x02 eid → UUID, +//! 0x03 ident → iid, 0x04 iid → ident, 0x05 tx_count → tx_id, 0x06 long-value +//! hash and ref (dedup). + +use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::graph::types::VALID_TIME_FOREVER; +use crate::storage::PAGE_SIZE; +use crate::storage::page::PAGE_HEADER_SIZE; +use anyhow::Result; +use uuid::Uuid; + +/// Longest string value, in bytes: one value page's payload (§6.3). +pub const MAX_VALUE_BYTES: usize = PAGE_SIZE - PAGE_HEADER_SIZE - 4; +/// Longest attribute name or keyword value, in bytes. The parser already caps +/// keywords at this length; it bounds DICT entries well inside a node. +pub const MAX_IDENT_BYTES: usize = 1024; +/// Strings up to this many bytes are stored inline in keys; longer ones go to +/// value pages (§6.2). +pub const SHORT_STRING_MAX: usize = 64; +/// Bytes of a long string kept in its key, before the marker and hash. +const LONG_PREFIX: usize = 32; + +// ─── Integers (§6.1) ───────────────────────────────────────────────────────── + +/// Type byte of zero. Positive values use `INT_ZERO + n` and negative values +/// `INT_ZERO - n`, where `n` is the length of the big-endian magnitude. +const INT_ZERO: u8 = 0x14; +/// `vt == VALID_TIME_FOREVER`: one byte that sorts after every finite time. +const FOREVER: u8 = 0xFF; + +/// Minimal big-endian bytes of `m` (empty for 0). +fn magnitude_bytes(m: u64) -> ([u8; 8], usize) { + let bytes = m.to_be_bytes(); + let skip = usize::try_from(m.leading_zeros() / 8).unwrap_or(8); + (bytes, skip) +} + +/// Append `v`, unsigned: type byte `0x14 + n`, then `n` big-endian bytes. +pub fn put_uint(out: &mut Vec, v: u64) { + let (bytes, skip) = magnitude_bytes(v); + let tail = bytes.get(skip..).unwrap_or(&[]); + out.push(INT_ZERO + u8::try_from(tail.len()).unwrap_or(8)); + out.extend_from_slice(tail); +} + +/// Append `v`, signed. Negative values store the one's complement of their +/// magnitude, so a larger magnitude sorts first. +pub fn put_int(out: &mut Vec, v: i64) { + if v >= 0 { + put_uint(out, v.unsigned_abs()); + return; + } + let m = v.unsigned_abs(); + let (_, skip) = magnitude_bytes(m); + let comp = (!m).to_be_bytes(); + let tail = comp.get(skip..).unwrap_or(&[]); + out.push(INT_ZERO - u8::try_from(tail.len()).unwrap_or(8)); + out.extend_from_slice(tail); +} + +/// A cursor over encoded bytes. Every read checks bounds and fails with +/// INT-049: a malformed key inside a verified page is an invariant violation. +pub struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Reader { buf, pos: 0 } + } + + pub fn is_empty(&self) -> bool { + self.pos >= self.buf.len() + } + + pub fn rest(&self) -> &'a [u8] { + self.buf.get(self.pos..).unwrap_or(&[]) + } + + fn byte(&mut self) -> Result { + let b = self + .buf + .get(self.pos) + .copied() + .ok_or_else(|| err_coded!(ErrorCode::Int049, "key ends early"))?; + self.pos += 1; + Ok(b) + } + + fn peek(&self) -> Result { + self.buf + .get(self.pos) + .copied() + .ok_or_else(|| err_coded!(ErrorCode::Int049, "key ends early")) + } + + pub fn take(&mut self, n: usize) -> Result<&'a [u8]> { + let end = self + .pos + .checked_add(n) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "key length overflow"))?; + let s = self + .buf + .get(self.pos..end) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "key ends early"))?; + self.pos = end; + Ok(s) + } + + fn be(&mut self, n: usize) -> Result { + let mut v = [0u8; 8]; + let src = self.take(n)?; + let dst = v + .get_mut(8usize.saturating_sub(n)..) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "integer longer than 8 bytes"))?; + dst.copy_from_slice(src); + Ok(u64::from_be_bytes(v)) + } + + pub fn uint(&mut self) -> Result { + let t = self.byte()?; + match t.checked_sub(INT_ZERO) { + Some(n @ 0..=8) => self.be(usize::from(n)), + _ => bail_coded!( + ErrorCode::Int049, + format!("bad unsigned type byte {t:#04x}") + ), + } + } + + pub fn int(&mut self) -> Result { + let t = self.byte()?; + if t >= INT_ZERO { + let n = t - INT_ZERO; + if n > 8 { + bail_coded!(ErrorCode::Int049, format!("bad integer type byte {t:#04x}")); + } + let m = self.be(usize::from(n))?; + return i64::try_from(m) + .map_err(|_| err_coded!(ErrorCode::Int049, "integer out of range")); + } + let n = INT_ZERO - t; + if n > 8 { + bail_coded!(ErrorCode::Int049, format!("bad integer type byte {t:#04x}")); + } + let n = usize::from(n); + let comp = self.be(n)?; + // The stored bytes are the low `n` bytes of !m; restore the high ones. + let mask = if n == 8 { + u64::MAX + } else { + (1u64 << (8 * n)) - 1 + }; + let m = !(comp | !mask); + if m == 0 || m > i64::MIN.unsigned_abs() { + bail_coded!(ErrorCode::Int049, "integer out of range"); + } + Ok(0i64.checked_sub_unsigned(m).unwrap_or(i64::MIN)) + } + + /// `tx↓`: see [`put_tx_desc`]. + pub fn tx_desc(&mut self) -> Result { + let t = !self.peek()?; + let n = match t.checked_sub(INT_ZERO) { + Some(n @ 0..=8) => usize::from(n), + _ => bail_coded!(ErrorCode::Int049, format!("bad tx type byte {t:#04x}")), + }; + let raw: Vec = self.take(n + 1)?.iter().map(|b| !b).collect(); + Reader::new(&raw).uint() + } + + pub fn valid_to(&mut self) -> Result { + if self.peek()? == FOREVER { + self.pos += 1; + return Ok(VALID_TIME_FOREVER); + } + self.int() + } +} + +/// Append `tx_count` so that newer transactions sort first: the encoding of +/// `tx_count` with every byte complemented. The encoding is prefix-free, so the +/// complement reverses its order exactly. +pub fn put_tx_desc(out: &mut Vec, tx_count: u64) { + let start = out.len(); + put_uint(out, tx_count); + if let Some(s) = out.get_mut(start..) { + for b in s { + *b = !*b; + } + } +} + +/// Append a valid-to time; FOREVER is the single byte 0xFF. +pub fn put_valid_to(out: &mut Vec, vt: i64) { + if vt == VALID_TIME_FOREVER { + out.push(FOREVER); + } else { + put_int(out, vt); + } +} + +// ─── Values (§6.2) ─────────────────────────────────────────────────────────── + +/// Value type tags, in the cross-type order Null < Boolean < Integer < Float < +/// String < Keyword < Ref. +const TAG_NULL: u8 = 0x01; +const TAG_BOOL: u8 = 0x02; +const TAG_INT: u8 = 0x03; +const TAG_FLOAT: u8 = 0x04; +const TAG_STRING: u8 = 0x05; +const TAG_KEYWORD: u8 = 0x06; +const TAG_REF: u8 = 0x07; + +/// Where a long string is stored: a record in a value page (§6.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ValueRef { + pub page: u64, + pub slot: u16, +} + +/// A value as it appears in a key: keywords and refs are ids, and a long +/// string is its value ref. +#[derive(Debug, Clone, PartialEq)] +pub enum KeyValue { + Null, + Bool(bool), + Int(i64), + Float(f64), + Str(String), + /// A string longer than [`SHORT_STRING_MAX`] bytes. + LongStr { + prefix: Vec, + hash: u64, + vref: ValueRef, + }, + Keyword(u32), + Ref(u64), +} + +/// The order-preserving bit transform for floats, with NaN canonicalised. +fn float_bits(f: f64) -> u64 { + if f.is_nan() { + return 0xFFF8_0000_0000_0000; + } + let raw = f.to_bits(); + if raw >> 63 == 0 { + raw ^ 0x8000_0000_0000_0000 + } else { + !raw + } +} + +fn float_from_bits(bits: u64) -> f64 { + if bits >> 63 == 1 { + f64::from_bits(bits ^ 0x8000_0000_0000_0000) + } else { + f64::from_bits(!bits) + } +} + +/// Append `bytes` with 0x00 escaped as `00 FF`. +fn put_escaped(out: &mut Vec, bytes: &[u8]) { + for &b in bytes { + out.push(b); + if b == 0 { + out.push(0xFF); + } + } +} + +/// FNV-1a 64 of a long value: stable across versions and platforms. A +/// collision costs one extra comparison of full values, never a wrong match. +pub fn hash64(bytes: &[u8]) -> u64 { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for &b in bytes { + h ^= u64::from(b); + h = h.wrapping_mul(0x0000_0100_0000_01b3); + } + h +} + +/// Append an encoded value. +pub fn put_value(out: &mut Vec, v: &KeyValue) { + match v { + KeyValue::Null => out.push(TAG_NULL), + KeyValue::Bool(b) => { + out.push(TAG_BOOL); + out.push(u8::from(*b)); + } + KeyValue::Int(n) => { + out.push(TAG_INT); + put_int(out, *n); + } + KeyValue::Float(f) => { + out.push(TAG_FLOAT); + out.extend_from_slice(&float_bits(*f).to_be_bytes()); + } + KeyValue::Str(s) => { + out.push(TAG_STRING); + put_escaped(out, s.as_bytes()); + out.extend_from_slice(&[0x00, 0x00]); + } + KeyValue::LongStr { prefix, hash, vref } => { + out.push(TAG_STRING); + put_escaped(out, prefix); + out.extend_from_slice(&[0x00, 0x01]); + out.extend_from_slice(&hash.to_be_bytes()); + out.extend_from_slice(&vref.page.to_be_bytes()); + out.extend_from_slice(&vref.slot.to_be_bytes()); + } + KeyValue::Keyword(iid) => { + out.push(TAG_KEYWORD); + put_uint(out, u64::from(*iid)); + } + KeyValue::Ref(eid) => { + out.push(TAG_REF); + put_uint(out, *eid); + } + } +} + +/// The key form of a long string: its first [`LONG_PREFIX`] bytes and hash. +pub fn long_str(s: &str, vref: ValueRef) -> KeyValue { + let bytes = s.as_bytes(); + KeyValue::LongStr { + prefix: bytes.get(..LONG_PREFIX).unwrap_or(bytes).to_vec(), + hash: hash64(bytes), + vref, + } +} + +impl Reader<'_> { + pub fn value(&mut self) -> Result { + let tag = self.byte()?; + Ok(match tag { + TAG_NULL => KeyValue::Null, + TAG_BOOL => KeyValue::Bool(self.byte()? != 0), + TAG_INT => KeyValue::Int(self.int()?), + TAG_FLOAT => KeyValue::Float(float_from_bits(self.be(8)?)), + TAG_STRING => self.string()?, + TAG_KEYWORD => KeyValue::Keyword( + u32::try_from(self.uint()?) + .map_err(|_| err_coded!(ErrorCode::Int049, "iid out of range"))?, + ), + TAG_REF => KeyValue::Ref(self.uint()?), + _ => bail_coded!(ErrorCode::Int049, format!("bad value tag {tag:#04x}")), + }) + } + + fn string(&mut self) -> Result { + let mut bytes = Vec::new(); + loop { + let b = self.byte()?; + if b != 0 { + bytes.push(b); + continue; + } + match self.byte()? { + 0xFF => bytes.push(0), + 0x00 => { + let s = String::from_utf8(bytes) + .map_err(|_| err_coded!(ErrorCode::Int049, "key string not UTF-8"))?; + return Ok(KeyValue::Str(s)); + } + 0x01 => { + let hash = self.be(8)?; + let page = self.be(8)?; + let slot = u16::try_from(self.be(2)?) + .map_err(|_| err_coded!(ErrorCode::Int049, "slot out of range"))?; + return Ok(KeyValue::LongStr { + prefix: bytes, + hash, + vref: ValueRef { page, slot }, + }); + } + other => bail_coded!(ErrorCode::Int049, format!("bad string escape {other:#04x}")), + } + } + } +} + +// ─── Index keys (§5.2) ─────────────────────────────────────────────────────── + +/// The four index trees. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Index { + Eavt, + Aevt, + Avet, + Vaet, +} + +impl Index { + pub const ALL: [Index; 4] = [Index::Eavt, Index::Aevt, Index::Avet, Index::Vaet]; +} + +/// A fact with dictionary ids in place of UUIDs and idents: one index entry. +#[derive(Debug, Clone, PartialEq)] +pub struct KeyFact { + pub e: u64, + pub a: u32, + pub v: KeyValue, + pub tx_count: u64, + pub vf: i64, + pub vt: i64, + pub asserted: bool, +} + +impl KeyFact { + /// The key of this fact in `index`, or `None` for VAET unless the value is a ref. + pub fn key(&self, index: Index) -> Option> { + let mut k = Vec::with_capacity(48); + let a = u64::from(self.a); + match index { + Index::Eavt => { + put_uint(&mut k, self.e); + put_uint(&mut k, a); + put_value(&mut k, &self.v); + } + Index::Aevt => { + put_uint(&mut k, a); + put_uint(&mut k, self.e); + put_value(&mut k, &self.v); + } + Index::Avet => { + put_uint(&mut k, a); + put_value(&mut k, &self.v); + put_uint(&mut k, self.e); + } + Index::Vaet => { + let KeyValue::Ref(target) = self.v else { + return None; + }; + put_uint(&mut k, target); + put_uint(&mut k, a); + put_uint(&mut k, self.e); + } + } + put_tx_desc(&mut k, self.tx_count); + put_int(&mut k, self.vf); + put_valid_to(&mut k, self.vt); + k.push(u8::from(self.asserted)); + Some(k) + } + + /// Decode an index key of `index`. + pub fn decode(index: Index, key: &[u8]) -> Result { + let mut r = Reader::new(key); + let (e, a, v) = match index { + Index::Eavt => { + let e = r.uint()?; + let a = r.uint()?; + (e, a, r.value()?) + } + Index::Aevt => { + let a = r.uint()?; + let e = r.uint()?; + (e, a, r.value()?) + } + Index::Avet => { + let a = r.uint()?; + let v = r.value()?; + (r.uint()?, a, v) + } + Index::Vaet => { + let target = r.uint()?; + let a = r.uint()?; + (r.uint()?, a, KeyValue::Ref(target)) + } + }; + let tx_count = r.tx_desc()?; + let vf = r.int()?; + let vt = r.valid_to()?; + let asserted = match r.byte()? { + 0 => false, + 1 => true, + b => bail_coded!(ErrorCode::Int049, format!("bad op byte {b:#04x}")), + }; + if !r.is_empty() { + bail_coded!(ErrorCode::Int049, "trailing bytes after index key"); + } + Ok(KeyFact { + e, + a: u32::try_from(a).map_err(|_| err_coded!(ErrorCode::Int049, "iid out of range"))?, + v, + tx_count, + vf, + vt, + asserted, + }) + } +} + +/// Key prefix of every EAVT entry of entity `e`. +pub fn entity_prefix(e: u64) -> Vec { + let mut k = Vec::new(); + put_uint(&mut k, e); + k +} + +/// Key prefix of every EAVT entry of `(e, a)`. +pub fn entity_attribute_prefix(e: u64, a: u32) -> Vec { + let mut k = entity_prefix(e); + put_uint(&mut k, u64::from(a)); + k +} + +/// Key prefix of every AEVT (or AVET) entry of attribute `a`. +pub fn attribute_prefix(a: u32) -> Vec { + let mut k = Vec::new(); + put_uint(&mut k, u64::from(a)); + k +} + +// ─── DICT keys (§5.2) ──────────────────────────────────────────────────────── + +pub const DICT_UUID_TO_EID: u8 = 0x01; +pub const DICT_EID_TO_UUID: u8 = 0x02; +pub const DICT_IDENT_TO_IID: u8 = 0x03; +pub const DICT_IID_TO_IDENT: u8 = 0x04; +pub const DICT_TX: u8 = 0x05; +pub const DICT_LONG_VALUE: u8 = 0x06; + +pub fn dict_uuid_key(uuid: &Uuid) -> Vec { + let mut k = vec![DICT_UUID_TO_EID]; + k.extend_from_slice(uuid.as_bytes()); + k +} + +pub fn dict_eid_key(eid: u64) -> Vec { + let mut k = vec![DICT_EID_TO_UUID]; + put_uint(&mut k, eid); + k +} + +pub fn dict_ident_key(ident: &str) -> Vec { + let mut k = vec![DICT_IDENT_TO_IID]; + k.extend_from_slice(ident.as_bytes()); + k +} + +pub fn dict_iid_key(iid: u32) -> Vec { + let mut k = vec![DICT_IID_TO_IDENT]; + put_uint(&mut k, u64::from(iid)); + k +} + +pub fn dict_tx_key(tx_count: u64) -> Vec { + let mut k = vec![DICT_TX]; + put_uint(&mut k, tx_count); + k +} + +/// Prefix of every DICT 0x06 entry for long values with this hash. +pub fn dict_long_value_prefix(hash: u64) -> Vec { + let mut k = vec![DICT_LONG_VALUE]; + k.extend_from_slice(&hash.to_be_bytes()); + k +} + +pub fn dict_long_value_key(hash: u64, vref: ValueRef) -> Vec { + let mut k = dict_long_value_prefix(hash); + k.extend_from_slice(&vref.page.to_be_bytes()); + k.extend_from_slice(&vref.slot.to_be_bytes()); + k +} + +/// The value ref at the end of a DICT 0x06 key. +pub fn dict_long_value_ref(key: &[u8]) -> Result { + let mut r = Reader::new(key); + if r.byte()? != DICT_LONG_VALUE { + bail_coded!(ErrorCode::Int049, "not a long-value DICT key"); + } + r.be(8)?; + let page = r.be(8)?; + let slot = + u16::try_from(r.be(2)?).map_err(|_| err_coded!(ErrorCode::Int049, "slot out of range"))?; + if !r.is_empty() { + bail_coded!(ErrorCode::Int049, "trailing bytes after DICT key"); + } + Ok(ValueRef { page, slot }) +} + +/// Encode a dictionary value that is an integer id or timestamp. +pub fn uint_bytes(v: u64) -> Vec { + let mut out = Vec::with_capacity(9); + put_uint(&mut out, v); + out +} + +/// Decode a dictionary value written by [`uint_bytes`]. +pub fn read_uint_bytes(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + let v = r.uint()?; + if !r.is_empty() { + bail_coded!(ErrorCode::Int049, "trailing bytes after integer"); + } + Ok(v) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cmp::Ordering; + + /// xorshift64*: deterministic randomness without a dependency. + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + } + + fn uint_enc(v: u64) -> Vec { + let mut o = Vec::new(); + put_uint(&mut o, v); + o + } + + fn int_enc(v: i64) -> Vec { + let mut o = Vec::new(); + put_int(&mut o, v); + o + } + + fn interesting_ints(rng: &mut Rng) -> Vec { + let mut v = vec![ + 0, + 1, + -1, + 255, + 256, + -255, + -256, + -257, + i64::MAX, + i64::MIN, + i64::MIN + 1, + i64::MAX - 1, + 1 << 41, + -(1 << 41), + ]; + for _ in 0..2000 { + let bits = rng.below(64); + let m = rng.next() >> (63 - bits); + v.push(m.cast_signed()); + v.push(m.cast_signed().wrapping_neg()); + } + v + } + + #[test] + fn signed_integers_sort_and_round_trip() { + let mut rng = Rng(7); + let mut vals = interesting_ints(&mut rng); + vals.sort_unstable(); + vals.dedup(); + let encs: Vec> = vals.iter().map(|&v| int_enc(v)).collect(); + for w in encs.windows(2) { + assert!(w[0] < w[1], "encoding order follows integer order"); + } + for (v, e) in vals.iter().zip(&encs) { + let mut r = Reader::new(e); + assert_eq!(r.int().unwrap(), *v); + assert!(r.is_empty()); + } + assert_eq!(int_enc(0).len(), 1, "zero takes one byte"); + assert_eq!(int_enc(255).len(), 2); + assert_eq!(int_enc(1 << 41).len(), 7, "a ms timestamp takes 7 bytes"); + } + + #[test] + fn unsigned_integers_sort_and_round_trip() { + let mut rng = Rng(11); + let mut vals: Vec = vec![0, 1, 255, 256, u64::MAX, u64::MAX - 1]; + for _ in 0..2000 { + vals.push(rng.next() >> rng.below(64)); + } + vals.sort_unstable(); + vals.dedup(); + let encs: Vec> = vals.iter().map(|&v| uint_enc(v)).collect(); + for w in encs.windows(2) { + assert!(w[0] < w[1], "encoding order follows integer order"); + } + for (v, e) in vals.iter().zip(&encs) { + assert_eq!(Reader::new(e).uint().unwrap(), *v); + } + } + + #[test] + fn tx_desc_reverses_order_and_is_short() { + let mut rng = Rng(3); + let mut vals: Vec = vec![0, 1, 255, 256, 70_000, u64::MAX]; + for _ in 0..1000 { + vals.push(rng.next() >> rng.below(64)); + } + vals.sort_unstable(); + vals.dedup(); + let encs: Vec> = vals + .iter() + .map(|&v| { + let mut o = Vec::new(); + put_tx_desc(&mut o, v); + o + }) + .collect(); + for w in encs.windows(2) { + assert!(w[0] > w[1], "newer transactions sort first"); + } + for (v, e) in vals.iter().zip(&encs) { + let mut r = Reader::new(e); + assert_eq!(r.tx_desc().unwrap(), *v); + assert!(r.is_empty()); + } + let mut o = Vec::new(); + put_tx_desc(&mut o, 70_000); + assert_eq!(o.len(), 4, "a typical tx_count costs a few bytes"); + } + + #[test] + fn forever_sorts_after_every_finite_valid_to() { + let mut forever = Vec::new(); + put_valid_to(&mut forever, VALID_TIME_FOREVER); + assert_eq!(forever, vec![0xFF]); + let mut rng = Rng(5); + for v in interesting_ints(&mut rng) { + if v == VALID_TIME_FOREVER { + continue; + } + let mut e = Vec::new(); + put_valid_to(&mut e, v); + assert!(e < forever, "finite time sorts before FOREVER"); + assert_eq!(Reader::new(&e).valid_to().unwrap(), v); + } + assert_eq!( + Reader::new(&forever).valid_to().unwrap(), + VALID_TIME_FOREVER + ); + } + + fn enc_value(v: &KeyValue) -> Vec { + let mut o = Vec::new(); + put_value(&mut o, v); + o + } + + fn rank(v: &KeyValue) -> u8 { + match v { + KeyValue::Null => 0, + KeyValue::Bool(_) => 1, + KeyValue::Int(_) => 2, + KeyValue::Float(_) => 3, + KeyValue::Str(_) | KeyValue::LongStr { .. } => 4, + KeyValue::Keyword(_) => 5, + KeyValue::Ref(_) => 6, + } + } + + /// Logical order of values whose order is exact (all but long strings). + fn logical_cmp(a: &KeyValue, b: &KeyValue) -> Ordering { + rank(a).cmp(&rank(b)).then_with(|| match (a, b) { + (KeyValue::Bool(x), KeyValue::Bool(y)) => x.cmp(y), + (KeyValue::Int(x), KeyValue::Int(y)) => x.cmp(y), + (KeyValue::Float(x), KeyValue::Float(y)) => { + // NaN is canonicalised to one value that sorts after +inf. + let c = |f: f64| if f.is_nan() { f64::NAN } else { f }; + c(*x).total_cmp(&c(*y)) + } + (KeyValue::Str(x), KeyValue::Str(y)) => x.as_bytes().cmp(y.as_bytes()), + (KeyValue::Keyword(x), KeyValue::Keyword(y)) => x.cmp(y), + (KeyValue::Ref(x), KeyValue::Ref(y)) => x.cmp(y), + _ => Ordering::Equal, + }) + } + + fn random_int(rng: &mut Rng) -> i64 { + let m = (rng.next() >> rng.below(64)).cast_signed(); + if rng.below(2) == 0 { + m + } else { + m.wrapping_neg() + } + } + + fn random_string(rng: &mut Rng, max_len: u64) -> String { + let len = rng.below(max_len + 1); + (0..len) + .map(|_| match rng.below(4) { + 0 => '\0', + 1 => 'a', + 2 => 'b', + _ => char::from(u8::try_from(rng.below(0x7f)).unwrap()), + }) + .collect() + } + + fn random_value(rng: &mut Rng) -> KeyValue { + match rng.below(8) { + 0 => KeyValue::Null, + 1 => KeyValue::Bool(rng.below(2) == 1), + 2 => KeyValue::Int(random_int(rng)), + 3 => KeyValue::Float(match rng.below(6) { + 0 => f64::NAN, + 1 => 0.0, + 2 => -0.0, + 3 => f64::INFINITY, + 4 => f64::NEG_INFINITY, + _ => f64::from_bits(rng.next()), + }), + 4 | 5 => KeyValue::Str(random_string(rng, 64)), + 6 => KeyValue::Keyword(u32::try_from(rng.below(1 << 20)).unwrap()), + _ => KeyValue::Ref(rng.next() >> rng.below(64)), + } + } + + #[test] + fn value_encoding_order_matches_logical_order() { + let mut rng = Rng(42); + let mut vals: Vec = (0..3000).map(|_| random_value(&mut rng)).collect(); + vals.push(KeyValue::Str("a".repeat(64))); + vals.push(KeyValue::Str(String::new())); + vals.push(KeyValue::Str("\0".into())); + vals.push(KeyValue::Str("\0\0".into())); + let mut by_logic = vals.clone(); + by_logic.sort_by(logical_cmp); + let mut by_bytes = vals; + by_bytes.sort_by_key(enc_value); + let a: Vec> = by_logic.iter().map(enc_value).collect(); + let b: Vec> = by_bytes.iter().map(enc_value).collect(); + assert!(a == b, "memcmp order equals logical order"); + } + + #[test] + fn values_round_trip() { + let mut rng = Rng(9); + for _ in 0..3000 { + let v = random_value(&mut rng); + let e = enc_value(&v); + let mut r = Reader::new(&e); + let back = r.value().unwrap(); + assert!(r.is_empty(), "value is self-delimiting"); + assert_eq!(enc_value(&back), e, "round trip"); + } + let long = "x\0y".repeat(40); + let v = long_str(&long, ValueRef { page: 77, slot: 3 }); + let e = enc_value(&v); + assert_eq!(Reader::new(&e).value().unwrap(), v); + } + + #[test] + fn long_strings_order_by_prefix_when_prefixes_differ() { + let r = ValueRef { page: 9, slot: 1 }; + let a = enc_value(&long_str(&format!("a{}", "z".repeat(100)), r)); + let b = enc_value(&long_str(&format!("b{}", "a".repeat(100)), r)); + let short_b = enc_value(&KeyValue::Str("b".into())); + let short_a = enc_value(&KeyValue::Str("a".repeat(SHORT_STRING_MAX))); + assert!(a < b, "first 32 bytes decide"); + assert!( + short_a < a && a < short_b, + "short and long interleave by prefix" + ); + } + + fn random_fact(rng: &mut Rng) -> KeyFact { + let ref_target = rng.below(2) == 0; + KeyFact { + e: rng.below(50), + a: u32::try_from(rng.below(10)).unwrap(), + v: if ref_target { + KeyValue::Ref(rng.below(50)) + } else { + random_value(rng) + }, + tx_count: rng.below(40), + vf: random_int(rng), + vt: if rng.below(2) == 0 { + VALID_TIME_FOREVER + } else { + (rng.next() >> 2).cast_signed() - (1 << 61) + }, + asserted: rng.below(2) == 0, + } + } + + fn fact_cmp(index: Index, x: &KeyFact, y: &KeyFact) -> Ordering { + let tail = |x: &KeyFact, y: &KeyFact| { + y.tx_count + .cmp(&x.tx_count) + .then(x.vf.cmp(&y.vf)) + .then(x.vt.cmp(&y.vt)) + .then(x.asserted.cmp(&y.asserted)) + }; + let head = match index { + Index::Eavt => { + x.e.cmp(&y.e) + .then(x.a.cmp(&y.a)) + .then_with(|| logical_cmp(&x.v, &y.v)) + } + Index::Aevt => { + x.a.cmp(&y.a) + .then(x.e.cmp(&y.e)) + .then_with(|| logical_cmp(&x.v, &y.v)) + } + Index::Avet => { + x.a.cmp(&y.a) + .then_with(|| logical_cmp(&x.v, &y.v)) + .then(x.e.cmp(&y.e)) + } + Index::Vaet => logical_cmp(&x.v, &y.v) + .then(x.a.cmp(&y.a)) + .then(x.e.cmp(&y.e)), + }; + head.then_with(|| tail(x, y)) + } + + #[test] + fn index_keys_sort_logically_and_round_trip() { + let mut rng = Rng(1234); + let facts: Vec = (0..3000).map(|_| random_fact(&mut rng)).collect(); + for index in Index::ALL { + let mut subset: Vec<&KeyFact> = facts + .iter() + .filter(|f| index != Index::Vaet || matches!(f.v, KeyValue::Ref(_))) + .collect(); + let mut by_bytes = subset.clone(); + by_bytes.sort_by_key(|f| f.key(index).unwrap()); + subset.sort_by(|x, y| fact_cmp(index, x, y)); + let a: Vec> = subset.iter().map(|f| f.key(index).unwrap()).collect(); + let b: Vec> = by_bytes.iter().map(|f| f.key(index).unwrap()).collect(); + assert!(a == b, "memcmp order equals logical order"); + for f in &subset { + let k = f.key(index).unwrap(); + let back = KeyFact::decode(index, &k).unwrap(); + assert_eq!(back.key(index).unwrap(), k, "round trip"); + } + } + let non_ref = KeyFact { + v: KeyValue::Int(1), + ..random_fact(&mut rng) + }; + assert!(non_ref.key(Index::Vaet).is_none(), "VAET holds refs only"); + } + + #[test] + fn prefixes_bound_exactly_their_entity_or_attribute() { + let f = KeyFact { + e: 300, + a: 7, + v: KeyValue::Int(1), + tx_count: 5, + vf: 0, + vt: VALID_TIME_FOREVER, + asserted: true, + }; + let eavt = f.key(Index::Eavt).unwrap(); + assert!(eavt.starts_with(&entity_prefix(300))); + assert!(eavt.starts_with(&entity_attribute_prefix(300, 7))); + assert!(!eavt.starts_with(&entity_prefix(3))); + assert!(!eavt.starts_with(&entity_attribute_prefix(300, 70))); + let aevt = f.key(Index::Aevt).unwrap(); + assert!(aevt.starts_with(&attribute_prefix(7))); + assert!(!aevt.starts_with(&attribute_prefix(700))); + } + + #[test] + fn dict_keys_round_trip() { + let r = ValueRef { + page: 123_456, + slot: 17, + }; + let k = dict_long_value_key(0xDEAD_BEEF, r); + assert!(k.starts_with(&dict_long_value_prefix(0xDEAD_BEEF))); + assert_eq!(dict_long_value_ref(&k).unwrap(), r); + assert_eq!(read_uint_bytes(&uint_bytes(1 << 41)).unwrap(), 1 << 41); + assert!(dict_eid_key(1) < dict_eid_key(256), "eids sort in order"); + assert!(dict_tx_key(9) < dict_tx_key(10)); + assert_eq!( + hash64(b"a"), + 0xaf63_dc4c_8601_ec8c, + "FNV-1a 64 reference value" + ); + } + + #[test] + fn malformed_keys_are_int_049() { + for bad in [&[][..], &[0x30][..], &[0x15][..], &[TAG_STRING, b'a'][..]] { + let err = Reader::new(bad).value().unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + } + let err = KeyFact::decode(Index::Eavt, &[0x14, 0x14, TAG_NULL]).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + } + + #[test] + fn size_limits() { + assert_eq!(MAX_VALUE_BYTES, 4068); + assert_eq!(MAX_IDENT_BYTES, 1024); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 528a2377f..794436ba2 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -11,6 +11,7 @@ pub mod cache; pub(crate) mod dir_sync; pub(crate) mod freelist; pub mod index; +pub(crate) mod keys; pub(crate) mod meta; pub mod packed_pages; pub(crate) mod page; From 38b2aee7850eac1f857ccd366c1173425ab334f4 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:00:47 +0530 Subject: [PATCH 3/9] feat(storage): prefix-compressed leaf and internal node codecs (#433) Leaves store prefix-compressed entries with a restart point every 16 entries; internal nodes store shortest separators with a slot array for binary search. Both reject a page overflow with INT-049. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- src/storage/mod.rs | 1 + src/storage/node.rs | 598 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 599 insertions(+) create mode 100644 src/storage/node.rs diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 794436ba2..59be7a33b 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod freelist; pub mod index; pub(crate) mod keys; pub(crate) mod meta; +pub(crate) mod node; pub mod packed_pages; pub(crate) mod page; pub mod persistent_facts; diff --git a/src/storage/node.rs b/src/storage/node.rs new file mode 100644 index 000000000..1ff105da9 --- /dev/null +++ b/src/storage/node.rs @@ -0,0 +1,598 @@ +//! B+tree node codecs (spec §4.3). +//! +//! **Leaf (0x61).** After the common header, entries in key order, each +//! `varint shared ‖ varint suffix_len ‖ suffix ‖ varint value_len ‖ value`, +//! where `shared` is the length of the prefix shared with the previous key. +//! Every 16th entry is a restart point (`shared = 0`); their offsets are a `u16` +//! array at the end of the page. The header's `count` is the number of entries. +//! +//! **Internal (0x62).** `rightmost_child u64` at offset 24, then entries +//! `varint sep_len ‖ sep ‖ child u64` from offset 32, with a `u16` slot array at +//! the end of the page. Child `i` holds the keys below separator `i` (and at or +//! above separator `i − 1`); `rightmost_child` holds the keys at or above the +//! last separator. The header's `count` is the number of separators. + +use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::storage::PAGE_SIZE; +use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_TYPE_INTERNAL, PAGE_TYPE_LEAF, new_page}; +use anyhow::Result; + +/// Entries between restart points. +pub const RESTART_INTERVAL: usize = 16; +/// Offset of an internal node's `rightmost_child`. +pub const RIGHTMOST_CHILD_OFFSET: usize = PAGE_HEADER_SIZE; +/// First byte of an internal node's entries. +pub const INTERNAL_BODY: usize = PAGE_HEADER_SIZE + 8; + +/// A key and its value. The index trees use empty values. +pub type Entry = (Vec, Vec); + +fn invalid(msg: impl Into) -> anyhow::Error { + err_coded!(ErrorCode::Int049, msg.into()) +} + +// ─── Varints and reads ─────────────────────────────────────────────────────── + +/// Append `v` as LEB128. +pub fn put_varint(out: &mut Vec, mut v: u64) { + while v >= 0x80 { + out.push(u8::try_from(v & 0x7F).unwrap_or(0) | 0x80); + v >>= 7; + } + out.push(u8::try_from(v).unwrap_or(0)); +} + +/// Bytes `put_varint` writes for `v`. +pub fn varint_len(v: usize) -> usize { + let mut n = 1; + let mut v = v >> 7; + while v > 0 { + n += 1; + v >>= 7; + } + n +} + +/// Read a LEB128 value at `*pos`, advancing it. +fn get_varint(page: &[u8], pos: &mut usize) -> Result { + let mut v: u64 = 0; + for shift in (0..64).step_by(7) { + let b = *page + .get(*pos) + .ok_or_else(|| invalid("varint runs past the page"))?; + *pos += 1; + v |= u64::from(b & 0x7F) << shift; + if b & 0x80 == 0 { + return usize::try_from(v).map_err(|_| invalid("varint overflows usize")); + } + } + Err(invalid("varint too long")) +} + +fn get_slice<'a>(page: &'a [u8], pos: &mut usize, len: usize) -> Result<&'a [u8]> { + let end = pos + .checked_add(len) + .ok_or_else(|| invalid("length overflow"))?; + let s = page + .get(*pos..end) + .ok_or_else(|| invalid("entry runs past the page"))?; + *pos = end; + Ok(s) +} + +fn get_u16(page: &[u8], at: usize) -> Result { + let b = page + .get(at..at.saturating_add(2)) + .ok_or_else(|| invalid("u16 past the page"))?; + Ok(usize::from(u16::from_le_bytes([ + b.first().copied().unwrap_or(0), + b.get(1).copied().unwrap_or(0), + ]))) +} + +fn get_u64(page: &[u8], at: usize) -> Result { + let b = page + .get(at..at.saturating_add(8)) + .ok_or_else(|| invalid("u64 past the page"))?; + let arr: [u8; 8] = b.try_into().map_err(|_| invalid("u64 not 8 bytes"))?; + Ok(u64::from_le_bytes(arr)) +} + +fn put_u16_at(page: &mut [u8], at: usize, v: usize) -> Result<()> { + let v = u16::try_from(v).map_err(|_| invalid("offset exceeds u16"))?; + page.get_mut(at..at.saturating_add(2)) + .ok_or_else(|| invalid("u16 write past the page"))? + .copy_from_slice(&v.to_le_bytes()); + Ok(()) +} + +/// The entry count in a node's header. +pub fn node_count(page: &[u8]) -> Result { + get_u16(page, 2) +} + +fn shared_len(a: &[u8], b: &[u8]) -> usize { + a.iter().zip(b).take_while(|(x, y)| x == y).count() +} + +// ─── Leaves ────────────────────────────────────────────────────────────────── + +/// Encoded size of one leaf entry. +fn leaf_entry_len(shared: usize, key: &[u8], value: &[u8]) -> usize { + let suffix = key.len().saturating_sub(shared); + varint_len(shared) + varint_len(suffix) + suffix + varint_len(value.len()) + value.len() +} + +/// Bytes a leaf holding `entries` needs, header and restart array included. +pub fn leaf_size(entries: &[Entry]) -> usize { + let mut size = PAGE_HEADER_SIZE; + let mut prev: &[u8] = &[]; + for (i, (k, v)) in entries.iter().enumerate() { + let restart = i % RESTART_INTERVAL == 0; + let shared = if restart { 0 } else { shared_len(prev, k) }; + size += leaf_entry_len(shared, k, v); + if restart { + size += 2; + } + prev = k; + } + size +} + +/// Encode a leaf. Fails with INT-049 if the entries do not fit in a page. +pub fn encode_leaf(entries: &[Entry]) -> Result> { + let size = leaf_size(entries); + if size > PAGE_SIZE { + bail_coded!( + ErrorCode::Int049, + format!("leaf entries need {size} bytes, more than a page") + ); + } + let count = u16::try_from(entries.len()).map_err(|_| invalid("too many entries for a leaf"))?; + let restarts = entries.len().div_ceil(RESTART_INTERVAL); + let mut body = Vec::with_capacity(size); + let mut offsets = Vec::with_capacity(restarts); + let mut prev: &[u8] = &[]; + for (i, (k, v)) in entries.iter().enumerate() { + let shared = if i % RESTART_INTERVAL == 0 { + offsets.push(PAGE_HEADER_SIZE + body.len()); + 0 + } else { + shared_len(prev, k) + }; + let suffix = k.get(shared..).unwrap_or(&[]); + put_varint(&mut body, u64::try_from(shared).unwrap_or(0)); + put_varint(&mut body, u64::try_from(suffix.len()).unwrap_or(0)); + body.extend_from_slice(suffix); + put_varint(&mut body, u64::try_from(v.len()).unwrap_or(0)); + body.extend_from_slice(v); + prev = k; + } + let mut page = new_page(PAGE_TYPE_LEAF, count); + page.get_mut(PAGE_HEADER_SIZE..PAGE_HEADER_SIZE + body.len()) + .ok_or_else(|| invalid("leaf body past the page"))? + .copy_from_slice(&body); + let array = PAGE_SIZE - 2 * restarts; + for (j, off) in offsets.into_iter().enumerate() { + put_u16_at(&mut page, array + 2 * j, off)?; + } + Ok(page) +} + +/// Decode one entry at `*pos` given the previous key; checks `shared`. +fn read_leaf_entry(page: &[u8], pos: &mut usize, prev: &[u8], end: usize) -> Result { + let shared = get_varint(page, pos)?; + if shared > prev.len() { + bail_coded!( + ErrorCode::Int049, + "leaf entry shares more than its predecessor" + ); + } + let suffix_len = get_varint(page, pos)?; + let suffix = get_slice(page, pos, suffix_len)?; + let value_len = get_varint(page, pos)?; + let value = get_slice(page, pos, value_len)?; + if *pos > end { + bail_coded!(ErrorCode::Int049, "leaf entry overlaps the restart array"); + } + let mut key = Vec::with_capacity(shared + suffix_len); + key.extend_from_slice(prev.get(..shared).unwrap_or(&[])); + key.extend_from_slice(suffix); + Ok((key, value.to_vec())) +} + +/// Decode every entry of a leaf, checking the restart array and key order. +pub fn decode_leaf(page: &[u8]) -> Result> { + if page.first().copied() != Some(PAGE_TYPE_LEAF) { + bail_coded!(ErrorCode::Int049, "not a leaf page"); + } + let count = node_count(page)?; + let restarts = count.div_ceil(RESTART_INTERVAL); + let end = PAGE_SIZE + .checked_sub(2 * restarts) + .filter(|&e| e >= PAGE_HEADER_SIZE) + .ok_or_else(|| invalid("restart array overflows the leaf"))?; + let mut entries: Vec = Vec::with_capacity(count); + let mut pos = PAGE_HEADER_SIZE; + for i in 0..count { + if i % RESTART_INTERVAL == 0 && get_u16(page, end + 2 * (i / RESTART_INTERVAL))? != pos { + bail_coded!(ErrorCode::Int049, "leaf restart offset mismatch"); + } + // A restart entry stores its whole key, so it shares nothing. + let prev: &[u8] = if i % RESTART_INTERVAL == 0 { + &[] + } else { + entries.last().map_or(&[], |(k, _)| k.as_slice()) + }; + let entry = read_leaf_entry(page, &mut pos, prev, end)?; + if entries.last().is_some_and(|(k, _)| *k >= entry.0) { + bail_coded!(ErrorCode::Int049, "leaf keys out of order"); + } + entries.push(entry); + } + Ok(entries) +} + +/// The value stored under `key` in a leaf, found by binary search over the +/// restart points and a scan of at most [`RESTART_INTERVAL`] entries. +pub fn leaf_get(page: &[u8], key: &[u8]) -> Result>> { + let count = node_count(page)?; + if count == 0 { + return Ok(None); + } + let restarts = count.div_ceil(RESTART_INTERVAL); + let end = PAGE_SIZE + .checked_sub(2 * restarts) + .ok_or_else(|| invalid("restart array overflows the leaf"))?; + // The last restart whose key is <= `key`. + let (mut lo, mut hi) = (0usize, restarts); + while hi - lo > 1 { + let mid = lo + (hi - lo) / 2; + let mut pos = get_u16(page, end + 2 * mid)?; + let (k, _) = read_leaf_entry(page, &mut pos, &[], end)?; + if k.as_slice() <= key { + lo = mid; + } else { + hi = mid; + } + } + let mut pos = get_u16(page, end + 2 * lo)?; + let first = lo * RESTART_INTERVAL; + let last = (first + RESTART_INTERVAL).min(count); + let mut prev: Vec = Vec::new(); + for _ in first..last { + let (k, v) = read_leaf_entry(page, &mut pos, &prev, end)?; + match k.as_slice().cmp(key) { + std::cmp::Ordering::Equal => return Ok(Some(v)), + std::cmp::Ordering::Greater => return Ok(None), + std::cmp::Ordering::Less => prev = k, + } + } + Ok(None) +} + +/// The first key of a leaf, or `None` if it is empty. +pub fn leaf_first_key(page: &[u8]) -> Result>> { + if node_count(page)? == 0 { + return Ok(None); + } + let mut pos = PAGE_HEADER_SIZE; + let (k, _) = read_leaf_entry(page, &mut pos, &[], PAGE_SIZE)?; + Ok(Some(k)) +} + +// ─── Internal nodes ────────────────────────────────────────────────────────── + +/// The shortest `s` with `left_last < s <= right_first`: the shortest prefix of +/// `right_first` that is greater than `left_last`. +pub fn shortest_separator(left_last: &[u8], right_first: &[u8]) -> Result> { + if left_last >= right_first { + bail_coded!(ErrorCode::Int049, "separator bounds out of order"); + } + let p = shared_len(left_last, right_first); + Ok(right_first + .get(..p.saturating_add(1)) + .unwrap_or(right_first) + .to_vec()) +} + +/// Bytes an internal node with these separators needs. +pub fn internal_size<'a>(seps: impl IntoIterator) -> usize { + seps.into_iter() + .map(|s| varint_len(s.len()) + s.len() + 8 + 2) + .sum::() + + INTERNAL_BODY +} + +/// Encode an internal node. `children` has one more element than `seps`; its +/// last element is `rightmost_child`. +pub fn encode_internal(children: &[u64], seps: &[Vec]) -> Result> { + if children.len() != seps.len().saturating_add(1) { + bail_coded!( + ErrorCode::Int049, + "internal node needs one more child than separators" + ); + } + let size = internal_size(seps.iter().map(Vec::as_slice)); + if size > PAGE_SIZE { + bail_coded!( + ErrorCode::Int049, + format!("internal node needs {size} bytes, more than a page") + ); + } + let count = u16::try_from(seps.len()).map_err(|_| invalid("too many separators"))?; + let mut page = new_page(PAGE_TYPE_INTERNAL, count); + let rightmost = children + .last() + .copied() + .ok_or_else(|| invalid("internal node without children"))?; + page.get_mut(RIGHTMOST_CHILD_OFFSET..INTERNAL_BODY) + .ok_or_else(|| invalid("internal header past the page"))? + .copy_from_slice(&rightmost.to_le_bytes()); + let mut body = Vec::new(); + let slots = PAGE_SIZE - 2 * seps.len(); + for (i, (sep, child)) in seps.iter().zip(children).enumerate() { + put_u16_at(&mut page, slots + 2 * i, INTERNAL_BODY + body.len())?; + put_varint(&mut body, u64::try_from(sep.len()).unwrap_or(0)); + body.extend_from_slice(sep); + body.extend_from_slice(&child.to_le_bytes()); + } + page.get_mut(INTERNAL_BODY..INTERNAL_BODY + body.len()) + .ok_or_else(|| invalid("internal body past the page"))? + .copy_from_slice(&body); + Ok(page) +} + +/// Read access to an internal node without decoding every separator. +pub struct Internal<'a> { + page: &'a [u8], + count: usize, + slots: usize, +} + +impl<'a> Internal<'a> { + pub fn new(page: &'a [u8]) -> Result { + if page.first().copied() != Some(PAGE_TYPE_INTERNAL) { + bail_coded!(ErrorCode::Int049, "not an internal page"); + } + let count = node_count(page)?; + let slots = PAGE_SIZE + .checked_sub(2 * count) + .filter(|&s| s >= INTERNAL_BODY) + .ok_or_else(|| invalid("slot array overflows the internal node"))?; + Ok(Internal { page, count, slots }) + } + + /// Number of separators; there are `count() + 1` children. + pub fn count(&self) -> usize { + self.count + } + + /// Separator `i` and the child below it. + fn entry(&self, i: usize) -> Result<(&'a [u8], u64)> { + let mut pos = get_u16(self.page, self.slots + 2 * i)?; + if pos < INTERNAL_BODY { + bail_coded!(ErrorCode::Int049, "internal slot points into the header"); + } + let len = get_varint(self.page, &mut pos)?; + let sep = get_slice(self.page, &mut pos, len)?; + if pos.saturating_add(8) > self.slots { + bail_coded!(ErrorCode::Int049, "internal entry overlaps the slot array"); + } + Ok((sep, get_u64(self.page, pos)?)) + } + + pub fn sep(&self, i: usize) -> Result<&'a [u8]> { + Ok(self.entry(i)?.0) + } + + /// Child `i`; `i == count()` is `rightmost_child`. + pub fn child(&self, i: usize) -> Result { + if i == self.count { + get_u64(self.page, RIGHTMOST_CHILD_OFFSET) + } else { + Ok(self.entry(i)?.1) + } + } + + /// All children in key order, `rightmost_child` last. + pub fn children(&self) -> Result> { + (0..=self.count).map(|i| self.child(i)).collect() + } + + /// The child to take for `key`, searching from child `from`: `from` plus the + /// number of separators from `from` on that are `<= key`. + pub fn route(&self, key: &[u8], from: usize) -> Result { + let (mut lo, mut hi) = (from, self.count); + while lo < hi { + let mid = lo + (hi - lo) / 2; + if self.sep(mid)? <= key { + lo = mid + 1; + } else { + hi = mid; + } + } + Ok(lo) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: u64) -> usize { + usize::try_from(self.next() % n).unwrap() + } + } + + /// Sorted, distinct random keys that share prefixes often. + fn random_entries(rng: &mut Rng, n: usize, value_max: u64) -> Vec { + let mut keys: Vec> = (0..n) + .map(|_| { + let len = 1 + rng.below(40); + (0..len) + .map(|_| u8::try_from(rng.below(4)).unwrap() * 60) + .collect() + }) + .collect(); + keys.sort(); + keys.dedup(); + keys.into_iter() + .map(|k| { + let v = (0..rng.below(value_max + 1)) + .map(|_| u8::try_from(rng.below(256)).unwrap()) + .collect(); + (k, v) + }) + .collect() + } + + /// The longest prefix of `entries` that fits in one leaf. + fn fitting(entries: &[Entry]) -> &[Entry] { + let mut n = entries.len(); + while leaf_size(&entries[..n]) > PAGE_SIZE { + n -= 1; + } + &entries[..n] + } + + #[test] + fn varint_round_trip() { + for v in [0u64, 1, 127, 128, 300, 16_383, 16_384, u64::from(u32::MAX)] { + let mut out = Vec::new(); + put_varint(&mut out, v); + assert_eq!(out.len(), varint_len(usize::try_from(v).unwrap())); + let mut pos = 0; + assert_eq!( + get_varint(&out, &mut pos).unwrap(), + usize::try_from(v).unwrap() + ); + } + } + + #[test] + fn leaf_round_trip_and_lookup_agree_with_linear_scan() { + let mut rng = Rng(17); + for round in 0..60 { + let all = random_entries(&mut rng, 300, if round % 2 == 0 { 0 } else { 20 }); + let entries = fitting(&all); + let page = encode_leaf(entries).unwrap(); + assert_eq!(page.len(), PAGE_SIZE); + assert_eq!(decode_leaf(&page).unwrap(), entries, "round trip"); + assert_eq!( + leaf_first_key(&page).unwrap(), + entries.first().map(|(k, _)| k.clone()) + ); + for (k, v) in entries { + assert_eq!(leaf_get(&page, k).unwrap().as_ref(), Some(v), "present key"); + } + for probe in random_entries(&mut rng, 50, 0) { + let want = entries.iter().find(|(k, _)| *k == probe.0).map(|(_, v)| v); + assert_eq!(leaf_get(&page, &probe.0).unwrap().as_ref(), want, "probe"); + } + } + } + + #[test] + fn prefix_compression_shrinks_shared_keys() { + let entries: Vec = (0u8..200) + .map(|i| { + let mut k = vec![7u8; 30]; + k.push(i); + (k, Vec::new()) + }) + .collect(); + let size = leaf_size(&entries); + assert!( + size < 200 * 10, + "shared prefixes are stored once per restart" + ); + let page = encode_leaf(&entries).unwrap(); + assert_eq!(decode_leaf(&page).unwrap(), entries); + } + + #[test] + fn empty_leaf() { + let page = encode_leaf(&[]).unwrap(); + assert!(decode_leaf(&page).unwrap().is_empty()); + assert_eq!(leaf_get(&page, b"x").unwrap(), None); + assert_eq!(leaf_first_key(&page).unwrap(), None); + } + + #[test] + fn overflowing_nodes_are_int_049() { + let big: Vec = (0u8..3).map(|i| (vec![i; 1500], Vec::new())).collect(); + let err = encode_leaf(&big).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + let seps: Vec> = (0u8..3).map(|i| vec![i; 1500]).collect(); + let err = encode_internal(&[1, 2, 3, 4], &seps).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + } + + #[test] + fn corrupt_leaf_is_rejected() { + let entries: Vec = (0u8..40).map(|i| (vec![1, i], vec![i])).collect(); + let page = encode_leaf(&entries).unwrap(); + let mut bad = page.clone(); + bad[2] = 200; // count far beyond the data + assert!(decode_leaf(&bad).is_err()); + let mut bad = page.clone(); + bad[PAGE_HEADER_SIZE] = 9; // first entry claims a shared prefix + assert!(decode_leaf(&bad).is_err()); + let mut bad = page; + let last = PAGE_SIZE - 2; + bad[last] ^= 1; // restart offset + assert!(decode_leaf(&bad).is_err()); + } + + #[test] + fn separators_are_shortest_and_bounded() { + let mut rng = Rng(99); + let entries = random_entries(&mut rng, 2000, 0); + for w in entries.windows(2) { + let (l, r) = (&w[0].0, &w[1].0); + let s = shortest_separator(l, r).unwrap(); + assert!(l.as_slice() < s.as_slice() && s.as_slice() <= r.as_slice()); + assert!(r.starts_with(&s)); + assert!( + s.len() == 1 || l.as_slice() >= &s[..s.len() - 1], + "no shorter prefix of r separates" + ); + } + assert!(shortest_separator(b"b", b"a").is_err()); + assert_eq!(shortest_separator(b"ab", b"abc").unwrap(), b"abc".to_vec()); + } + + #[test] + fn internal_round_trip_and_route() { + let mut rng = Rng(5); + let keys = random_entries(&mut rng, 400, 0); + // Separators from consecutive key pairs; children numbered. + let seps: Vec> = keys + .windows(2) + .step_by(4) + .map(|w| shortest_separator(&w[0].0, &w[1].0).unwrap()) + .take(150) + .collect(); + let children: Vec = (100..100 + u64::try_from(seps.len()).unwrap() + 1).collect(); + let page = encode_internal(&children, &seps).unwrap(); + let node = Internal::new(&page).unwrap(); + assert_eq!(node.count(), seps.len()); + assert_eq!(node.children().unwrap(), children); + for (i, s) in seps.iter().enumerate() { + assert_eq!(node.sep(i).unwrap(), s.as_slice()); + } + for (k, _) in &keys { + let want = seps.iter().filter(|s| s.as_slice() <= k.as_slice()).count(); + assert_eq!(node.route(k, 0).unwrap(), want, "route = separators <= key"); + } + } +} From f78dc7c6101cbbe252ad7b675e5cfdf8aba6281e Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:16:44 +0530 Subject: [PATCH 4/9] feat(storage): B+tree on byte keys with shortest separators (#433) build_btree, rebuild_btree_incremental, collect_leaf_pages, LeafCursor (with seek), prefix_scan and point get over (key, value) byte entries in the new node format. Leaves split by balanced bytes at cumulative-size boundaries; untouched leaves are copied and restamped. btree_v6.rs stays until its callers move over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- src/storage/btree.rs | 1325 ++++++++++++++++++++++++++++++++++++++++++ src/storage/mod.rs | 1 + src/storage/node.rs | 29 +- 3 files changed, 1343 insertions(+), 12 deletions(-) create mode 100644 src/storage/btree.rs diff --git a/src/storage/btree.rs b/src/storage/btree.rs new file mode 100644 index 000000000..e9d5504cc --- /dev/null +++ b/src/storage/btree.rs @@ -0,0 +1,1325 @@ +//! On-disk B+tree over byte keys (spec §4.3, §7). +//! +//! Each node is one page in the format of [`crate::storage::node`]. Entries are +//! `(key, value)` byte strings compared with memcmp; the four index trees use +//! empty values and DICT uses real ones. Leaves have no sibling pointers: scans +//! use [`LeafCursor`], which keeps the path from the root. +//! +//! [`build_btree`] bulk-builds a tree from sorted entries. [`rebuild_btree_incremental`] +//! rebuilds one from its old leaves plus new entries, copying untouched leaves +//! (restamped) and splitting touched ones by balanced bytes. Every page comes +//! from a [`PageAllocator`], so a rebuild never writes a page the old tree uses. + +use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::storage::cache::PageCache; +use crate::storage::node::{ + Entry, Internal, decode_leaf, encode_internal, encode_leaf, internal_size, leaf_entry_sizes, + leaf_first_key, leaf_get, leaf_size, shortest_separator, +}; +use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_TYPE_INTERNAL, PAGE_TYPE_LEAF, PageAllocator}; +use crate::storage::{PAGE_SIZE, StorageBackend}; +use anyhow::Result; +use std::sync::{Arc, Mutex}; + +/// Fill target for bulk-built and split leaves and internal nodes (~75%). +const PAGE_FILL_BYTES: usize = PAGE_SIZE * 3 / 4; + +/// Deepest tree a reader descends. Every internal node has at least two +/// children, so a real tree this deep would need 2^32 leaves; anything deeper +/// is a cycle in the child pointers or corruption. +const MAX_TREE_DEPTH: usize = 32; + +/// A written node: its page id and the first and last key below it. +struct NodeInfo { + id: u64, + first: Vec, + last: Vec, +} + +// ─── Packing ───────────────────────────────────────────────────────────────── + +/// Split sorted `entries` into leaves. Entries that fit in one page stay in one +/// leaf. Otherwise they are cut into the fewest leaves of about +/// [`PAGE_FILL_BYTES`] each, balanced by size, so a leaf that overflows by one +/// entry splits roughly in half (#315). +fn pack_leaves(entries: Vec) -> Result>> { + let sizes = leaf_entry_sizes(&entries); + let total: usize = sizes.iter().sum(); + if PAGE_HEADER_SIZE + total <= PAGE_SIZE { + return Ok(vec![entries]); + } + let usable = PAGE_FILL_BYTES - PAGE_HEADER_SIZE; + let mut parts = total.div_ceil(usable).max(2); + loop { + // Group `g` ends where the running size reaches (g + 1) / parts of the + // total, so cuts never drift and the last group is no larger than the + // others. Cuts move restart points, so each group is checked exactly. + let mut bounds = Vec::with_capacity(parts); + let mut running = 0usize; + for (i, size) in sizes.iter().enumerate() { + running += size; + let g = bounds.len(); + if g + 1 < parts && running * parts >= total * (g + 1) { + bounds.push(i + 1); + } + } + bounds.push(entries.len()); + let mut groups: Vec<&[Entry]> = Vec::with_capacity(parts); + let mut start = 0; + for end in bounds { + if end > start { + groups.push(entries.get(start..end).unwrap_or(&[])); + } + start = end; + } + if groups.iter().all(|g| leaf_size(g) <= PAGE_SIZE) { + return Ok(groups.into_iter().map(<[Entry]>::to_vec).collect()); + } + if parts >= entries.len() { + bail_coded!(ErrorCode::Int049, "an entry does not fit in a leaf"); + } + parts += 1; + } +} + +/// Write `entries` as one leaf at a new page id. +fn write_leaf( + entries: &[Entry], + backend: &mut dyn StorageBackend, + cache: &PageCache, + alloc: &mut PageAllocator, +) -> Result { + let page = encode_leaf(entries)?; + let id = alloc.alloc()?; + alloc.write(backend, cache, id, page)?; + Ok(id) +} + +/// Write sorted, non-empty `entries` as leaves; returns their infos. +fn emit_leaves( + entries: Vec, + out: &mut Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + alloc: &mut PageAllocator, +) -> Result<()> { + for group in pack_leaves(entries)? { + let (Some((first, _)), Some((last, _))) = (group.first(), group.last()) else { + continue; + }; + let (first, last) = (first.clone(), last.clone()); + let id = write_leaf(&group, backend, cache, alloc)?; + out.push(NodeInfo { id, first, last }); + } + Ok(()) +} + +/// Build internal levels bottom-up over `level` (nodes in key order) and return +/// the root page id. A single node is its own root. +fn build_internal_levels( + mut level: Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + alloc: &mut PageAllocator, +) -> Result { + loop { + match level.len() { + 0 => bail_coded!(ErrorCode::Int049, "build_internal_levels: no nodes"), + 1 => { + return level + .pop() + .map(|n| n.id) + .ok_or_else(|| err_coded!(ErrorCode::Int049)); + } + _ => {} + } + let mut next: Vec = Vec::new(); + let mut nodes = level.into_iter().peekable(); + while let Some(first) = nodes.next() { + let mut children = vec![first.id]; + let mut seps: Vec> = Vec::new(); + let mut size = internal_size(std::iter::empty()); + let (node_first, mut node_last) = (first.first, first.last); + while let Some(n) = nodes.peek() { + let sep = shortest_separator(&node_last, &n.first)?; + let grown = size + internal_size([sep.as_slice()]) - internal_size([]); + if grown > PAGE_FILL_BYTES && !seps.is_empty() { + break; + } + let Some(n) = nodes.next() else { break }; + size = grown; + seps.push(sep); + children.push(n.id); + node_last = n.last; + } + let page = encode_internal(&children, &seps)?; + let id = alloc.alloc()?; + alloc.write(backend, cache, id, page)?; + next.push(NodeInfo { + id, + first: node_first, + last: node_last, + }); + } + level = next; + } +} + +/// Build a B+tree from sorted entries with distinct keys. Pages come from +/// `alloc` and are stamped with its generation. An empty input is one empty +/// leaf. Returns the root page id. +pub fn build_btree( + sorted: Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + alloc: &mut PageAllocator, +) -> Result { + check_sorted(&sorted)?; + if sorted.is_empty() { + return write_leaf(&[], backend, cache, alloc); + } + let mut leaves = Vec::new(); + emit_leaves(sorted, &mut leaves, backend, cache, alloc)?; + build_internal_levels(leaves, backend, cache, alloc) +} + +fn check_sorted(entries: &[Entry]) -> Result<()> { + for w in entries.windows(2) { + if let [(a, _), (b, _)] = w + && a >= b + { + bail_coded!(ErrorCode::Int049, "entries not sorted and distinct"); + } + } + Ok(()) +} + +/// Merge two sorted entry lists. An entry present in both (same key and value) +/// is kept once; the same key with different values is INT-049. +fn merge_entries(old: Vec, new: Vec) -> Result> { + let mut out = Vec::with_capacity(old.len() + new.len()); + let mut a = old.into_iter().peekable(); + let mut b = new.into_iter().peekable(); + loop { + let take_a = match (a.peek(), b.peek()) { + (None, None) => return Ok(out), + (Some(_), None) => true, + (None, Some(_)) => false, + (Some((ka, va)), Some((kb, vb))) => match ka.cmp(kb) { + std::cmp::Ordering::Less => true, + std::cmp::Ordering::Greater => false, + std::cmp::Ordering::Equal => { + if va != vb { + bail_coded!(ErrorCode::Int049, "one key with two values"); + } + b.next(); + true + } + }, + }; + let next = if take_a { a.next() } else { b.next() }; + out.extend(next); + } +} + +/// Rebuild a B+tree from its old leaves (in key order) plus sorted `pending` +/// entries with distinct keys. +/// +/// A pending key routes to leaf `i` when `first_key[i] <= key < first_key[i + 1]` +/// (keys below the first leaf go to leaf 0). Leaves that receive nothing are +/// copied verbatim apart from the page id, generation and CRC; the others are +/// decoded, merged and repacked. Internal levels are rebuilt. With no non-empty +/// old leaf this is a bulk build. Returns the root page id. +pub fn rebuild_btree_incremental( + old_leaves: Vec>>, + pending: Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + alloc: &mut PageAllocator, +) -> Result { + check_sorted(&pending)?; + let mut leaves: Vec<(Arc>, Vec)> = Vec::with_capacity(old_leaves.len()); + for page in old_leaves { + if let Some(first) = leaf_first_key(&page[..])? { + leaves.push((page, first)); + } + } + if leaves.is_empty() { + return build_btree(pending, backend, cache, alloc); + } + let mut infos: Vec = Vec::new(); + let mut pending = pending.into_iter().peekable(); + for (i, (page, first)) in leaves.iter().enumerate() { + let upper = leaves.get(i + 1).map(|(_, k)| k); + let mut batch = Vec::new(); + while let Some((key, _)) = pending.peek() { + if upper.is_some_and(|u| key >= u) { + break; + } + batch.extend(pending.next()); + } + let old = decode_leaf(&page[..])?; + if batch.is_empty() { + let last = old + .last() + .map(|(k, _)| k.clone()) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "non-empty leaf without entries"))?; + let id = alloc.alloc()?; + alloc.write(backend, cache, id, (**page).clone())?; + infos.push(NodeInfo { + id, + first: first.clone(), + last, + }); + } else { + emit_leaves( + merge_entries(old, batch)?, + &mut infos, + backend, + cache, + alloc, + )?; + } + } + build_internal_levels(infos, backend, cache, alloc) +} + +// ─── Reading ───────────────────────────────────────────────────────────────── + +/// Collect the leaf pages of the tree at `root`, in key order, each checked by +/// a full decode. Every node visited is also pushed onto `nodes_out` (when +/// given), so a checkpoint can free the whole old tree. +pub fn collect_leaf_pages( + root: u64, + backend: &dyn StorageBackend, + cache: &PageCache, + mut nodes_out: Option<&mut Vec>, +) -> Result>>> { + // A tree cannot have more nodes than the file has pages; more visits + // means a cycle in the child pointers. + let max_visits = backend.page_count()?; + let mut visits: u64 = 0; + let mut leaves = Vec::new(); + let mut stack = vec![(root, 0usize)]; + while let Some((page_id, depth)) = stack.pop() { + visits = visits.saturating_add(1); + if depth > MAX_TREE_DEPTH || visits > max_visits { + bail_coded!( + ErrorCode::Int049, + format!("collect_leaf_pages: cycle or corruption at page_id={page_id}") + ); + } + let page = cache.get_or_load(page_id, backend)?; + if let Some(nodes) = nodes_out.as_deref_mut() { + nodes.push(page_id); + } + match page.first().copied() { + Some(PAGE_TYPE_LEAF) => { + // Untouched leaves are copied and resealed, so damage the CRC + // cannot see must be caught here, not carried forward. + decode_leaf(&page[..])?; + leaves.push(page); + } + Some(PAGE_TYPE_INTERNAL) => { + let children = Internal::new(&page[..])?.children()?; + stack.extend(children.into_iter().rev().map(|c| (c, depth + 1))); + } + _ => bail_coded!(ErrorCode::Stg013, page_id), + } + } + Ok(leaves) +} + +/// One internal node on the cursor's path from the root. +struct Frame { + page: Arc>, + /// Index into the node's children of the subtree the cursor is in; + /// `count` means `rightmost_child`. + child_idx: usize, + count: usize, +} + +impl Frame { + fn node(&self) -> Result> { + Internal::new(&self.page[..]) + } +} + +/// Forward cursor over a B+tree's entries in key order. +/// +/// Holds the path from the root as a stack of internal nodes, so moving to the +/// next leaf or seeking forward re-reads only the pages below the lowest +/// ancestor that still covers the target. +pub(crate) struct LeafCursor<'a> { + backend: &'a dyn StorageBackend, + cache: &'a PageCache, + stack: Vec, + entries: Vec, + pos: usize, + /// Set once the cursor has moved past the last leaf. + done: bool, +} + +impl<'a> LeafCursor<'a> { + /// A cursor positioned before the first entry `>= start` (`None`: the first). + pub(crate) fn new( + root: u64, + start: Option<&[u8]>, + backend: &'a dyn StorageBackend, + cache: &'a PageCache, + ) -> Result { + let mut cursor = LeafCursor { + backend, + cache, + stack: Vec::new(), + entries: Vec::new(), + pos: 0, + done: false, + }; + cursor.descend(root, start)?; + Ok(cursor) + } + + /// The next entry in key order, or `None` past the last one. + pub(crate) fn next_ref(&mut self) -> Result> { + loop { + if self.done { + return Ok(None); + } + if self.pos < self.entries.len() { + self.pos += 1; + return Ok(self.entries.get(self.pos - 1)); + } + if !self.advance_leaf()? { + return Ok(None); + } + } + } + + /// Like [`next_ref`](Self::next_ref), but clones the entry. + #[cfg(test)] + pub(crate) fn next(&mut self) -> Result> { + Ok(self.next_ref()?.cloned()) + } + + /// Move forward to the first entry `>= key`. A key at or before the current + /// position leaves the cursor where it is. + // Only tests call this until the streaming engine's joins (#432) do. + #[allow(dead_code)] + pub(crate) fn seek(&mut self, key: &[u8]) -> Result<()> { + if self.done { + return Ok(()); + } + if let Some((next_key, _)) = self.entries.get(self.pos) + && key <= next_key.as_slice() + { + return Ok(()); + } + // Find the lowest node whose range covers `key`. The child a frame is + // in ends at that frame's separator `child_idx`; a rightmost child + // inherits its parent's bound. `reroute` is the frame to route `key` in + // again; `stack.len()` means the current leaf covers it. + let mut reroute = self.stack.len(); + for (d, f) in self.stack.iter().enumerate().rev() { + if f.child_idx < f.count { + if key < f.node()?.sep(f.child_idx)? { + break; + } + reroute = d; + } + } + if reroute == self.stack.len() { + let p = self.entries.partition_point(|(k, _)| k.as_slice() < key); + self.pos = self.pos.max(p); + return Ok(()); + } + self.stack.truncate(reroute + 1); + let top = self + .stack + .last_mut() + .ok_or_else(|| err_coded!(ErrorCode::Int049, "seek: empty cursor stack"))?; + let (c, child) = { + let node = top.node()?; + let c = node.route(key, top.child_idx)?; + (c, node.child(c)?) + }; + top.child_idx = c; + self.descend(child, Some(key)) + } + + /// Move to the start of the next leaf. Returns false past the last leaf. + fn advance_leaf(&mut self) -> Result { + loop { + let Some(top) = self.stack.last_mut() else { + self.done = true; + self.entries.clear(); + self.pos = 0; + return Ok(false); + }; + if top.child_idx < top.count { + top.child_idx += 1; + let child = top.node()?.child(top.child_idx)?; + self.descend(child, None)?; + return Ok(true); + } + self.stack.pop(); + } + } + + /// Descend from `page_id` to the leaf that holds `key`, or to the leftmost + /// leaf when `key` is `None`. + fn descend(&mut self, page_id: u64, key: Option<&[u8]>) -> Result<()> { + let mut page_id = page_id; + loop { + if self.stack.len() >= MAX_TREE_DEPTH { + bail_coded!( + ErrorCode::Int049, + format!("tree deeper than {MAX_TREE_DEPTH} at page_id={page_id}") + ); + } + let page = self.cache.get_or_load(page_id, self.backend)?; + match page.first().copied() { + Some(PAGE_TYPE_LEAF) => { + self.entries = decode_leaf(&page[..])?; + self.pos = key.map_or(0, |k| { + self.entries.partition_point(|(e, _)| e.as_slice() < k) + }); + return Ok(()); + } + Some(PAGE_TYPE_INTERNAL) => { + let node = Internal::new(&page[..])?; + let child_idx = match key { + Some(k) => node.route(k, 0)?, + None => 0, + }; + let count = node.count(); + page_id = node.child(child_idx)?; + self.stack.push(Frame { + page, + child_idx, + count, + }); + } + _ => bail_coded!(ErrorCode::Stg013, page_id), + } + } + } +} + +/// Every entry whose key starts with `prefix`, in key order. +pub fn prefix_scan( + root: u64, + prefix: &[u8], + backend: &dyn StorageBackend, + cache: &PageCache, +) -> Result> { + let mut cursor = LeafCursor::new(root, Some(prefix), backend, cache)?; + let mut out = Vec::new(); + while let Some((k, v)) = cursor.next_ref()? { + if !k.starts_with(prefix) { + break; + } + out.push((k.clone(), v.clone())); + } + Ok(out) +} + +/// The value stored under `key`, if any. +pub fn get( + root: u64, + key: &[u8], + backend: &dyn StorageBackend, + cache: &PageCache, +) -> Result>> { + let mut page_id = root; + for _ in 0..MAX_TREE_DEPTH { + let page = cache.get_or_load(page_id, backend)?; + match page.first().copied() { + Some(PAGE_TYPE_LEAF) => return leaf_get(&page[..], key), + Some(PAGE_TYPE_INTERNAL) => { + let node = Internal::new(&page[..])?; + page_id = node.child(node.route(key, 0)?)?; + } + _ => bail_coded!(ErrorCode::Stg013, page_id), + } + } + bail_coded!( + ErrorCode::Int049, + format!("tree deeper than {MAX_TREE_DEPTH} below page_id={root}") + ) +} + +/// Every entry of the tree, in key order. +#[cfg(test)] +pub fn stream_all_entries( + root: u64, + backend: &dyn StorageBackend, + cache: &PageCache, +) -> Result> { + prefix_scan(root, &[], backend, cache) +} + +// ─── MutexStorageBackend ───────────────────────────────────────────────────── + +/// Read-only [`StorageBackend`] adapter that locks `Arc>` only for the +/// duration of one [`StorageBackend::read_page`] call, so readers hold the +/// backend lock only while a cold page is read. Every other method fails. +pub(crate) struct MutexStorageBackend(pub(crate) Arc>); + +impl StorageBackend for MutexStorageBackend { + fn read_page(&self, page_id: u64) -> Result> { + self.0 + .lock() + .map_err(|_| err_coded!(ErrorCode::Int050, "MutexStorageBackend"))? + .read_page(page_id) + } + + fn write_page(&mut self, _page_id: u64, _data: &[u8]) -> Result<()> { + bail_coded!(ErrorCode::Int049, "MutexStorageBackend is read-only") + } + + fn sync(&mut self) -> Result<()> { + bail_coded!(ErrorCode::Int049, "MutexStorageBackend is read-only") + } + + fn page_count(&self) -> Result { + self.0 + .lock() + .map_err(|_| err_coded!(ErrorCode::Int050, "MutexStorageBackend"))? + .page_count() + } + + fn close(&mut self) -> Result<()> { + bail_coded!(ErrorCode::Int049, "MutexStorageBackend is read-only") + } + + fn backend_name(&self) -> &'static str { + "mutex-adapter" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::backend::MemoryBackend; + use crate::storage::keys::{Index, KeyFact, KeyValue}; + use crate::storage::node::RIGHTMOST_CHILD_OFFSET; + + /// An EAVT key for entity `n`: real-shaped keys with shared prefixes. + fn key(n: u64, tx: u64) -> Vec { + KeyFact { + e: n, + a: 3, + v: KeyValue::Int(i64::try_from(n % 1000).unwrap()), + tx_count: tx, + vf: 1_700_000_000_000, + vt: i64::MAX, + asserted: true, + } + .key(Index::Eavt) + .unwrap() + } + + fn entry(n: u64, tx: u64) -> Entry { + (key(n, tx), Vec::new()) + } + + struct Rng(u64); + impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + fn below(&mut self, n: u64) -> usize { + usize::try_from(self.next() % n).unwrap() + } + } + + /// `n` entries with random entities; `counter` keeps keys unique. + fn random_entries(rng: &mut Rng, n: usize, counter: &mut u64) -> Vec { + let mut v: Vec = (0..n) + .map(|_| { + *counter += 1; + entry(rng.next() >> 20, *counter) + }) + .collect(); + v.sort(); + v.dedup_by(|a, b| a.0 == b.0); + v + } + + fn sorted_union(a: &[Entry], b: &[Entry]) -> Vec { + let mut v: Vec = a.iter().chain(b).cloned().collect(); + v.sort(); + v.dedup(); + v + } + + fn build_at( + entries: Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + start: u64, + ) -> (u64, u64) { + let mut alloc = PageAllocator::new(Vec::new(), start, 1); + let root = build_btree(entries, backend, cache, &mut alloc).unwrap(); + (root, alloc.next_append()) + } + + fn rebuild_at( + leaves: Vec>>, + pending: Vec, + backend: &mut dyn StorageBackend, + cache: &PageCache, + start: u64, + ) -> (u64, u64) { + let mut alloc = PageAllocator::new(Vec::new(), start, 1); + let root = rebuild_btree_incremental(leaves, pending, backend, cache, &mut alloc).unwrap(); + (root, alloc.next_append()) + } + + fn build_then_incremental( + committed: &[Entry], + pending: &[Entry], + start: u64, + ) -> (MemoryBackend, PageCache, u64) { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let (old_root, _) = build_at(committed.to_vec(), &mut backend, &cache, 2); + let leaves = collect_leaf_pages(old_root, &backend, &cache, None).unwrap(); + let (root, _) = rebuild_at(leaves, pending.to_vec(), &mut backend, &cache, start); + (backend, cache, root) + } + + /// Stream equality, no empty leaf in a non-empty tree, and prefix scans + /// that agree with the expected set. + fn assert_tree_exact( + root: u64, + backend: &MemoryBackend, + cache: &PageCache, + expected: &[Entry], + rng: &mut Rng, + ) { + let got = stream_all_entries(root, backend, cache).unwrap(); + assert_eq!(got.len(), expected.len(), "entry count differs"); + assert!(got == expected, "streamed entries differ from expected"); + let leaves = collect_leaf_pages(root, backend, cache, None).unwrap(); + if expected.is_empty() { + assert_eq!(leaves.len(), 1, "empty tree is one empty leaf"); + } else { + for page in &leaves { + assert!( + !decode_leaf(&page[..]).unwrap().is_empty(), + "empty leaf in tree" + ); + } + } + for _ in 0..20 { + if expected.is_empty() { + break; + } + let (k, v) = &expected[rng.below(u64::try_from(expected.len()).unwrap())]; + assert_eq!( + get(root, k, backend, cache).unwrap().as_ref(), + Some(v), + "get" + ); + let prefix = &k[..k.len().min(3)]; + let want: Vec = expected + .iter() + .filter(|(e, _)| e.starts_with(prefix)) + .cloned() + .collect(); + assert!( + prefix_scan(root, prefix, backend, cache).unwrap() == want, + "prefix scan differs" + ); + } + } + + fn leaf_ids(root: u64, backend: &dyn StorageBackend, cache: &PageCache) -> Vec { + let mut ids = Vec::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + let page = cache.get_or_load(id, backend).unwrap(); + if page[0] == PAGE_TYPE_LEAF { + ids.push(id); + } else { + let children = Internal::new(&page[..]).unwrap().children().unwrap(); + stack.extend(children.into_iter().rev()); + } + } + ids + } + + fn leaf_keys(id: u64, backend: &dyn StorageBackend, cache: &PageCache) -> Vec> { + let page = cache.get_or_load(id, backend).unwrap(); + decode_leaf(&page[..]) + .unwrap() + .into_iter() + .map(|(k, _)| k) + .collect() + } + + fn multi_leaf_tree() -> (MemoryBackend, PageCache, u64, Vec) { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries: Vec = (0..2000).map(|n| entry(n, n)).collect(); + let (root, _) = build_at(entries, &mut backend, &cache, 2); + let ids = leaf_ids(root, &backend, &cache); + assert!(ids.len() >= 3, "fixture needs several leaves"); + (backend, cache, root, ids) + } + + /// Replace page `id` with `page`, resealed so only its content is wrong. + fn overwrite_page(backend: &mut MemoryBackend, cache: &PageCache, id: u64, page: Vec) { + let mut page = page; + crate::storage::page::seal(&mut page, id, 1).unwrap(); + backend.write_page(id, &page).unwrap(); + cache.put_dirty(id, page); + } + + #[test] + fn build_empty_and_single() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(64); + let (root, next) = build_at(Vec::new(), &mut backend, &cache, 2); + assert_eq!((root, next), (2, 3), "empty tree is one leaf"); + assert!( + stream_all_entries(root, &backend, &cache) + .unwrap() + .is_empty() + ); + let (root, _) = build_at(vec![entry(1, 1)], &mut backend, &cache, 3); + assert_eq!( + stream_all_entries(root, &backend, &cache).unwrap(), + vec![entry(1, 1)] + ); + } + + #[test] + fn build_spans_levels_and_seals_every_page() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(0); + let entries: Vec = (0..60_000).map(|n| entry(n * 7, n)).collect(); + let (root, next) = build_at(entries.clone(), &mut backend, &cache, 2); + let root_page = backend.read_page(root).unwrap(); + let below = Internal::new(&root_page).unwrap().child(0).unwrap(); + assert_eq!( + backend.read_page(below).unwrap()[0], + PAGE_TYPE_INTERNAL, + "60k entries need depth 3" + ); + for id in 2..next { + let page = backend.read_page(id).unwrap(); + crate::storage::page::verify(&page, id, 1).unwrap(); + assert!(page.len() == PAGE_SIZE); + } + assert!(stream_all_entries(root, &backend, &cache).unwrap() == entries); + } + + #[test] + fn build_rejects_unsorted_or_duplicate_input() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(0); + let mut alloc = PageAllocator::new(Vec::new(), 2, 1); + let dup = vec![entry(1, 1), entry(1, 1)]; + assert!(build_btree(dup, &mut backend, &cache, &mut alloc).is_err()); + let unsorted = vec![entry(2, 1), entry(1, 1)]; + assert!(build_btree(unsorted, &mut backend, &cache, &mut alloc).is_err()); + } + + #[test] + fn bulk_leaves_are_filled_to_target() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries: Vec = (0..20_000).map(|n| entry(n, n)).collect(); + let (root, _) = build_at(entries, &mut backend, &cache, 2); + let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); + for page in &leaves[..leaves.len() - 1] { + let size = leaf_size(&decode_leaf(&page[..]).unwrap()); + assert!(size <= PAGE_SIZE, "leaf fits"); + assert!(size >= PAGE_FILL_BYTES / 2, "leaf at least half the target"); + } + } + + #[test] + fn collect_leaf_pages_rejects_cycles_and_corrupt_leaves() { + let (mut backend, cache, root, ids) = multi_leaf_tree(); + let mut page = cache.get_or_load(root, &backend).unwrap().to_vec(); + assert_eq!(page[0], PAGE_TYPE_INTERNAL, "fixture root must be internal"); + page[RIGHTMOST_CHILD_OFFSET..RIGHTMOST_CHILD_OFFSET + 8] + .copy_from_slice(&root.to_le_bytes()); + overwrite_page(&mut backend, &cache, root, page); + assert!( + collect_leaf_pages(root, &backend, &cache, None).is_err(), + "cycle" + ); + assert!( + stream_all_entries(root, &backend, &cache).is_err(), + "cursor cycle" + ); + + let (mut backend, cache, root, ids2) = multi_leaf_tree(); + let mut page = cache.get_or_load(ids2[1], &backend).unwrap().to_vec(); + page[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); + overwrite_page(&mut backend, &cache, ids2[1], page); + assert!( + collect_leaf_pages(root, &backend, &cache, None).is_err(), + "a count beyond the data is rejected before the leaf is copied" + ); + assert!(ids.len() >= 3); + } + + #[test] + fn child_pointing_at_non_btree_page_is_stg_013() { + let (mut backend, cache, root, ids) = multi_leaf_tree(); + let mut page = cache.get_or_load(ids[0], &backend).unwrap().to_vec(); + page[0] = crate::storage::page::PAGE_TYPE_FREELIST; + overwrite_page(&mut backend, &cache, ids[0], page); + let err = stream_all_entries(root, &backend, &cache).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-013"); + } + + #[test] + fn incremental_without_pending_copies_leaves_verbatim() { + let mut rng = Rng(0x315); + let mut ctr = 0; + let committed = random_entries(&mut rng, 3000, &mut ctr); + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let (old_root, old_next) = build_at(committed.clone(), &mut backend, &cache, 2); + let old_leaves = collect_leaf_pages(old_root, &backend, &cache, None).unwrap(); + let (root, _) = rebuild_at( + old_leaves.clone(), + Vec::new(), + &mut backend, + &cache, + old_next, + ); + let new_leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); + assert_eq!(new_leaves.len(), old_leaves.len(), "leaf count changed"); + for (o, n) in old_leaves.iter().zip(&new_leaves) { + assert!(o[..4] == n[..4], "leaf type/count changed"); + assert!( + o[PAGE_HEADER_SIZE..] == n[PAGE_HEADER_SIZE..], + "leaf body changed" + ); + } + assert_tree_exact(root, &backend, &cache, &committed, &mut rng); + } + + #[test] + fn incremental_matches_merge_random() { + let mut rng = Rng(0xC0FFEE); + let mut ctr = 0; + for _ in 0..40 { + let (nc, np) = (rng.below(4000), rng.below(300)); + let committed = random_entries(&mut rng, nc, &mut ctr); + let pending = random_entries(&mut rng, np, &mut ctr); + let (backend, cache, root) = build_then_incremental(&committed, &pending, 20_000); + let expected = sorted_union(&committed, &pending); + assert_tree_exact(root, &backend, &cache, &expected, &mut rng); + } + } + + #[test] + fn incremental_pending_outside_old_range_and_into_one_leaf() { + let mut rng = Rng(7); + let committed: Vec = (1000..3000).map(|n| entry(n, n)).collect(); + let pending = vec![entry(1, 1), entry(2, 2), entry(9_000, 9), entry(9_001, 9)]; + let (backend, cache, root) = build_then_incremental(&committed, &pending, 20_000); + assert_tree_exact( + root, + &backend, + &cache, + &sorted_union(&committed, &pending), + &mut rng, + ); + + // Committed 0, 10_000, …: every pending 0 < e < 10_000 routes to leaf 0, + // which must split into several balanced leaves. + let committed: Vec = (0..2000).map(|n| entry(n * 10_000, n)).collect(); + let pending: Vec = (1..3000).map(|n| entry(n, 100_000 + n)).collect(); + let (backend, cache, root) = build_then_incremental(&committed, &pending, 20_000); + assert_tree_exact( + root, + &backend, + &cache, + &sorted_union(&committed, &pending), + &mut rng, + ); + } + + #[test] + fn incremental_empty_old_tree_is_a_bulk_build() { + let mut rng = Rng(13); + let mut ctr = 0; + let pending = random_entries(&mut rng, 500, &mut ctr); + let (backend, cache, root) = build_then_incremental(&[], &pending, 20_000); + assert_tree_exact(root, &backend, &cache, &pending, &mut rng); + let (backend, cache, root) = build_then_incremental(&[], &[], 20_000); + assert_tree_exact(root, &backend, &cache, &[], &mut rng); + } + + #[test] + fn incremental_keeps_shared_entries_once_and_rejects_conflicts() { + let committed: Vec = (0..100).map(|n| entry(n, n)).collect(); + let (backend, cache, root) = build_then_incremental(&committed, &committed[10..20], 5000); + assert_tree_exact(root, &backend, &cache, &committed, &mut Rng(3)); + + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(64); + let old = vec![(b"k".to_vec(), b"1".to_vec())]; + let (root, next) = build_at(old, &mut backend, &cache, 2); + let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); + let mut alloc = PageAllocator::new(Vec::new(), next, 1); + let conflict = vec![(b"k".to_vec(), b"2".to_vec())]; + let err = rebuild_btree_incremental(leaves, conflict, &mut backend, &cache, &mut alloc) + .unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + } + + #[test] + fn incremental_single_inserts_do_not_fragment_leaves() { + // One random insert per round, like one checkpoint per new fact: leaves + // must stay reasonably full instead of leaving a remainder each time. + let mut rng = Rng(23); + let mut ctr = 0; + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let mut expected = random_entries(&mut rng, 3000, &mut ctr); + let (mut root, _) = build_at(expected.clone(), &mut backend, &cache, 2); + let bulk = collect_leaf_pages(root, &backend, &cache, None) + .unwrap() + .len(); + let mut start = 10_000; + for _ in 0..600 { + let pending = random_entries(&mut rng, 1, &mut ctr); + let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); + let (r, next) = rebuild_at(leaves, pending.clone(), &mut backend, &cache, start); + root = r; + start = next; + expected = sorted_union(&expected, &pending); + } + assert_tree_exact(root, &backend, &cache, &expected, &mut rng); + let leaves = collect_leaf_pages(root, &backend, &cache, None) + .unwrap() + .len(); + assert!( + leaves <= bulk * 5 / 2, + "leaf count grew from {bulk} to {leaves}" + ); + } + + #[test] + fn incremental_repeated_rounds() { + let mut rng = Rng(19); + let mut ctr = 0; + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let mut expected = random_entries(&mut rng, 1500, &mut ctr); + let (mut root, mut start) = build_at(expected.clone(), &mut backend, &cache, 2); + for _ in 0..30 { + let n = 1 + rng.below(150); + let pending = random_entries(&mut rng, n, &mut ctr); + let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); + let (r, next) = rebuild_at(leaves, pending.clone(), &mut backend, &cache, start); + root = r; + start = next; + expected = sorted_union(&expected, &pending); + assert_tree_exact(root, &backend, &cache, &expected, &mut rng); + } + } + + #[test] + fn dict_shaped_entries_with_values_round_trip() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries: Vec = (0u64..3000) + .map(|n| { + let mut k = vec![0x04]; + crate::storage::keys::put_uint(&mut k, n); + (k, vec![b'x'; usize::try_from(n % 1024).unwrap()]) + }) + .collect(); + let (root, _) = build_at(entries.clone(), &mut backend, &cache, 2); + assert!(stream_all_entries(root, &backend, &cache).unwrap() == entries); + for (k, v) in entries.iter().step_by(97) { + assert_eq!(get(root, k, &backend, &cache).unwrap().as_ref(), Some(v)); + } + assert_eq!(get(root, &[0x05], &backend, &cache).unwrap(), None); + } + + // ─── LeafCursor ────────────────────────────────────────────────────────── + + /// `MemoryBackend` that counts `read_page` calls, for seek cost tests. + struct CountingBackend { + inner: MemoryBackend, + reads: std::sync::atomic::AtomicU64, + } + + impl CountingBackend { + fn reads(&self) -> u64 { + self.reads.load(std::sync::atomic::Ordering::Relaxed) + } + fn reset(&self) { + self.reads.store(0, std::sync::atomic::Ordering::Relaxed); + } + } + + impl StorageBackend for CountingBackend { + fn write_page(&mut self, page_id: u64, data: &[u8]) -> Result<()> { + self.inner.write_page(page_id, data) + } + fn read_page(&self, page_id: u64) -> Result> { + self.reads + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.inner.read_page(page_id) + } + fn sync(&mut self) -> Result<()> { + self.inner.sync() + } + fn page_count(&self) -> Result { + self.inner.page_count() + } + fn close(&mut self) -> Result<()> { + self.inner.close() + } + fn backend_name(&self) -> &'static str { + "counting" + } + } + + /// Entities `0, 2, 4, ...` so every odd entity is absent. + fn even_entries(n: u64) -> Vec { + (0..n).map(|i| entry(i * 2, 1)).collect() + } + + fn key_for(entity: u64) -> Vec { + key(entity, 1) + } + + fn drain(cursor: &mut LeafCursor<'_>) -> Vec { + let mut v = Vec::new(); + while let Some(e) = cursor.next().unwrap() { + v.push(e); + } + v + } + + #[test] + fn cursor_full_scan_matches_input() { + for n in [0u64, 1, 10, 500, 20_000] { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries = even_entries(n); + let (root, _) = build_at(entries.clone(), &mut backend, &cache, 2); + let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); + assert!( + drain(&mut cursor) == entries, + "cursor scan differs from input" + ); + } + } + + #[test] + fn cursor_start_key_positions_at_first_ge() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries = even_entries(5_000); + let (root, _) = build_at(entries.clone(), &mut backend, &cache, 2); + let leaves = leaf_ids(root, &backend, &cache); + assert!(leaves.len() >= 3, "fixture needs several leaves"); + let second_leaf_first = leaf_keys(leaves[1], &backend, &cache)[0].clone(); + let starts = [ + key_for(0), + key_for(1), + key_for(500), + key_for(501), + second_leaf_first, + key_for(9_998), + key_for(9_999), + ]; + for start in &starts { + let mut cursor = LeafCursor::new(root, Some(start), &backend, &cache).unwrap(); + let want = entries + .get(entries.partition_point(|(e, _)| e < start)) + .cloned(); + assert!( + cursor.next().unwrap() == want, + "first entry after start differs" + ); + } + } + + #[test] + fn cursor_seek_matches_fresh_descent_random() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let (root, _) = build_at(even_entries(20_000), &mut backend, &cache, 2); + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let mut targets: Vec = (0..2_000).map(|_| rng.next() % 40_010).collect(); + targets.sort_unstable(); + let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); + for t in targets { + let k = key_for(t); + cursor.seek(&k).unwrap(); + let got = cursor.next().unwrap(); + let want = LeafCursor::new(root, Some(&k), &backend, &cache) + .unwrap() + .next() + .unwrap(); + assert!(got == want, "seek differs from a fresh descent"); + cursor = LeafCursor::new(root, Some(&k), &backend, &cache).unwrap(); + } + } + + #[test] + fn cursor_seek_sequence_without_reset_visits_ascending_entries() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries = even_entries(20_000); + let (root, _) = build_at(entries.clone(), &mut backend, &cache, 2); + let mut rng = Rng(0xD1B5_4A32_D192_ED03); + let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); + let mut consumed = 0usize; + let mut target = 0u64; + while consumed < entries.len() { + target += rng.next() % 200; + let k = key_for(target); + cursor.seek(&k).unwrap(); + let expect = consumed.max(entries.partition_point(|(e, _)| *e < k)); + assert!( + cursor.next().unwrap() == entries.get(expect).cloned(), + "seek/next differs" + ); + consumed = expect + 1; + } + assert!( + cursor.next().unwrap().is_none(), + "cursor ends after the last entry" + ); + } + + #[test] + fn cursor_seek_within_leaf_sibling_across_subtrees_gap_and_end() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(4096); + let entries = even_entries(20_000); + let (root, _) = build_at(entries, &mut backend, &cache, 2); + let leaves = leaf_ids(root, &backend, &cache); + assert!(leaves.len() > 20, "fixture needs many leaves"); + let l0 = leaf_keys(leaves[0], &backend, &cache); + let sibling = leaf_keys(leaves[1], &backend, &cache)[0].clone(); + let far = leaf_keys(leaves[leaves.len() - 2], &backend, &cache)[0].clone(); + let mut cursor = LeafCursor::new(root, Some(&l0[0]), &backend, &cache).unwrap(); + for target in [l0.last().unwrap(), &sibling, &far] { + cursor.seek(target).unwrap(); + assert!( + cursor.next().unwrap().unwrap().0 == *target, + "seek lands on the target" + ); + } + + // A key in the gap after a leaf's last entry lands on the next leaf. + let l2_last = leaf_keys(leaves[2], &backend, &cache) + .last() + .unwrap() + .clone(); + let l3_first = leaf_keys(leaves[3], &backend, &cache)[0].clone(); + let mut gap = l2_last.clone(); + gap.push(0); + assert!(gap > l2_last && gap < l3_first); + let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); + cursor.seek(&gap).unwrap(); + assert!(cursor.next().unwrap().unwrap().0 == l3_first, "gap seek"); + + // Backwards is a no-op; past the end stays at the end. + cursor.seek(&key_for(12_000)).unwrap(); + cursor.seek(&key_for(10)).unwrap(); + assert!( + cursor.next().unwrap().unwrap().0 == key_for(12_000), + "backward seek" + ); + cursor.seek(&key_for(1_000_000)).unwrap(); + assert!(cursor.next().unwrap().is_none(), "past the end"); + cursor.seek(&key_for(2_000_000)).unwrap(); + assert!(cursor.next().unwrap().is_none(), "still at the end"); + } + + #[test] + fn cursor_seek_to_next_leaf_reads_one_page() { + let mut backend = CountingBackend { + inner: MemoryBackend::new(), + reads: std::sync::atomic::AtomicU64::new(0), + }; + let cache = PageCache::new(0); + let (root, _) = build_at(even_entries(20_000), &mut backend, &cache, 2); + let leaves = leaf_ids(root, &backend, &cache); + let first = leaf_keys(leaves[0], &backend, &cache)[0].clone(); + let next_first = leaf_keys(leaves[1], &backend, &cache)[0].clone(); + let mut cursor = LeafCursor::new(root, Some(&first), &backend, &cache).unwrap(); + backend.reset(); + cursor.seek(&next_first).unwrap(); + assert_eq!( + backend.reads(), + 1, + "seek to the sibling reads only that leaf" + ); + assert!(cursor.next().unwrap().unwrap().0 == next_first); + } + + #[test] + #[cfg(not(target_os = "wasi"))] + fn concurrent_prefix_scans_agree() { + use std::sync::Barrier; + use std::thread; + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(16); + let entries = even_entries(5_000); + let (root, _) = build_at(entries, &mut backend, &cache, 2); + let shared = Arc::new(Mutex::new(backend)); + let cache = Arc::new(cache); + let barrier = Arc::new(Barrier::new(8)); + let prefix = crate::storage::keys::entity_prefix(4_000); + let handles: Vec<_> = (0..8) + .map(|_| { + let (s, c, b, p) = ( + Arc::clone(&shared), + Arc::clone(&cache), + Arc::clone(&barrier), + prefix.clone(), + ); + thread::spawn(move || { + b.wait(); + let adapter = MutexStorageBackend(s); + prefix_scan(root, &p, &adapter, &c).unwrap().len() + }) + }) + .collect(); + for h in handles { + assert_eq!(h.join().unwrap(), 1, "entity 4000 has one entry"); + } + } + + #[test] + fn largest_entries_fit() { + // The largest DICT entry (tag + 1024-byte ident key) beside others. + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(64); + let entries: Vec = (0u8..20) + .map(|i| { + let mut k = vec![0x03, i]; + k.extend(vec![b'k'; crate::storage::keys::MAX_IDENT_BYTES - 1]); + (k, crate::storage::keys::uint_bytes(u64::from(i))) + }) + .collect(); + let (root, _) = build_at(entries.clone(), &mut backend, &cache, 2); + assert!(stream_all_entries(root, &backend, &cache).unwrap() == entries); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 59be7a33b..2107ce778 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -6,6 +6,7 @@ /// /// Inspired by SQLite's VFS (Virtual File System) architecture. pub mod backend; +pub(crate) mod btree; pub mod btree_v6; pub mod cache; pub(crate) mod dir_sync; diff --git a/src/storage/node.rs b/src/storage/node.rs index 1ff105da9..9a0d8c11f 100644 --- a/src/storage/node.rs +++ b/src/storage/node.rs @@ -123,20 +123,25 @@ fn leaf_entry_len(shared: usize, key: &[u8], value: &[u8]) -> usize { varint_len(shared) + varint_len(suffix) + suffix + varint_len(value.len()) + value.len() } +/// Bytes each entry adds to a leaf holding all of `entries`, its restart-array +/// slot included. +pub fn leaf_entry_sizes(entries: &[Entry]) -> Vec { + let mut prev: &[u8] = &[]; + entries + .iter() + .enumerate() + .map(|(i, (k, v))| { + let restart = i % RESTART_INTERVAL == 0; + let shared = if restart { 0 } else { shared_len(prev, k) }; + prev = k; + leaf_entry_len(shared, k, v) + if restart { 2 } else { 0 } + }) + .collect() +} + /// Bytes a leaf holding `entries` needs, header and restart array included. pub fn leaf_size(entries: &[Entry]) -> usize { - let mut size = PAGE_HEADER_SIZE; - let mut prev: &[u8] = &[]; - for (i, (k, v)) in entries.iter().enumerate() { - let restart = i % RESTART_INTERVAL == 0; - let shared = if restart { 0 } else { shared_len(prev, k) }; - size += leaf_entry_len(shared, k, v); - if restart { - size += 2; - } - prev = k; - } - size + PAGE_HEADER_SIZE + leaf_entry_sizes(entries).iter().sum::() } /// Encode a leaf. Fails with INT-049 if the entries do not fit in a page. From 84443fef7df941e34f362b9024f90d09f52c1f20 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:17:42 +0530 Subject: [PATCH 5/9] feat(storage): append-only value pages for long strings (#433) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- src/storage/mod.rs | 1 + src/storage/value_pages.rs | 227 +++++++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/storage/value_pages.rs diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 2107ce778..b14813fa5 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod node; pub mod packed_pages; pub(crate) mod page; pub mod persistent_facts; +pub(crate) mod value_pages; use crate::error::{ErrorCode, bail_coded, err_coded}; use anyhow::Result; diff --git a/src/storage/value_pages.rs b/src/storage/value_pages.rs new file mode 100644 index 000000000..389904ab8 --- /dev/null +++ b/src/storage/value_pages.rs @@ -0,0 +1,227 @@ +//! Value pages (spec §6.3): long strings, stored once and referenced from keys. +//! +//! Layout after the common header: a directory of `(offset u16, len u16)` per +//! record, then the record bytes packed from the end of the page down. The +//! header's `count` is the number of records. Value pages are always appended +//! and never rewritten or freed, and each checkpoint that writes long values +//! starts a fresh page, so a torn write can never damage a committed value +//! (§13). + +use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::storage::PAGE_SIZE; +use crate::storage::StorageBackend; +use crate::storage::cache::PageCache; +use crate::storage::keys::{MAX_VALUE_BYTES, ValueRef}; +use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_TYPE_VALUE, PageAllocator, new_page}; +use anyhow::Result; + +const DIR_ENTRY: usize = 4; + +/// Appends long values to fresh value pages for one checkpoint. +pub struct ValueWriter { + /// The page being filled: its id and its records. + current: Option<(u64, Vec>)>, + /// Bytes the current page's records and directory use. + used: usize, +} + +impl Default for ValueWriter { + fn default() -> Self { + Self::new() + } +} + +impl ValueWriter { + pub fn new() -> Self { + ValueWriter { + current: None, + used: 0, + } + } + + /// Store `value` and return its ref. The page id is taken from `alloc` by + /// appending when a page is started; a full page is written out then. + pub fn push( + &mut self, + value: &[u8], + alloc: &mut PageAllocator, + backend: &mut dyn StorageBackend, + cache: &PageCache, + ) -> Result { + if value.len() > MAX_VALUE_BYTES { + bail_coded!( + ErrorCode::Int049, + format!("value of {} bytes exceeds {MAX_VALUE_BYTES}", value.len()) + ); + } + let need = value.len() + DIR_ENTRY; + let fits = self.current.as_ref().is_some_and(|(_, records)| { + records.len() < usize::from(u16::MAX) + && PAGE_HEADER_SIZE + self.used + need <= PAGE_SIZE + }); + if !fits { + self.flush(alloc, backend, cache)?; + self.current = Some((alloc.alloc_append()?, Vec::new())); + self.used = 0; + } + let (page, records) = self + .current + .as_mut() + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value page missing"))?; + let slot = u16::try_from(records.len()) + .map_err(|_| err_coded!(ErrorCode::Int049, "too many values in a page"))?; + records.push(value.to_vec()); + self.used += need; + Ok(ValueRef { page: *page, slot }) + } + + /// Write the page being filled, if any. + pub fn flush( + &mut self, + alloc: &mut PageAllocator, + backend: &mut dyn StorageBackend, + cache: &PageCache, + ) -> Result<()> { + let Some((id, records)) = self.current.take() else { + return Ok(()); + }; + alloc.write(backend, cache, id, encode_value_page(&records)?)?; + Ok(()) + } +} + +/// Encode a value page holding `records` in slot order. +pub fn encode_value_page(records: &[Vec]) -> Result> { + let count = u16::try_from(records.len()) + .map_err(|_| err_coded!(ErrorCode::Int049, "too many values in a page"))?; + let used = PAGE_HEADER_SIZE + records.iter().map(|r| r.len() + DIR_ENTRY).sum::(); + if used > PAGE_SIZE { + bail_coded!(ErrorCode::Int049, "value records overflow the page"); + } + let mut page = new_page(PAGE_TYPE_VALUE, count); + let mut end = PAGE_SIZE; + for (i, record) in records.iter().enumerate() { + let start = end - record.len(); + page.get_mut(start..end) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value record past the page"))? + .copy_from_slice(record); + let dir = PAGE_HEADER_SIZE + i * DIR_ENTRY; + let off = u16::try_from(start) + .map_err(|_| err_coded!(ErrorCode::Int049, "value offset exceeds u16"))?; + let len = u16::try_from(record.len()) + .map_err(|_| err_coded!(ErrorCode::Int049, "value length exceeds u16"))?; + page.get_mut(dir..dir + 2) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value directory past the page"))? + .copy_from_slice(&off.to_le_bytes()); + page.get_mut(dir + 2..dir + 4) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value directory past the page"))? + .copy_from_slice(&len.to_le_bytes()); + end = start; + } + Ok(page) +} + +/// The record in `slot` of a value page. +pub fn read_record(page: &[u8], page_id: u64, slot: u16) -> Result> { + if page.first().copied() != Some(PAGE_TYPE_VALUE) { + bail_coded!(ErrorCode::Stg013, page_id); + } + let count = crate::storage::page::page_count_field(page)?; + if slot >= count { + bail_coded!( + ErrorCode::Int049, + format!("value slot {slot} out of range on page {page_id}") + ); + } + let dir = PAGE_HEADER_SIZE + usize::from(slot) * DIR_ENTRY; + let word = |at: usize| -> Result { + page.get(at..at + 2) + .and_then(|b| <[u8; 2]>::try_from(b).ok()) + .map(|b| usize::from(u16::from_le_bytes(b))) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value directory past the page")) + }; + let (off, len) = (word(dir)?, word(dir + 2)?); + let dir_end = PAGE_HEADER_SIZE + usize::from(count) * DIR_ENTRY; + if off < dir_end { + bail_coded!(ErrorCode::Int049, "value record overlaps the directory"); + } + page.get(off..off + len) + .map(<[u8]>::to_vec) + .ok_or_else(|| err_coded!(ErrorCode::Int049, "value record past the page")) +} + +/// Read the long value at `vref` through the page cache. +pub fn read_value( + vref: ValueRef, + backend: &dyn StorageBackend, + cache: &PageCache, +) -> Result> { + let page = cache.get_or_load(vref.page, backend)?; + read_record(&page[..], vref.page, vref.slot) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::backend::MemoryBackend; + + #[test] + fn values_round_trip_across_pages() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(0); + let mut alloc = PageAllocator::new(vec![2, 3], 10, 1); + let mut writer = ValueWriter::new(); + let values: Vec> = (0..200u32) + .map(|i| { + vec![u8::try_from(i % 251).unwrap(); 65 + usize::try_from(i * 13 % 900).unwrap()] + }) + .collect(); + let refs: Vec = values + .iter() + .map(|v| writer.push(v, &mut alloc, &mut backend, &cache).unwrap()) + .collect(); + writer.flush(&mut alloc, &mut backend, &cache).unwrap(); + assert!( + refs.iter().all(|r| r.page >= 10), + "value pages always append" + ); + assert!(refs.windows(2).all(|w| w[0] < w[1]), "refs increase"); + for (v, r) in values.iter().zip(&refs) { + assert_eq!(&read_value(*r, &backend, &cache).unwrap(), v); + crate::storage::page::verify(&backend.read_page(r.page).unwrap(), r.page, 1).unwrap(); + } + } + + #[test] + fn a_maximum_size_value_fills_one_page() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(0); + let mut alloc = PageAllocator::new(Vec::new(), 2, 1); + let mut writer = ValueWriter::new(); + let max = vec![9u8; MAX_VALUE_BYTES]; + let a = writer.push(&max, &mut alloc, &mut backend, &cache).unwrap(); + let b = writer + .push(b"next", &mut alloc, &mut backend, &cache) + .unwrap(); + writer.flush(&mut alloc, &mut backend, &cache).unwrap(); + assert_ne!(a.page, b.page, "a full page is closed"); + assert_eq!(read_value(a, &backend, &cache).unwrap(), max); + let too_big = vec![0u8; MAX_VALUE_BYTES + 1]; + assert!( + writer + .push(&too_big, &mut alloc, &mut backend, &cache) + .is_err() + ); + } + + #[test] + fn bad_slot_or_page_type_is_an_error() { + let page = encode_value_page(&[b"abc".to_vec()]).unwrap(); + assert_eq!(read_record(&page, 5, 0).unwrap(), b"abc"); + let err = read_record(&page, 5, 1).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "INT-049"); + let leaf = crate::storage::node::encode_leaf(&[]).unwrap(); + let err = read_record(&leaf, 5, 0).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-013"); + } +} From 78c708e4daa76cd2d780b36071e32bda345caf5d Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:19:53 +0530 Subject: [PATCH 6/9] feat(storage): DICT reader and checkpoint-time fact encoder (#433) DictReader translates index entries back into facts through memoised DICT lookups. Encoder assigns eids and iids in pending order, records one timestamp per transaction (STG-037 on a conflict), deduplicates long values against committed and new ones, and builds sorted entries for all five trees. A missing DICT entry is STG-036. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- docs/ERROR_REFERENCE.md | 28 ++ src/error.rs | 18 ++ src/storage/dict.rs | 699 ++++++++++++++++++++++++++++++++++++++++ src/storage/mod.rs | 1 + 4 files changed, 746 insertions(+) create mode 100644 src/storage/dict.rs diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md index 0ac766c58..d07d8a38b 100644 --- a/docs/ERROR_REFERENCE.md +++ b/docs/ERROR_REFERENCE.md @@ -144,6 +144,8 @@ with no `CodedError` anywhere in its chain. | STG-033 | Meta page damaged after commit | Storage | | STG-034 | Unsupported file feature | Storage | | STG-035 | Free-list inconsistency | Storage | +| STG-036 | Dictionary entry missing | Storage | +| STG-037 | Transaction with two timestamps | Storage | | WAL-001 | Invalid WAL magic number | WAL | | WAL-002 | Unsupported WAL version | WAL | | WAL-003 | Fact serialised size exceeds maximum | WAL | @@ -2090,6 +2092,32 @@ See the [file format section in README](../README.md#file-format) for version hi --- +### STG-036 Dictionary entry missing + +**Error text**: `Dictionary entry missing: {}` + +**Cause**: An index entry refers to an entity id, ident id, transaction or long value that has no entry in the file's dictionary (DICT tree). The file is damaged. + +**Resolution**: +- Restore from backup. + +**Scenario**: A DICT leaf lost to a bad sector while the index trees that refer to its ids are intact. + +--- + +### STG-037 Transaction with two timestamps + +**Error text**: `Transaction {} has two timestamps: {} and {}` + +**Cause**: A checkpoint or a v7 migration found facts with the same `tx_count` but different `tx_id` values. Format v8 records one timestamp per transaction, and Minigraf refuses to change a stored timestamp. Every write path stamps one `tx_id` per `tx_count`, so this means the facts were written outside those paths or the file is damaged. + +**Resolution**: +- Keep using the release that wrote the file and report the issue, with the transaction number from the message. + +**Scenario**: A v7 file whose facts were edited by an external tool so that two transactions share one `tx_count`. + +--- + ## WAL — Write-Ahead Log Errors WAL errors relate to the sidecar `.wal` file written alongside the `.graph` file. diff --git a/src/error.rs b/src/error.rs index 153126423..b9acbff51 100644 --- a/src/error.rs +++ b/src/error.rs @@ -176,6 +176,8 @@ pub(crate) enum ErrorCode { Stg033, Stg034, Stg035, + Stg036, + Stg037, Wal001, Wal002, Wal003, @@ -998,6 +1000,18 @@ pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[ "Free list is inconsistent: {}", ErrorCategory::Storage, ), + ( + ErrorCode::Stg036, + "STG-036", + "Dictionary entry missing: {}", + ErrorCategory::Storage, + ), + ( + ErrorCode::Stg037, + "STG-037", + "Transaction {} has two timestamps: {} and {}", + ErrorCategory::Storage, + ), ( ErrorCode::Wal001, "WAL-001", @@ -1820,6 +1834,8 @@ mod tests { | ErrorCode::Stg033 | ErrorCode::Stg034 | ErrorCode::Stg035 + | ErrorCode::Stg036 + | ErrorCode::Stg037 | ErrorCode::Wal001 | ErrorCode::Wal002 | ErrorCode::Wal003 @@ -2019,6 +2035,8 @@ mod tests { ErrorCode::Stg033, ErrorCode::Stg034, ErrorCode::Stg035, + ErrorCode::Stg036, + ErrorCode::Stg037, ErrorCode::Wal001, ErrorCode::Wal002, ErrorCode::Wal003, diff --git a/src/storage/dict.rs b/src/storage/dict.rs new file mode 100644 index 000000000..4e2b574c1 --- /dev/null +++ b/src/storage/dict.rs @@ -0,0 +1,699 @@ +//! The dictionary tree (DICT, spec §5) and the encoding of facts into keys. +//! +//! [`DictReader`] looks ids up in a committed DICT tree and translates index +//! entries back into [`Fact`]s, memoising every lookup for its own lifetime, so +//! memory is bounded by one read's results. +//! +//! [`Encoder`] turns pending facts into index keys at checkpoint time, in +//! pending (WAL) order: it assigns new eids and iids from the meta's counters, +//! records each transaction's timestamp, and deduplicates long values against +//! the committed ones and against each other. Ids are never persisted before the +//! commit that writes their DICT entries, so assigning them here is crash-safe. +//! Encoding runs in two phases so the committed reader and the page writes +//! never borrow the backend at once: [`Encoder::stage`] reads, then +//! [`Encoder::finish`] writes the new long values and builds the keys. + +use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::graph::types::{Fact, Value}; +use crate::storage::StorageBackend; +use crate::storage::btree; +use crate::storage::cache::PageCache; +use crate::storage::keys::{ + self, DICT_LONG_VALUE, Index, KeyFact, KeyValue, SHORT_STRING_MAX, ValueRef, +}; +use crate::storage::node::Entry; +use crate::storage::page::PageAllocator; +use crate::storage::value_pages::{ValueWriter, read_value}; +use anyhow::Result; +use std::collections::HashMap; +use uuid::Uuid; + +/// Lookups in a committed DICT tree, memoised. +pub struct DictReader<'a> { + root: u64, + backend: &'a dyn StorageBackend, + cache: &'a PageCache, + uuids: HashMap, + names: HashMap, + tx_ids: HashMap, + long_values: HashMap, +} + +impl<'a> DictReader<'a> { + /// A reader over the DICT tree at `root` (0: the empty dictionary). + pub fn new(root: u64, backend: &'a dyn StorageBackend, cache: &'a PageCache) -> Self { + DictReader { + root, + backend, + cache, + uuids: HashMap::new(), + names: HashMap::new(), + tx_ids: HashMap::new(), + long_values: HashMap::new(), + } + } + + fn get(&self, key: &[u8]) -> Result>> { + if self.root == 0 { + return Ok(None); + } + btree::get(self.root, key, self.backend, self.cache) + } + + /// The eid of `uuid`, if it has one. + pub fn eid_of(&self, uuid: &Uuid) -> Result> { + self.get(&keys::dict_uuid_key(uuid))? + .map(|v| keys::read_uint_bytes(&v)) + .transpose() + } + + /// The iid of `ident`, if it has one. + pub fn iid_of(&self, ident: &str) -> Result> { + self.get(&keys::dict_ident_key(ident))? + .map(|v| { + u32::try_from(keys::read_uint_bytes(&v)?) + .map_err(|_| err_coded!(ErrorCode::Int049, "iid out of range")) + }) + .transpose() + } + + /// The timestamp recorded for `tx_count`, if any. + pub fn tx_id_lookup(&mut self, tx_count: u64) -> Result> { + if let Some(t) = self.tx_ids.get(&tx_count) { + return Ok(Some(*t)); + } + let found = self + .get(&keys::dict_tx_key(tx_count))? + .map(|v| keys::read_uint_bytes(&v)) + .transpose()?; + if let Some(t) = found { + self.tx_ids.insert(tx_count, t); + } + Ok(found) + } + + pub fn uuid_of(&mut self, eid: u64) -> Result { + if let Some(u) = self.uuids.get(&eid) { + return Ok(*u); + } + let v = self + .get(&keys::dict_eid_key(eid))? + .ok_or_else(|| err_coded!(ErrorCode::Stg036, format!("entity id {eid}")))?; + let u = Uuid::from_slice(&v) + .map_err(|_| err_coded!(ErrorCode::Stg036, format!("entity id {eid}: bad UUID")))?; + self.uuids.insert(eid, u); + Ok(u) + } + + pub fn name_of(&mut self, iid: u32) -> Result { + if let Some(n) = self.names.get(&iid) { + return Ok(n.clone()); + } + let v = self + .get(&keys::dict_iid_key(iid))? + .ok_or_else(|| err_coded!(ErrorCode::Stg036, format!("ident id {iid}")))?; + let n = String::from_utf8(v) + .map_err(|_| err_coded!(ErrorCode::Stg036, format!("ident id {iid}: not UTF-8")))?; + self.names.insert(iid, n.clone()); + Ok(n) + } + + pub fn tx_id_of(&mut self, tx_count: u64) -> Result { + self.tx_id_lookup(tx_count)? + .ok_or_else(|| err_coded!(ErrorCode::Stg036, format!("transaction {tx_count}"))) + } + + pub fn long_value(&mut self, vref: ValueRef) -> Result { + if let Some(s) = self.long_values.get(&vref) { + return Ok(s.clone()); + } + let bytes = read_value(vref, self.backend, self.cache)?; + let s = String::from_utf8(bytes).map_err(|_| { + err_coded!( + ErrorCode::Stg036, + format!("long value {}:{} not UTF-8", vref.page, vref.slot) + ) + })?; + self.long_values.insert(vref, s.clone()); + Ok(s) + } + + /// The committed long value equal to `s`, if any (§6.3 dedup). + fn find_long_value(&mut self, s: &str) -> Result> { + if self.root == 0 { + return Ok(None); + } + let prefix = keys::dict_long_value_prefix(keys::hash64(s.as_bytes())); + for (k, _) in btree::prefix_scan(self.root, &prefix, self.backend, self.cache)? { + let vref = keys::dict_long_value_ref(&k)?; + if self.long_value(vref)? == s { + return Ok(Some(vref)); + } + } + Ok(None) + } + + /// Translate an index entry back into a fact. + pub fn fact(&mut self, kf: &KeyFact) -> Result { + let value = match &kf.v { + KeyValue::Null => Value::Null, + KeyValue::Bool(b) => Value::Boolean(*b), + KeyValue::Int(n) => Value::Integer(*n), + KeyValue::Float(f) => Value::Float(*f), + KeyValue::Str(s) => Value::String(s.clone()), + KeyValue::LongStr { vref, .. } => Value::String(self.long_value(*vref)?), + KeyValue::Keyword(iid) => Value::Keyword(self.name_of(*iid)?), + KeyValue::Ref(eid) => Value::Ref(self.uuid_of(*eid)?), + }; + Ok(Fact { + entity: self.uuid_of(kf.e)?, + attribute: self.name_of(kf.a)?, + value, + tx_id: self.tx_id_of(kf.tx_count)?, + tx_count: kf.tx_count, + valid_from: kf.vf, + valid_to: kf.vt, + asserted: kf.asserted, + }) + } +} + +/// A value after staging: ready, or a long value still to be written. +enum StagedValue { + Ready(KeyValue), + NewLong(usize), +} + +/// A fact with its ids assigned. +struct Staged { + e: u64, + a: u32, + v: StagedValue, + tx_count: u64, + vf: i64, + vt: i64, + asserted: bool, +} + +/// What one checkpoint adds: sorted, distinct entries per tree. +pub struct Encoded { + /// EAVT, AEVT, AVET, VAET entries, in [`Index::ALL`] order. + pub index: [Vec; 4], + pub dict: Vec, + pub next_eid: u64, + pub next_iid: u32, +} + +/// Assigns ids and builds keys for one checkpoint's facts. +pub struct Encoder { + next_eid: u64, + next_iid: u32, + eids: HashMap, + iids: HashMap, + txs: HashMap, + new_long: HashMap, + new_values: Vec, + dict: Vec, + staged: Vec, +} + +impl Encoder { + /// An encoder whose new ids start at the meta's counters (0 means 1). + pub fn new(next_eid: u64, next_iid: u32) -> Self { + Encoder { + next_eid: next_eid.max(1), + next_iid: next_iid.max(1), + eids: HashMap::new(), + iids: HashMap::new(), + txs: HashMap::new(), + new_long: HashMap::new(), + new_values: Vec::new(), + dict: Vec::new(), + staged: Vec::new(), + } + } + + fn eid(&mut self, uuid: Uuid, dict: &DictReader<'_>) -> Result { + if let Some(e) = self.eids.get(&uuid) { + return Ok(*e); + } + let e = match dict.eid_of(&uuid)? { + Some(e) => e, + None => { + let e = self.next_eid; + self.next_eid = e + .checked_add(1) + .ok_or_else(|| err_coded!(ErrorCode::Int048, "entity ids exhausted"))?; + self.dict + .push((keys::dict_uuid_key(&uuid), keys::uint_bytes(e))); + self.dict + .push((keys::dict_eid_key(e), uuid.as_bytes().to_vec())); + e + } + }; + self.eids.insert(uuid, e); + Ok(e) + } + + fn iid(&mut self, ident: &str, dict: &DictReader<'_>) -> Result { + if let Some(i) = self.iids.get(ident) { + return Ok(*i); + } + if ident.len() > keys::MAX_IDENT_BYTES { + bail_coded!(ErrorCode::Wal003, ident.len(), keys::MAX_IDENT_BYTES); + } + let i = match dict.iid_of(ident)? { + Some(i) => i, + None => { + let i = self.next_iid; + self.next_iid = i + .checked_add(1) + .ok_or_else(|| err_coded!(ErrorCode::Int048, "ident ids exhausted"))?; + self.dict + .push((keys::dict_ident_key(ident), keys::uint_bytes(u64::from(i)))); + self.dict + .push((keys::dict_iid_key(i), ident.as_bytes().to_vec())); + i + } + }; + self.iids.insert(ident.to_string(), i); + Ok(i) + } + + fn tx(&mut self, tx_count: u64, tx_id: u64, dict: &mut DictReader<'_>) -> Result<()> { + let known = match self.txs.get(&tx_count) { + Some(t) => Some(*t), + None => dict.tx_id_lookup(tx_count)?, + }; + match known { + Some(t) if t != tx_id => bail_coded!(ErrorCode::Stg037, tx_count, t, tx_id), + Some(_) => {} + None => self + .dict + .push((keys::dict_tx_key(tx_count), keys::uint_bytes(tx_id))), + } + self.txs.insert(tx_count, tx_id); + Ok(()) + } + + fn value(&mut self, v: &Value, dict: &mut DictReader<'_>) -> Result { + Ok(StagedValue::Ready(match v { + Value::Null => KeyValue::Null, + Value::Boolean(b) => KeyValue::Bool(*b), + Value::Integer(n) => KeyValue::Int(*n), + Value::Float(f) => KeyValue::Float(*f), + Value::String(s) if s.len() <= SHORT_STRING_MAX => KeyValue::Str(s.clone()), + Value::String(s) => { + if s.len() > keys::MAX_VALUE_BYTES { + bail_coded!(ErrorCode::Wal003, s.len(), keys::MAX_VALUE_BYTES); + } + if let Some(i) = self.new_long.get(s) { + return Ok(StagedValue::NewLong(*i)); + } + if let Some(vref) = dict.find_long_value(s)? { + keys::long_str(s, vref) + } else { + let i = self.new_values.len(); + self.new_values.push(s.clone()); + self.new_long.insert(s.clone(), i); + return Ok(StagedValue::NewLong(i)); + } + } + Value::Keyword(k) => KeyValue::Keyword(self.iid(k, dict)?), + Value::Ref(u) => KeyValue::Ref(self.eid(*u, dict)?), + })) + } + + /// Assign ids for `fact`, reading the committed dictionary. + pub fn stage(&mut self, fact: &Fact, dict: &mut DictReader<'_>) -> Result<()> { + let e = self.eid(fact.entity, dict)?; + let a = self.iid(&fact.attribute, dict)?; + let v = self.value(&fact.value, dict)?; + self.tx(fact.tx_count, fact.tx_id, dict)?; + self.staged.push(Staged { + e, + a, + v, + tx_count: fact.tx_count, + vf: fact.valid_from, + vt: fact.valid_to, + asserted: fact.asserted, + }); + Ok(()) + } + + /// Write the new long values to fresh value pages and build every entry. + pub fn finish( + mut self, + alloc: &mut PageAllocator, + backend: &mut dyn StorageBackend, + cache: &PageCache, + ) -> Result { + let mut writer = ValueWriter::new(); + let mut new_refs = Vec::with_capacity(self.new_values.len()); + for s in &self.new_values { + let vref = writer.push(s.as_bytes(), alloc, backend, cache)?; + self.dict.push(( + keys::dict_long_value_key(keys::hash64(s.as_bytes()), vref), + Vec::new(), + )); + new_refs.push(keys::long_str(s, vref)); + } + writer.flush(alloc, backend, cache)?; + + let mut index: [Vec; 4] = Default::default(); + for st in self.staged { + let v = match st.v { + StagedValue::Ready(v) => v, + StagedValue::NewLong(i) => new_refs + .get(i) + .cloned() + .ok_or_else(|| err_coded!(ErrorCode::Int049, "staged long value missing"))?, + }; + let kf = KeyFact { + e: st.e, + a: st.a, + v, + tx_count: st.tx_count, + vf: st.vf, + vt: st.vt, + asserted: st.asserted, + }; + for (tree, idx) in index.iter_mut().zip(Index::ALL) { + if let Some(k) = kf.key(idx) { + tree.push((k, Vec::new())); + } + } + } + for tree in &mut index { + tree.sort_unstable(); + tree.dedup(); + } + self.dict.sort_unstable(); + if self + .dict + .windows(2) + .any(|w| matches!(w, [a, b] if a.0 == b.0)) + { + bail_coded!(ErrorCode::Int049, "a DICT key assigned twice"); + } + Ok(Encoded { + index, + dict: self.dict, + next_eid: self.next_eid, + next_iid: self.next_iid, + }) + } +} + +/// True if `key` is a DICT long-value entry (its value ref keeps a value page alive). +pub fn is_long_value_entry(key: &[u8]) -> bool { + key.first() == Some(&DICT_LONG_VALUE) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::types::VALID_TIME_FOREVER; + use crate::storage::backend::MemoryBackend; + + fn fact(e: u128, a: &str, v: Value, tx: u64) -> Fact { + Fact { + entity: Uuid::from_u128(e), + attribute: a.to_string(), + value: v, + tx_id: 1_000 + tx, + tx_count: tx, + valid_from: 5, + valid_to: VALID_TIME_FOREVER, + asserted: true, + } + } + + /// Encode `facts` on top of the dictionary at `root`, write the new DICT + /// tree, and return `(new_root, encoded)`. + fn checkpoint( + backend: &mut MemoryBackend, + cache: &PageCache, + root: u64, + counters: (u64, u32), + facts: &[Fact], + ) -> (u64, Encoded) { + let mut enc = Encoder::new(counters.0, counters.1); + { + let mut dict = DictReader::new(root, &*backend, cache); + for f in facts { + enc.stage(f, &mut dict).unwrap(); + } + } + let start = backend.page_count().unwrap().max(2); + let mut alloc = PageAllocator::new(Vec::new(), start, 1); + let encoded = enc.finish(&mut alloc, backend, cache).unwrap(); + let leaves = if root == 0 { + Vec::new() + } else { + btree::collect_leaf_pages(root, &*backend, cache, None).unwrap() + }; + let new_root = btree::rebuild_btree_incremental( + leaves, + encoded.dict.clone(), + backend, + cache, + &mut alloc, + ) + .unwrap(); + (new_root, encoded) + } + + fn decode_all( + encoded: &Encoded, + root: u64, + backend: &MemoryBackend, + cache: &PageCache, + ) -> Vec { + let mut dict = DictReader::new(root, backend, cache); + encoded.index[0] + .iter() + .map(|(k, _)| { + dict.fact(&KeyFact::decode(Index::Eavt, k).unwrap()) + .unwrap() + }) + .collect() + } + + fn same_facts(mut a: Vec, mut b: Vec) -> bool { + let key = |f: &Fact| crate::storage::index::encode_value(&f.value); + a.sort_by(|x, y| { + (x.entity, &x.attribute, key(x), x.tx_count).cmp(&( + y.entity, + &y.attribute, + key(y), + y.tx_count, + )) + }); + b.sort_by(|x, y| { + (x.entity, &x.attribute, key(x), x.tx_count).cmp(&( + y.entity, + &y.attribute, + key(y), + y.tx_count, + )) + }); + a == b + } + + #[test] + fn facts_round_trip_through_keys_and_dictionary() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let long = "L".repeat(200); + let facts = vec![ + fact(1, ":name", Value::String("Alice".into()), 1), + fact(1, ":status", Value::Keyword(":active".into()), 1), + fact(1, ":friend", Value::Ref(Uuid::from_u128(2)), 2), + fact(2, ":bio", Value::String(long.clone()), 2), + fact(2, ":score", Value::Float(-0.5), 3), + fact(2, ":n", Value::Integer(-7), 3), + fact(3, ":flag", Value::Boolean(true), 3), + fact(3, ":none", Value::Null, 3), + ]; + let (root, enc) = checkpoint(&mut backend, &cache, 0, (1, 1), &facts); + assert_eq!(enc.index[3].len(), 1, "one ref fact in VAET"); + assert!(same_facts(decode_all(&enc, root, &backend, &cache), facts)); + assert_eq!(enc.next_eid, 4, "entities 1, 2, 3 (2 via a ref)"); + } + + #[test] + fn ids_are_assigned_once_and_shared_between_attribute_and_keyword() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let first = vec![ + fact(1, ":color", Value::Keyword(":red".into()), 1), + fact(2, ":red", Value::Integer(1), 1), + ]; + let (root, enc) = checkpoint(&mut backend, &cache, 0, (1, 1), &first); + // Idents :color and :red: two iids, :red shared by attribute and keyword. + assert_eq!(enc.next_iid, 3); + let dict = DictReader::new(root, &backend, &cache); + let red = dict.iid_of(":red").unwrap().unwrap(); + let e1 = dict.eid_of(&Uuid::from_u128(1)).unwrap().unwrap(); + + // A later checkpoint reuses committed ids and continues the counters. + let second = vec![ + fact(1, ":red", Value::Keyword(":blue".into()), 2), + fact(9, ":color", Value::Keyword(":red".into()), 2), + ]; + let (root2, enc2) = checkpoint( + &mut backend, + &cache, + root, + (enc.next_eid, enc.next_iid), + &second, + ); + let dict2 = DictReader::new(root2, &backend, &cache); + assert_eq!( + dict2.iid_of(":red").unwrap(), + Some(red), + "iid never changes" + ); + assert_eq!(dict2.eid_of(&Uuid::from_u128(1)).unwrap(), Some(e1)); + assert_eq!(enc2.next_iid, 4, "only :blue is new"); + assert_eq!(enc2.next_eid, enc.next_eid + 1, "only entity 9 is new"); + assert!(same_facts( + decode_all(&enc2, root2, &backend, &cache), + second + )); + } + + #[test] + fn assignment_is_deterministic() { + let facts: Vec = (0..300u128) + .map(|i| { + fact( + i * 7919 % 1000, + &format!(":a{}", i % 13), + Value::Integer(1), + u64::try_from(i / 10).unwrap(), + ) + }) + .collect(); + let run = || { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let (_, enc) = checkpoint(&mut backend, &cache, 0, (1, 1), &facts); + (enc.index, enc.dict) + }; + assert!( + run() == run(), + "same facts in the same order give the same keys" + ); + } + + #[test] + fn long_values_are_deduplicated() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let long = "x".repeat(500); + let mut retract = fact(1, ":bio", Value::String(long.clone()), 2); + retract.asserted = false; + let facts = vec![ + fact(1, ":bio", Value::String(long.clone()), 1), + retract, + fact(2, ":bio", Value::String(long.clone()), 2), + ]; + let (root, enc) = checkpoint(&mut backend, &cache, 0, (1, 1), &facts); + let pages_after_first = backend.page_count().unwrap(); + let long_entries = enc + .dict + .iter() + .filter(|(k, _)| is_long_value_entry(k)) + .count(); + assert_eq!(long_entries, 1, "one stored copy within a checkpoint"); + + // Re-asserting in a later checkpoint writes no value page. + let again = vec![fact(3, ":bio", Value::String(long), 3)]; + let mut enc2 = Encoder::new(enc.next_eid, enc.next_iid); + { + let mut dict = DictReader::new(root, &backend, &cache); + enc2.stage(&again[0], &mut dict).unwrap(); + } + let mut alloc = PageAllocator::new(Vec::new(), pages_after_first, 2); + let enc2 = enc2.finish(&mut alloc, &mut backend, &cache).unwrap(); + assert_eq!(alloc.next_append(), pages_after_first, "no new value page"); + assert!(!enc2.dict.iter().any(|(k, _)| is_long_value_entry(k))); + } + + #[test] + fn hash_collisions_still_find_the_right_value() { + // Two DICT 0x06 entries under one hash: dedup compares full values. + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let a = "a".repeat(100); + let b = "b".repeat(100); + let mut alloc = PageAllocator::new(Vec::new(), 2, 1); + let mut w = ValueWriter::new(); + let ra = w + .push(a.as_bytes(), &mut alloc, &mut backend, &cache) + .unwrap(); + let rb = w + .push(b.as_bytes(), &mut alloc, &mut backend, &cache) + .unwrap(); + w.flush(&mut alloc, &mut backend, &cache).unwrap(); + let h = keys::hash64(b.as_bytes()); + // Forge a collision: file `a` under `b`'s hash, ahead of `b`'s own entry. + let mut entries = vec![ + (keys::dict_long_value_key(h, ra), Vec::new()), + (keys::dict_long_value_key(h, rb), Vec::new()), + ]; + entries.sort(); + let root = btree::build_btree(entries, &mut backend, &cache, &mut alloc).unwrap(); + let mut dict = DictReader::new(root, &backend, &cache); + assert_eq!(dict.find_long_value(&b).unwrap(), Some(rb)); + assert_eq!(dict.find_long_value(&"c".repeat(100)).unwrap(), None); + } + + #[test] + fn one_tx_count_with_two_timestamps_is_stg_037() { + let mut backend = MemoryBackend::new(); + let cache = PageCache::new(256); + let mut other = fact(2, ":a", Value::Integer(1), 1); + other.tx_id += 1; + let mut enc = Encoder::new(1, 1); + let mut dict = DictReader::new(0, &backend, &cache); + enc.stage(&fact(1, ":a", Value::Integer(1), 1), &mut dict) + .unwrap(); + let err = enc.stage(&other, &mut dict).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-037"); + + // Also against a committed timestamp. + let (root, enc) = checkpoint( + &mut backend, + &cache, + 0, + (1, 1), + &[fact(1, ":a", Value::Integer(1), 1)], + ); + let mut enc2 = Encoder::new(enc.next_eid, enc.next_iid); + let mut dict = DictReader::new(root, &backend, &cache); + let err = enc2.stage(&other, &mut dict).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-037"); + } + + #[test] + fn missing_dictionary_entry_is_stg_036() { + let backend = MemoryBackend::new(); + let cache = PageCache::new(16); + let mut dict = DictReader::new(0, &backend, &cache); + let kf = KeyFact { + e: 5, + a: 1, + v: KeyValue::Null, + tx_count: 1, + vf: 0, + vt: VALID_TIME_FOREVER, + asserted: true, + }; + let err = dict.fact(&kf).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-036"); + } +} diff --git a/src/storage/mod.rs b/src/storage/mod.rs index b14813fa5..620f1ddde 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -9,6 +9,7 @@ pub mod backend; pub(crate) mod btree; pub mod btree_v6; pub mod cache; +pub(crate) mod dict; pub(crate) mod dir_sync; pub(crate) mod freelist; pub mod index; From 20edc858748acf62b7d2cf77a10218ae871fc645 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:32:18 +0530 Subject: [PATCH 7/9] feat(storage): covering indexes with DICT and value pages; drop fact pages (#433) save() and the v7 migration now encode facts into byte-comparable keys at checkpoint time (Encoder), write new long strings to value pages, and rebuild all five trees (EAVT, AEVT, AVET, VAET, DICT). The meta records the DICT root and the next eid/iid. Fact pages (0x41, now retired), FactRef, CommittedFactReader/CommittedIndexReader and btree_v6.rs are removed. Committed reads go through one CommittedReader (all facts, by entity, by entity and attribute, by attribute) that decodes index entries and translates ids through DICT; nothing but index leaves, DICT pages and long values is read. Pending indexes map to usize positions. The matcher's and optimizer's unused index plumbing is removed. WAL-003 now limits string values to 4068 bytes (one value page) and attributes/keywords to 1024 bytes, instead of the serialised fact size. Also carries the #447 fix (the tx counter never rewinds on WAL replay), which the new one-timestamp-per-transaction check (STG-037) exposed. Refs #433 #374 #434 #388 #447 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- docs/ERROR_REFERENCE.md | 10 +- src/error.rs | 2 +- src/graph/storage.rs | 843 +++-------- src/query/datalog/evaluator.rs | 5 +- src/query/datalog/executor.rs | 10 +- src/query/datalog/matcher.rs | 97 +- src/query/datalog/optimizer.rs | 46 +- src/storage/btree_v6.rs | 2366 ------------------------------- src/storage/dict.rs | 7 +- src/storage/index.rs | 465 +----- src/storage/keys.rs | 4 - src/storage/meta.rs | 5 +- src/storage/mod.rs | 71 +- src/storage/packed_pages.rs | 556 ++------ src/storage/page.rs | 12 +- src/storage/persistent_facts.rs | 414 ++---- src/storage/reader.rs | 125 ++ src/wal.rs | 28 +- tests/edge_cases_test.rs | 10 +- tests/error_codes_wal_test.rs | 8 +- tests/multi_value_test.rs | 18 +- tests/wal_test.rs | 57 + 22 files changed, 703 insertions(+), 4456 deletions(-) delete mode 100644 src/storage/btree_v6.rs create mode 100644 src/storage/reader.rs diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md index d07d8a38b..f0a49c487 100644 --- a/docs/ERROR_REFERENCE.md +++ b/docs/ERROR_REFERENCE.md @@ -148,7 +148,7 @@ with no `CodedError` anywhere in its chain. | STG-037 | Transaction with two timestamps | Storage | | WAL-001 | Invalid WAL magic number | WAL | | WAL-002 | Unsupported WAL version | WAL | -| WAL-003 | Fact serialised size exceeds maximum | WAL | +| WAL-003 | Value size exceeds maximum | WAL | | WAL-004 | Fact serialised size exceeds u32 range | WAL | | WAL-005 | WAL num_facts exceeds platform usize | WAL | | WAL-006 | Failed to delete WAL file | WAL | @@ -2148,11 +2148,11 @@ The WAL is replayed on open and deleted on checkpoint. **Scenario**: A `.wal` file written by a pre-release version of Minigraf is opened with the stable release, which uses a different WAL version number — e.g. `Unsupported WAL version: 3 (expected 2)`. -### WAL-003 Fact serialised size exceeds maximum +### WAL-003 Value size exceeds maximum -**Error text**: `Fact serialised size {} bytes exceeds maximum {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.` +**Error text**: `Value of {} bytes exceeds the maximum of {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.` -**Cause**: A single fact's serialised size exceeds the WAL entry limit (~512 KB). This typically means a `Value::String` attribute value contains very large content such as raw document text, a base64-encoded image, or binary data. +**Cause**: A string value is longer than `MAX_VALUE_BYTES` (4,068 bytes, one value page), or an attribute name or keyword value is longer than 1,024 bytes. This typically means a `Value::String` contains very large content such as raw document text, a base64-encoded image, or binary data. In-memory databases have no WAL and no limit. **Resolution**: - Store large payloads in an external file or object store. @@ -2170,7 +2170,7 @@ The WAL is replayed on open and deleted on checkpoint. **Error text**: `fact serialised size {} exceeds u32 range` -**Cause**: The serialised size of a single fact exceeds `u32::MAX` (~4 GB). This is practically unreachable — WAL-003's ~512 KB limit fires first. +**Cause**: The serialised size of a single fact exceeds `u32::MAX` (~4 GB). This is practically unreachable: WAL-003's value limits fire first. **Resolution**: - This should not occur under normal operation. diff --git a/src/error.rs b/src/error.rs index b9acbff51..0e4c39290 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1027,7 +1027,7 @@ pub(crate) const REGISTRY: &[(ErrorCode, &str, &str, ErrorCategory)] = &[ ( ErrorCode::Wal003, "WAL-003", - "Fact serialised size {} bytes exceeds maximum {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.", + "Value of {} bytes exceeds the maximum of {} bytes. Store large payloads externally and reference them with a Value::String URL/path or Value::Ref entity ID.", ErrorCategory::Wal, ), ( diff --git a/src/graph/storage.rs b/src/graph/storage.rs index fe1939d09..b51ac7567 100644 --- a/src/graph/storage.rs +++ b/src/graph/storage.rs @@ -1,9 +1,9 @@ -use crate::error::{ErrorCode, bail_coded, err_coded}; +use crate::error::{ErrorCode, err_coded}; use crate::graph::types::{ Attribute, EntityId, Fact, TransactOptions, TxId, VALID_TIME_FOREVER, Value, tx_id_now, }; use crate::query::datalog::types::AsOf; -use crate::storage::index::{FactRef, Indexes, encode_value}; +use crate::storage::index::{Indexes, encode_value}; use anyhow::Result; use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; @@ -45,12 +45,9 @@ struct FactData { /// fact sets (e.g. 1M-fact benchmark setup). pending_keys: HashSet, pending_indexes: Indexes, - /// Resolves committed (on-disk) FactRefs to Fact objects. - /// None for in-memory databases or before load() is called. - committed: Option>, - /// Provides bounded range scans over the four committed (on-disk) covering indexes. - /// Set by `set_committed_index_reader()` after open/migration/checkpoint. - committed_index_reader: Option>, + /// Reads committed (checkpointed) facts through the on-disk covering indexes. + /// None for in-memory databases. Set after every open, migration and checkpoint. + committed: Option>, } /// In-memory storage for Datalog facts with transaction support @@ -109,7 +106,6 @@ impl FactStorage { pending_keys: HashSet::new(), pending_indexes: Indexes::new(), committed: None, - committed_index_reader: None, })), tx_counter: Arc::new(AtomicU64::new(0)), } @@ -156,15 +152,10 @@ impl FactStorage { .data .write() .map_err(|_| err_coded!(ErrorCode::Int050, "data"))?; - for (slot, fact) in (u16::try_from(d.facts.len()).unwrap_or(u16::MAX)..).zip(facts.iter()) { + let base = d.facts.len(); + for (i, fact) in facts.iter().enumerate() { d.pending_keys.insert(pending_key(fact)); - d.pending_indexes.insert( - fact, - FactRef { - page_id: 0, - slot_index: slot, - }, - ); + d.pending_indexes.insert(fact, base + i); } d.facts.extend(facts); @@ -215,15 +206,10 @@ impl FactStorage { .data .write() .map_err(|_| err_coded!(ErrorCode::Int050, "data"))?; - for (slot, fact) in (u16::try_from(d.facts.len()).unwrap_or(u16::MAX)..).zip(facts.iter()) { + let base = d.facts.len(); + for (i, fact) in facts.iter().enumerate() { d.pending_keys.insert(pending_key(fact)); - d.pending_indexes.insert( - fact, - FactRef { - page_id: 0, - slot_index: slot, - }, - ); + d.pending_indexes.insert(fact, base + i); } d.facts.extend(facts); @@ -264,17 +250,10 @@ impl FactStorage { .data .write() .map_err(|_| err_coded!(ErrorCode::Int050, "data"))?; - for (slot, fact) in - (u16::try_from(d.facts.len()).unwrap_or(u16::MAX)..).zip(retractions.iter()) - { + let base = d.facts.len(); + for (i, fact) in retractions.iter().enumerate() { d.pending_keys.insert(pending_key(fact)); - d.pending_indexes.insert( - fact, - FactRef { - page_id: 0, - slot_index: slot, - }, - ); + d.pending_indexes.insert(fact, base + i); } d.facts.extend(retractions); @@ -303,29 +282,25 @@ impl FactStorage { return Ok(false); // Already exists, not loaded } - let slot = u16::try_from(d.facts.len()).unwrap_or(u16::MAX); - d.pending_indexes.insert( - &fact, - FactRef { - page_id: 0, - slot_index: slot, - }, - ); + let pos = d.facts.len(); + d.pending_indexes.insert(&fact, pos); d.facts.push(fact); Ok(true) } - /// Set tx_counter to max(tx_count) across all loaded facts. + /// Raise tx_counter to max(tx_count) across all loaded facts. /// /// Must be called after all `load_fact()` calls complete so that the next - /// `transact()` call picks up from the right sequence number. + /// `transact()` call picks up from the right sequence number. The counter + /// never moves backwards: it may already hold the file's checkpointed count, + /// and when WAL replay loads no facts that count must survive (#447). pub(crate) fn restore_tx_counter(&self) -> Result<()> { let d = self .data .read() .map_err(|_| err_coded!(ErrorCode::Int050, "data"))?; let max = d.facts.iter().map(|f| f.tx_count).max().unwrap_or(0); - self.tx_counter.store(max, Ordering::SeqCst); + self.tx_counter.fetch_max(max, Ordering::SeqCst); Ok(()) } @@ -357,9 +332,9 @@ impl FactStorage { .read() .map_err(|_| err_coded!(ErrorCode::Int050, "data"))?; let mut all = Vec::new(); - // Committed facts first (on disk, via CommittedFactReader) - if let Some(loader) = &d.committed { - all.extend(loader.stream_all()?); + // Committed facts first (on disk, in EAVT order) + if let Some(reader) = &d.committed { + all.extend(reader.all_facts()?); } // Then pending facts (post-checkpoint, in memory) all.extend(d.facts.iter().cloned()); @@ -384,13 +359,6 @@ impl FactStorage { Ok(all.into_iter().filter(|f| f.is_asserted()).collect()) } - /// Replace the pending in-memory indexes (tests install hand-built ones). - #[cfg(test)] - pub(crate) fn replace_pending_indexes(&self, indexes: Indexes) { - let mut d = self.data.write().unwrap_or_else(|e| e.into_inner()); - d.pending_indexes = indexes; - } - /// Return the pending (uncommitted) facts held in memory. pub(crate) fn get_pending_facts(&self) -> Vec { let d = self.data.read().unwrap_or_else(|e| e.into_inner()); @@ -410,51 +378,18 @@ impl FactStorage { self.tx_counter.store(max, Ordering::SeqCst); } - /// Return a snapshot (clone) of the current pending in-memory indexes. - /// - /// Used by `PersistentFactStorage::save()` to write index B+tree pages. - /// Clones the BTreeMaps — acceptable since `save()` is not on the hot path. - pub(crate) fn pending_indexes_snapshot(&self) -> Indexes { - let d = self.data.read().unwrap_or_else(|e| e.into_inner()); - Indexes { - eavt: d.pending_indexes.eavt.clone(), - aevt: d.pending_indexes.aevt.clone(), - avet: d.pending_indexes.avet.clone(), - vaet: d.pending_indexes.vaet.clone(), - } - } - - /// Set the committed fact reader. Called by PersistentFactStorage::load() after - /// opening a v5 file so index-driven reads can resolve FactRefs via page cache. - pub(crate) fn set_committed_reader( - &self, - reader: Arc, - ) { + /// Set the committed reader. Called by `PersistentFactStorage` after each + /// open, migration and checkpoint. + pub(crate) fn set_committed_reader(&self, reader: Arc) { let mut d = self.data.write().unwrap_or_else(|e| e.into_inner()); d.committed = Some(reader); } - /// Set the committed index reader. Called by PersistentFactStorage after - /// each open/migration/checkpoint so queries can range-scan the on-disk B+tree. - pub(crate) fn set_committed_index_reader( - &self, - reader: Arc, - ) { - let mut d = self.data.write().unwrap_or_else(|e| e.into_inner()); - d.committed_index_reader = Some(reader); - } - - /// Returns (eavt_len, aevt_len, avet_len, vaet_len) for the pending indexes. - /// Used in tests to verify pending index state. - #[allow(dead_code)] - pub(crate) fn pending_index_counts(&self) -> (usize, usize, usize, usize) { + /// Returns (eavt_len, aevt_len) for the pending indexes (tests). + #[cfg(test)] + pub(crate) fn pending_index_counts(&self) -> (usize, usize) { let d = self.data.read().unwrap_or_else(|e| e.into_inner()); - ( - d.pending_indexes.eavt.len(), - d.pending_indexes.aevt.len(), - d.pending_indexes.avet.len(), - d.pending_indexes.vaet.len(), - ) + (d.pending_indexes.eavt.len(), d.pending_indexes.aevt.len()) } } @@ -559,22 +494,12 @@ pub(crate) fn net_asserted_facts(facts: Vec) -> Vec { .collect() } -/// Resolve a [FactRef] to a [Fact] using the committed reader (for on-disk facts) -/// or the pending facts vector (for in-memory facts with page_id=0). -/// Used by the production index-driven lookup methods (`get_facts_by_entity`, -/// `get_facts_by_attribute`). -fn resolve_fact_ref(d: &FactData, fr: FactRef) -> Result { - if fr.page_id == 0 { - d.facts - .get(fr.slot_index as usize) - .cloned() - .ok_or_else(|| err_coded!(ErrorCode::Int045, fr.slot_index)) - } else { - match &d.committed { - Some(loader) => loader.resolve(fr), - None => bail_coded!(ErrorCode::Int046, fr.page_id), - } - } +/// The pending fact at `pos`, as indexed by `FactData::pending_indexes`. +fn pending_fact(d: &FactData, pos: usize) -> Result { + d.facts + .get(pos) + .cloned() + .ok_or_else(|| err_coded!(ErrorCode::Int045, pos)) } /// Production helpers on FactStorage: index-driven entity/attribute lookups used by the query executor. @@ -583,63 +508,28 @@ impl FactStorage { pub(crate) fn get_facts_by_entity(&self, entity_id: &EntityId) -> Result> { use crate::storage::index::EavtKey; let d = self.data.read().unwrap_or_else(|e| e.into_inner()); - - let start = EavtKey::entity_start(*entity_id); - let next_entity = uuid::Uuid::from_u128(entity_id.as_u128().wrapping_add(1)); - let end = EavtKey::entity_start(next_entity); - - // Fallback: no indexes built yet - if d.pending_indexes.eavt.is_empty() && d.committed_index_reader.is_none() { - if d.committed.is_none() { - return Ok(d - .facts - .iter() - .filter(|f| &f.entity == entity_id) - .cloned() - .collect()); - } - let mut result: Vec = d - .facts - .iter() - .filter(|f| &f.entity == entity_id) - .cloned() - .collect(); - if let Some(loader) = &d.committed { - for fact in loader.stream_all()? { - if &fact.entity == entity_id { - result.push(fact); - } - } - } - return Ok(result); - } - let mut facts = Vec::new(); - - // Pending: in-memory BTreeMap bounded range. - for (key, &fr) in d.pending_indexes.eavt.range(start.clone()..end.clone()) { + for (key, &pos) in d + .pending_indexes + .eavt + .range(EavtKey::entity_start(*entity_id)..) + { if key.entity != *entity_id { break; } - facts.push(resolve_fact_ref(&d, fr)?); + facts.push(pending_fact(&d, pos)?); } - - // Committed: on-disk B+tree range scan - if let Some(reader) = &d.committed_index_reader { - let committed_refs = reader.range_scan_eavt(&start, Some(&end))?; - for fr in committed_refs { - facts.push(resolve_fact_ref(&d, fr)?); - } + if let Some(reader) = &d.committed { + facts.extend(reader.facts_for_entity(entity_id)?); } - Ok(facts) } /// Get every stored record for one `(entity, attribute)` pair (index-driven, #323). /// - /// Range-scans EAVT over exactly `[(e, a, …), (e, a + "\0", …))`, so other - /// attributes of the same entity — including prefix siblings such as `:ab` for - /// `:a` — and their version history are never resolved. + /// Reads only that pair's EAVT range, so other attributes of the same entity + /// (including prefix siblings such as `:ab` for `:a`) and their version + /// history are never read. pub(crate) fn get_facts_by_entity_attribute_indexed( &self, entity_id: &EntityId, @@ -647,46 +537,17 @@ impl FactStorage { ) -> Result> { use crate::storage::index::EavtKey; let d = self.data.read().unwrap_or_else(|e| e.into_inner()); - let matches = |f: &Fact| &f.entity == entity_id && &f.attribute == attribute; - - // Fallback: no indexes built yet - if d.pending_indexes.eavt.is_empty() && d.committed_index_reader.is_none() { - let mut result: Vec = d.facts.iter().filter(|f| matches(f)).cloned().collect(); - if let Some(loader) = &d.committed { - result.extend(loader.stream_all()?.into_iter().filter(|f| matches(f))); - } - return Ok(result); - } - let start = EavtKey::entity_attribute_start(*entity_id, attribute); - // Exact successor of `attribute`: the smallest string that sorts after it is - // `attribute + "\0"`, so `[start, end)` holds exactly this (e, a) pair. Always - // valid UTF-8 — unlike incrementing the last byte, which fails for attributes - // ending in 0x7F or a character whose last UTF-8 byte is 0xBF (e.g. `:丿`). - let end = EavtKey::entity_attribute_start(*entity_id, &format!("{attribute}\0")); - let mut facts = Vec::new(); - - // Pending: EAVT keys for the exact (e, a) are contiguous from `start`; - // the first key with a different entity or attribute ends the run. - for (key, &fr) in d.pending_indexes.eavt.range(start.clone()..) { + for (key, &pos) in d.pending_indexes.eavt.range(start..) { if key.entity != *entity_id || key.attribute != *attribute { break; } - facts.push(resolve_fact_ref(&d, fr)?); + facts.push(pending_fact(&d, pos)?); } - - // Committed: on-disk B+tree range scan over exactly this (e, a) pair; the - // post-filter is a cheap guard, not something the range relies on. - if let Some(reader) = &d.committed_index_reader { - for fr in reader.range_scan_eavt(&start, Some(&end))? { - let fact = resolve_fact_ref(&d, fr)?; - if matches(&fact) { - facts.push(fact); - } - } + if let Some(reader) = &d.committed { + facts.extend(reader.facts_for_entity_attribute(entity_id, attribute)?); } - Ok(facts) } @@ -694,41 +555,20 @@ impl FactStorage { pub(crate) fn get_facts_by_attribute(&self, attribute: &Attribute) -> Result> { use crate::storage::index::AevtKey; let d = self.data.read().unwrap_or_else(|e| e.into_inner()); - - // Fallback: no index - if d.pending_indexes.aevt.is_empty() && d.committed_index_reader.is_none() { - drop(d); - return Ok(self - .get_all_facts()? - .into_iter() - .filter(|f| &f.attribute == attribute) - .collect()); - } - - let start = AevtKey::attribute_start(attribute); - // Exact successor of `attribute` (see `get_facts_by_entity_attribute_indexed`): - // `[start, end)` holds exactly this attribute, never prefix siblings such as - // `:ab` for `:a`, and is valid UTF-8 for any attribute (#381). - let end = AevtKey::attribute_start(&format!("{attribute}\0")); - let mut facts = Vec::new(); - - // Pending: AEVT keys for the exact attribute are contiguous from `start`. - for (_, &fr) in d.pending_indexes.aevt.range(start.clone()..end.clone()) { - facts.push(resolve_fact_ref(&d, fr)?); - } - - // Committed: on-disk B+tree range scan over exactly this attribute; the - // post-filter is a cheap guard, not something the range relies on. - if let Some(reader) = &d.committed_index_reader { - for fr in reader.range_scan_aevt(&start, Some(&end))? { - let fact = resolve_fact_ref(&d, fr)?; - if &fact.attribute == attribute { - facts.push(fact); - } + for (key, &pos) in d + .pending_indexes + .aevt + .range(AevtKey::attribute_start(attribute)..) + { + if key.attribute != *attribute { + break; } + facts.push(pending_fact(&d, pos)?); + } + if let Some(reader) = &d.committed { + facts.extend(reader.facts_for_attribute(attribute)?); } - Ok(facts) } } @@ -781,7 +621,7 @@ impl FactStorage { let committed_count = d .committed .as_ref() - .and_then(|l| l.stream_all().ok()) + .and_then(|r| r.all_facts().ok()) .map(|v| v.len()) .unwrap_or(0); committed_count + d.facts.len() @@ -792,15 +632,9 @@ impl FactStorage { self.get_asserted_facts().map(|v| v.len()).unwrap_or(0) } - /// Returns (eavt_len, aevt_len, avet_len, vaet_len). Test use only. - pub(crate) fn index_counts(&self) -> (usize, usize, usize, usize) { - let d = self.data.read().unwrap(); - ( - d.pending_indexes.eavt.len(), - d.pending_indexes.aevt.len(), - d.pending_indexes.avet.len(), - d.pending_indexes.vaet.len(), - ) + /// Returns (eavt_len, aevt_len). Test use only. + pub(crate) fn index_counts(&self) -> (usize, usize) { + self.pending_index_counts() } } @@ -1404,24 +1238,9 @@ mod tests { None, ) .unwrap(); - let (eavt, aevt, avet, vaet) = storage.index_counts(); + let (eavt, aevt) = storage.index_counts(); assert_eq!(eavt, 2); assert_eq!(aevt, 2); - assert_eq!(avet, 2); - assert_eq!(vaet, 1, "Only Ref values go into VAET"); - } - - #[test] - fn test_slot_index_is_zero_in_6_1() { - use uuid::Uuid; - - let storage = FactStorage::new(); - let e = Uuid::new_v4(); - storage - .transact(vec![(e, ":x".to_string(), Value::Integer(1))], None) - .unwrap(); - let (eavt, _, _, _) = storage.index_counts(); - assert_eq!(eavt, 1); } #[test] @@ -1441,186 +1260,99 @@ mod tests { ); storage.load_fact(fact).unwrap(); storage.restore_tx_counter().unwrap(); - let (eavt, _, _, _) = storage.index_counts(); + let (eavt, _) = storage.index_counts(); assert_eq!(eavt, 1); } // ========================================================================= - // Phase 6.2: CommittedFactReader integration tests + // Committed reader integration // ========================================================================= - #[test] - fn test_committed_reader_resolves_facts() { - use crate::storage::CommittedFactReader; - use crate::storage::index::{FactRef, Indexes}; - use std::sync::Arc; - use uuid::Uuid; + /// A committed reader over a fixed fact list, answering by filtering. + struct MockReader(Vec); - /// Mock loader: resolves FactRefs by slot_index into a fixed Vec. - struct MockLoader { - facts: Vec, + impl crate::storage::CommittedReader for MockReader { + fn all_facts(&self) -> anyhow::Result> { + Ok(self.0.clone()) } - impl CommittedFactReader for MockLoader { - fn resolve(&self, fr: FactRef) -> anyhow::Result { - self.facts - .get(fr.slot_index as usize) - .cloned() - .ok_or_else(|| anyhow::anyhow!("MockLoader: no fact at slot {}", fr.slot_index)) - } - fn stream_all(&self) -> anyhow::Result> { - Ok(self.facts.clone()) - } + fn facts_for_entity(&self, e: &EntityId) -> anyhow::Result> { + Ok(self.0.iter().filter(|f| f.entity == *e).cloned().collect()) } - - let storage = FactStorage::new(); - let alice = Uuid::new_v4(); - let committed_fact = Fact::with_valid_time( - alice, - ":name".to_string(), - Value::String("Alice".to_string()), - 0, - 1, - 0, - VALID_TIME_FOREVER, - ); - let loader = Arc::new(MockLoader { - facts: vec![committed_fact.clone()], - }); - - // Insert a committed FactRef into the indexes (page_id > 0 → committed path). - let mut indexes = Indexes::new(); - indexes.insert( - &committed_fact, - FactRef { - page_id: 1, - slot_index: 0, - }, - ); - storage.replace_pending_indexes(indexes); - storage.set_committed_reader(loader); - - // get_facts_by_entity must resolve via CommittedFactReader (EAVT range scan). - let entity_facts = storage.get_facts_by_entity(&alice).unwrap(); - assert_eq!( - entity_facts.len(), - 1, - "EAVT range scan should resolve committed fact" - ); - assert_eq!(entity_facts[0].entity, alice); - assert_eq!(entity_facts[0].attribute, ":name"); - - // get_facts_by_attribute must resolve via CommittedFactReader (AEVT range scan). - let attr_facts = storage - .get_facts_by_attribute(&":name".to_string()) - .unwrap(); - assert_eq!( - attr_facts.len(), - 1, - "AEVT range scan should resolve committed fact" - ); - assert_eq!(attr_facts[0].value, Value::String("Alice".to_string())); - - // get_all_facts must include committed facts via stream_all(). - let all = storage.get_all_facts().unwrap(); - assert_eq!(all.len(), 1, "get_all_facts must include committed facts"); - assert_eq!(all[0].entity, alice); - - // get_facts_as_of should see committed facts. - let as_of = storage - .get_facts_as_of(&crate::query::datalog::types::AsOf::Counter(10)) - .unwrap(); - assert_eq!( - as_of.len(), - 1, - "get_facts_as_of should include committed facts" - ); - - // get_facts_valid_at should see committed facts valid at time 0. - let valid_at = storage.get_facts_valid_at(0).unwrap(); - assert_eq!( - valid_at.len(), - 1, - "get_facts_valid_at should include committed facts" - ); - } - - #[test] - fn test_committed_reader_combined_with_pending() { - use crate::storage::CommittedFactReader; - use crate::storage::index::{FactRef, Indexes}; - use std::sync::Arc; - use uuid::Uuid; - - struct MockLoader { - facts: Vec, + fn facts_for_entity_attribute(&self, e: &EntityId, a: &str) -> anyhow::Result> { + Ok(self + .0 + .iter() + .filter(|f| f.entity == *e && f.attribute == a) + .cloned() + .collect()) } - impl CommittedFactReader for MockLoader { - fn resolve(&self, fr: FactRef) -> anyhow::Result { - self.facts - .get(fr.slot_index as usize) - .cloned() - .ok_or_else(|| anyhow::anyhow!("slot {} not found", fr.slot_index)) - } - fn stream_all(&self) -> anyhow::Result> { - Ok(self.facts.clone()) - } + fn facts_for_attribute(&self, a: &str) -> anyhow::Result> { + Ok(self + .0 + .iter() + .filter(|f| f.attribute == a) + .cloned() + .collect()) } + } + #[test] + fn committed_and_pending_facts_combine_in_every_lookup() { + let (e, other) = (uuid::Uuid::from_u128(1), uuid::Uuid::from_u128(2)); + let committed = vec![ + Fact::with_valid_time( + e, + ":a".into(), + Value::Integer(1), + 10, + 1, + 0, + crate::graph::types::VALID_TIME_FOREVER, + ), + Fact::with_valid_time( + e, + ":b".into(), + Value::Integer(2), + 10, + 1, + 0, + crate::graph::types::VALID_TIME_FOREVER, + ), + Fact::with_valid_time( + other, + ":a".into(), + Value::Integer(3), + 10, + 1, + 0, + crate::graph::types::VALID_TIME_FOREVER, + ), + ]; let storage = FactStorage::new(); - let alice = Uuid::new_v4(); - let bob = Uuid::new_v4(); - - // One committed fact (Alice, on disk) - let alice_fact = Fact::with_valid_time( - alice, - ":name".to_string(), - Value::String("Alice".to_string()), - 1000, - 1, - 1000, - VALID_TIME_FOREVER, - ); - let loader = Arc::new(MockLoader { - facts: vec![alice_fact.clone()], - }); - let mut indexes = Indexes::new(); - indexes.insert( - &alice_fact, - FactRef { - page_id: 1, - slot_index: 0, - }, - ); - storage.replace_pending_indexes(indexes); - storage.set_committed_reader(loader); - - // Restore tx_counter so pending transact gets tx_count = 2 - storage.restore_tx_counter_from(1); - - // One pending fact (Bob, in memory) + storage.set_committed_reader(std::sync::Arc::new(MockReader(committed))); storage - .transact( - vec![(bob, ":name".to_string(), Value::String("Bob".to_string()))], - None, - ) + .transact(vec![(e, ":a".to_string(), Value::Integer(4))], None) .unwrap(); - - // get_all_facts should see both - let all = storage.get_all_facts().unwrap(); + assert_eq!(storage.get_all_facts().unwrap().len(), 4); + assert_eq!(storage.get_facts_by_entity(&e).unwrap().len(), 3); + let ea = storage + .get_facts_by_entity_attribute_indexed(&e, &":a".to_string()) + .unwrap(); + assert_eq!(ea.len(), 2, "one committed and one pending :a for e"); + assert!(ea.iter().all(|f| f.entity == e && f.attribute == ":a")); assert_eq!( - all.len(), - 2, - "Both committed and pending facts must be visible" + storage + .get_facts_by_attribute(&":a".to_string()) + .unwrap() + .len(), + 3 ); - - // get_facts_by_attribute uses AEVT — must also see both - let name_facts = storage - .get_facts_by_attribute(&":name".to_string()) - .unwrap(); assert_eq!( - name_facts.len(), - 2, - "AEVT scan must return both committed and pending facts" + storage + .get_facts_by_entity(&uuid::Uuid::from_u128(9)) + .unwrap() + .len(), + 0 ); } @@ -1654,56 +1386,6 @@ mod tests { ); } - #[test] - fn test_set_committed_index_reader_accepted() { - use crate::storage::CommittedIndexReader; - use crate::storage::index::{AevtKey, AvetKey, EavtKey, FactRef, VaetKey}; - use std::sync::Arc; - - struct NoopReader; - impl CommittedIndexReader for NoopReader { - fn range_scan_eavt( - &self, - _: &EavtKey, - _: Option<&EavtKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_aevt( - &self, - _: &AevtKey, - _: Option<&AevtKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_avet( - &self, - _: &AvetKey, - _: Option<&AvetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_vaet( - &self, - _: &VaetKey, - _: Option<&VaetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - } - - let storage = FactStorage::new(); - // Verify set_committed_index_reader wires the reader (no panic, usable storage) - storage.set_committed_index_reader(Arc::new(NoopReader)); - // After setting, get_facts_by_entity should use the index path without panicking - let result = storage.get_facts_by_entity(&uuid::Uuid::nil()); - assert!( - result.is_ok(), - "storage should be usable after setting committed index reader" - ); - assert_eq!(result.unwrap().len(), 0); - } - #[test] fn test_load_fact_prevents_duplicates() { use crate::graph::types::Value; @@ -2103,230 +1785,10 @@ mod tests { assert_eq!(facts[0].value, Value::Integer(1)); } - #[test] - fn entity_attribute_indexed_no_index_fallback() { - // FactStorage with facts but empty pending indexes and no committed reader - // exercises the fallback branch. - let storage = FactStorage::new(); - let e = uuid::Uuid::from_u128(1); - storage - .transact( - vec![ - (e, ":a".to_string(), Value::Integer(1)), - (e, ":b".to_string(), Value::Integer(2)), - ], - None, - ) - .unwrap(); - storage.replace_pending_indexes(crate::storage::index::Indexes::new()); - let facts = storage - .get_facts_by_entity_attribute_indexed(&e, &":b".to_string()) - .unwrap(); - assert_eq!(facts.len(), 1); - assert_eq!(facts[0].value, Value::Integer(2)); - } - - #[test] - fn entity_attribute_indexed_committed_and_pending() { - use crate::storage::CommittedFactReader; - use crate::storage::index::{FactRef, Indexes}; - use std::sync::Arc; - - struct MockLoader { - facts: Vec, - } - impl CommittedFactReader for MockLoader { - fn resolve(&self, fr: FactRef) -> anyhow::Result { - self.facts - .get(fr.slot_index as usize) - .cloned() - .ok_or_else(|| anyhow::anyhow!("no fact at slot")) - } - fn stream_all(&self) -> anyhow::Result> { - Ok(self.facts.clone()) - } - } - - let storage = FactStorage::new(); - let e = uuid::Uuid::from_u128(1); - let committed = vec![ - make_assert(e, ":a", Value::Integer(1), 1, 0, VALID_TIME_FOREVER), - make_assert(e, ":ab", Value::Integer(9), 1, 0, VALID_TIME_FOREVER), - ]; - let mut indexes = Indexes::new(); - for (slot, f) in committed.iter().enumerate() { - indexes.insert( - f, - FactRef { - page_id: 1, - slot_index: u16::try_from(slot).unwrap(), - }, - ); - } - storage.replace_pending_indexes(indexes); - storage.set_committed_reader(Arc::new(MockLoader { facts: committed })); - - let facts = storage - .get_facts_by_entity_attribute_indexed(&e, &":a".to_string()) - .unwrap(); - assert_eq!(facts.len(), 1, "committed :a only, :ab excluded"); - assert_eq!(facts[0].value, Value::Integer(1)); - } - /// #323 review: the committed EAVT range must end at the exact successor of /// `(e, a)` for every attribute — including ones whose last UTF-8 byte is 0xBF /// (e.g. `:丿`), where incrementing the last byte is not valid UTF-8 and the scan /// used to run unbounded to the end of the index. - #[test] - fn entity_attribute_indexed_committed_range_is_bounded_for_any_attribute() { - use crate::storage::CommittedIndexReader; - use crate::storage::index::{AevtKey, AvetKey, EavtKey, FactRef, VaetKey}; - use std::sync::{Arc, Mutex}; - - struct RecordingReader { - eavt_bounds: Mutex)>>, - } - impl CommittedIndexReader for RecordingReader { - fn range_scan_eavt( - &self, - start: &EavtKey, - end: Option<&EavtKey>, - ) -> anyhow::Result> { - self.eavt_bounds - .lock() - .unwrap() - .push((start.clone(), end.cloned())); - Ok(vec![]) - } - fn range_scan_aevt( - &self, - _: &AevtKey, - _: Option<&AevtKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_avet( - &self, - _: &AvetKey, - _: Option<&AvetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_vaet( - &self, - _: &VaetKey, - _: Option<&VaetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - } - - let reader = Arc::new(RecordingReader { - eavt_bounds: Mutex::new(Vec::new()), - }); - let storage = FactStorage::new(); - storage.set_committed_index_reader(reader.clone()); - let e = uuid::Uuid::from_u128(5); - - for attr in [":plain", ":\u{4e3f}", ":\u{bf}", ":a\u{7f}"] { - storage - .get_facts_by_entity_attribute_indexed(&e, &attr.to_string()) - .unwrap(); - } - - let bounds = reader.eavt_bounds.lock().unwrap(); - assert_eq!(bounds.len(), 4, "one committed scan per lookup"); - for (start, end) in bounds.iter() { - let end = end - .as_ref() - .expect("committed EAVT scan must have an upper bound"); - assert_eq!(end.entity, e, "upper bound must stay within the entity"); - assert!(start < end, "range must be non-empty"); - // No other attribute may sort between start and end. - let sibling = EavtKey { - attribute: format!("{}x", start.attribute), - ..start.clone() - }; - assert!( - sibling >= *end, - "prefix sibling must fall outside the range" - ); - } - } - - #[test] - fn attribute_committed_range_is_bounded_for_any_attribute() { - use crate::storage::CommittedIndexReader; - use crate::storage::index::{AevtKey, AvetKey, EavtKey, FactRef, VaetKey}; - use std::sync::{Arc, Mutex}; - - struct RecordingReader { - aevt_bounds: Mutex)>>, - } - impl CommittedIndexReader for RecordingReader { - fn range_scan_eavt( - &self, - _: &EavtKey, - _: Option<&EavtKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_aevt( - &self, - start: &AevtKey, - end: Option<&AevtKey>, - ) -> anyhow::Result> { - self.aevt_bounds - .lock() - .unwrap() - .push((start.clone(), end.cloned())); - Ok(vec![]) - } - fn range_scan_avet( - &self, - _: &AvetKey, - _: Option<&AvetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - fn range_scan_vaet( - &self, - _: &VaetKey, - _: Option<&VaetKey>, - ) -> anyhow::Result> { - Ok(vec![]) - } - } - - let reader = Arc::new(RecordingReader { - aevt_bounds: Mutex::new(Vec::new()), - }); - let storage = FactStorage::new(); - storage.set_committed_index_reader(reader.clone()); - - for attr in [":plain", ":\u{4e3f}", ":\u{bf}", ":a\u{7f}"] { - storage.get_facts_by_attribute(&attr.to_string()).unwrap(); - } - - let bounds = reader.aevt_bounds.lock().unwrap(); - assert_eq!(bounds.len(), 4, "one committed scan per lookup"); - for (start, end) in bounds.iter() { - let end = end - .as_ref() - .expect("committed AEVT scan must have an upper bound"); - assert!(start < end, "range must be non-empty"); - // No other attribute may sort between start and end. - let sibling = AevtKey { - attribute: format!("{}x", start.attribute), - ..start.clone() - }; - assert!( - sibling >= *end, - "prefix sibling must fall outside the range" - ); - } - } - #[test] fn attribute_scan_excludes_prefix_siblings_and_non_ascii_neighbours() { let storage = FactStorage::new(); @@ -2356,4 +1818,23 @@ mod tests { ); } } + + /// #447: restoring the counter after loading facts never lowers it. + #[test] + fn restore_tx_counter_never_moves_backwards() { + let storage = FactStorage::new(); + storage.restore_tx_counter_from(5); + storage.restore_tx_counter().unwrap(); + assert_eq!(storage.current_tx_count(), 5, "no loaded facts: keep 5"); + let mut f = Fact::new( + uuid::Uuid::from_u128(1), + ":a".to_string(), + Value::Integer(1), + 1, + ); + f.tx_count = 9; + storage.load_fact(f).unwrap(); + storage.restore_tx_counter().unwrap(); + assert_eq!(storage.current_tx_count(), 9, "a higher loaded count wins"); + } } diff --git a/src/query/datalog/evaluator.rs b/src/query/datalog/evaluator.rs index 7e58d225c..866de0c49 100644 --- a/src/query/datalog/evaluator.rs +++ b/src/query/datalog/evaluator.rs @@ -840,10 +840,7 @@ impl StratifiedEvaluator { // Plan: assigns index hints + pushes Expr to earliest binding position. // Not/NotJoin are excluded from plan_clauses above, so `deferred` is // always empty here — rule bodies keep their existing not-filter path. - let (planned, _deferred) = crate::query::datalog::optimizer::plan( - plan_clauses, - &crate::storage::index::Indexes::new(), - ); + let (planned, _deferred) = crate::query::datalog::optimizer::plan(plan_clauses); // Process planned clauses in order: Pattern → join, Expr → filter/extend. let fn_guard = self diff --git a/src/query/datalog/executor.rs b/src/query/datalog/executor.rs index 896629031..298d64f3a 100644 --- a/src/query/datalog/executor.rs +++ b/src/query/datalog/executor.rs @@ -107,7 +107,6 @@ pub struct DatalogExecutor { rules: Arc>, // RwLock pre-wired for 7.7b register_aggregate API. functions: Arc>, - indexes: Arc, max_derived_facts: usize, max_results: usize, } @@ -121,7 +120,6 @@ impl DatalogExecutor { read_now_floor: None, rules: Arc::new(RwLock::new(RuleRegistry::new())), functions: Arc::new(RwLock::new(FunctionRegistry::with_builtins())), - indexes: Arc::new(crate::storage::index::Indexes::new()), max_derived_facts: crate::query::datalog::evaluator::DEFAULT_MAX_DERIVED_FACTS, max_results: crate::query::datalog::evaluator::DEFAULT_MAX_RESULTS, } @@ -141,7 +139,6 @@ impl DatalogExecutor { read_now_floor: None, rules, functions, - indexes: Arc::new(crate::storage::index::Indexes::new()), max_derived_facts: crate::query::datalog::evaluator::DEFAULT_MAX_DERIVED_FACTS, max_results: crate::query::datalog::evaluator::DEFAULT_MAX_RESULTS, } @@ -160,7 +157,6 @@ impl DatalogExecutor { read_now_floor: pending_read_now_floor, rules, functions, - indexes: Arc::new(crate::storage::index::Indexes::new()), max_derived_facts: crate::query::datalog::evaluator::DEFAULT_MAX_DERIVED_FACTS, max_results: crate::query::datalog::evaluator::DEFAULT_MAX_RESULTS, } @@ -189,14 +185,12 @@ impl DatalogExecutor { max_derived_facts: usize, max_results: usize, ) -> Self { - let indexes = storage.pending_indexes_snapshot(); DatalogExecutor { storage, facts_override: None, read_now_floor: None, rules, functions, - indexes: Arc::new(indexes), max_derived_facts, max_results, } @@ -530,7 +524,7 @@ impl DatalogExecutor { .cloned() .collect(); - let (planned, deferred_not_clauses) = optimizer::plan(plan_clauses, &self.indexes); + let (planned, deferred_not_clauses) = optimizer::plan(plan_clauses); // Process planned clauses in order: Pattern → expand bindings, Expr → filter/extend, // Not/NotJoin → shrink bindings as soon as their variables are bound (#248). @@ -780,7 +774,7 @@ impl DatalogExecutor { plan_clauses.push(WhereClause::Pattern(pattern)); } - let (planned, deferred_not_clauses) = optimizer::plan(plan_clauses, &self.indexes); + let (planned, deferred_not_clauses) = optimizer::plan(plan_clauses); // Process planned clauses in order: Pattern → expand, Expr → filter/extend, // Not/NotJoin → shrink bindings as soon as their variables are bound (#248). diff --git a/src/query/datalog/matcher.rs b/src/query/datalog/matcher.rs index ae1261865..c401dd389 100644 --- a/src/query/datalog/matcher.rs +++ b/src/query/datalog/matcher.rs @@ -2,7 +2,6 @@ use super::optimizer::IndexHint; use super::types::{AttributeSpec, EdnValue, Pattern, PseudoAttr}; use crate::graph::FactStorage; use crate::graph::types::{EntityId, Fact, Value}; -use crate::storage::index::Indexes; use std::borrow::Cow; use std::collections::HashMap; use std::sync::Arc; @@ -22,18 +21,13 @@ pub struct PatternMatcher { storage: MatcherStorage, /// The `:db/valid-at` value for this query context (Value::Null when not set). pub(crate) valid_at_value: Value, - #[allow(dead_code)] - /// Indexes for index-guided lookups (Phase 6.2) - indexes: Arc, } impl PatternMatcher { pub fn new(storage: FactStorage) -> Self { - let indexes = storage.pending_indexes_snapshot(); PatternMatcher { storage: MatcherStorage::Owned(storage), valid_at_value: Value::Null, - indexes: Arc::new(indexes), } } @@ -45,7 +39,6 @@ impl PatternMatcher { PatternMatcher { storage: MatcherStorage::Slice(facts), valid_at_value: Value::Null, - indexes: Arc::new(Indexes::new()), } } @@ -55,7 +48,6 @@ impl PatternMatcher { PatternMatcher { storage: MatcherStorage::Slice(facts), valid_at_value: valid_at, - indexes: Arc::new(Indexes::new()), } } @@ -308,91 +300,10 @@ impl PatternMatcher { } } - /// Match a single pattern with an index hint for optimized lookup. - fn match_pattern_with_hint(&self, pattern: &Pattern, hint: &IndexHint) -> Vec { - // Get matching fact references from index - let fact_refs = self.lookup_with_hint(pattern, hint); - - // If no index lookup possible, fall back to full scan - if fact_refs.is_empty() { - return self.match_pattern(pattern); - } - - // Get all facts and filter by the fact refs from index lookup - let facts = self.get_facts(); - - let mut results = Vec::new(); - for fact in &*facts { - if let Some(bindings) = self.match_fact_against_pattern(fact, pattern) { - results.push(bindings); - } - } - - results - } - - /// Look up fact references using the index based on pattern and hint. - fn lookup_with_hint( - &self, - pattern: &Pattern, - hint: &IndexHint, - ) -> Vec { - let indexes = &self.indexes; - - match hint { - IndexHint::Eavt => { - // If entity is bound, use EAVT entity lookup - if let EdnValue::Uuid(entity) = &pattern.entity { - return indexes.lookup_eavt_entity(*entity); - } - // Fall back to full scan - vec![] - } - IndexHint::Aevt => { - // If attribute is bound, use AEVT attribute lookup - if let AttributeSpec::Real(EdnValue::Keyword(attr)) = &pattern.attribute { - return indexes.lookup_aevt_attr(attr); - } - // Fall back to full scan - vec![] - } - IndexHint::Avet => { - // If attribute and value are bound, use AVET - let attr_bound = match &pattern.attribute { - AttributeSpec::Real(edn) => { - if let EdnValue::Keyword(attr) = edn { - Some(attr.clone()) - } else { - None - } - } - _ => None, - }; - let value_bound = match &pattern.value { - EdnValue::Keyword(k) => Some(Value::Keyword(k.clone())), - EdnValue::String(s) => Some(Value::String(s.clone())), - EdnValue::Integer(i) => Some(Value::Integer(*i)), - EdnValue::Float(f) => Some(Value::Float(*f)), - EdnValue::Boolean(b) => Some(Value::Boolean(*b)), - EdnValue::Uuid(u) => Some(Value::Ref(*u)), - _ => None, - }; - - if let (Some(attr), Some(value)) = (attr_bound, value_bound) { - return indexes.lookup_avet_attr_value(&attr, &value); - } - // Fall back to full scan - vec![] - } - IndexHint::Vaet => { - // If value is a Ref, use VAET reverse lookup - if let EdnValue::Uuid(target) = &pattern.value { - return indexes.lookup_vaet_ref(*target); - } - // Fall back to full scan - vec![] - } - } + /// Match a single pattern. Every pattern scans the matcher's facts; the + /// hint only orders patterns in the optimizer. + fn match_pattern_with_hint(&self, pattern: &Pattern, _hint: &IndexHint) -> Vec { + self.match_pattern(pattern) } /// Match multiple patterns starting from existing seed bindings. diff --git a/src/query/datalog/optimizer.rs b/src/query/datalog/optimizer.rs index ed4e959c2..2c1ade8d9 100644 --- a/src/query/datalog/optimizer.rs +++ b/src/query/datalog/optimizer.rs @@ -169,7 +169,6 @@ fn clause_ref_vars(clauses: &[WhereClause]) -> std::collections::HashSet /// `Some(hint)` and each Expr/Not/NotJoin with `None`. pub fn plan( clauses: Vec, - _indexes: &crate::storage::index::Indexes, ) -> (Vec<(WhereClause, Option)>, Vec) { // Separate into patterns (with hints), exprs, and not/not-join clauses. let mut patterns: Vec<(WhereClause, IndexHint)> = Vec::new(); @@ -433,15 +432,11 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_join_ordering_moves_selective_pattern_first() { - use crate::storage::index::Indexes; let p1 = make_pattern(var("e"), kw(":age"), var("a")); // selectivity 1 (attr only) let p2 = make_pattern(entity_lit(), kw(":name"), var("v")); // selectivity 2 (entity + attr) let p1_attr = p1.attribute.clone(); let p2_attr = p2.attribute.clone(); - let (planned, _deferred) = plan( - vec![WhereClause::Pattern(p1), WhereClause::Pattern(p2)], - &Indexes::new(), - ); + let (planned, _deferred) = plan(vec![WhereClause::Pattern(p1), WhereClause::Pattern(p2)]); let first_attr = match &planned[0].0 { WhereClause::Pattern(p) => p.attribute.clone(), _ => panic!("expected Pattern at index 0"), @@ -520,9 +515,8 @@ mod tests { fn test_plan_pattern_carries_some_hint() { #[cfg(not(feature = "wasm"))] { - use crate::storage::index::Indexes; let p = WhereClause::Pattern(make_pattern(var("e"), kw(":val"), var("v"))); - let (planned, _deferred) = plan(vec![p], &Indexes::new()); + let (planned, _deferred) = plan(vec![p]); assert!( planned[0].1.is_some(), "Pattern entry must carry Some(IndexHint)" @@ -534,13 +528,12 @@ mod tests { fn test_plan_expr_carries_none_hint() { #[cfg(not(feature = "wasm"))] { - use crate::storage::index::Indexes; let p = WhereClause::Pattern(make_pattern(var("e"), kw(":val"), var("v"))); let expr = WhereClause::Expr { expr: Expr::Lit(Value::Boolean(true)), binding: None, }; - let (planned, _deferred) = plan(vec![p, expr], &Indexes::new()); + let (planned, _deferred) = plan(vec![p, expr]); let expr_entry = planned .iter() .find(|(c, _)| matches!(c, WhereClause::Expr { .. })); @@ -555,7 +548,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_expr_pushed_after_binding_pattern() { - use crate::storage::index::Indexes; // Three patterns with equal selectivity (1 attr bound each) — stable sort preserves // original order: [p1, p2, p3]. Expr needs ?v, bound by p2 (pos 1). // Expected output: [p1, p2, expr, p3]. @@ -570,7 +562,7 @@ mod tests { ), binding: None, }; - let (planned, _deferred) = plan(vec![p1, p2, p3, expr], &Indexes::new()); + let (planned, _deferred) = plan(vec![p1, p2, p3, expr]); assert_eq!(planned.len(), 4); // Item at index 2 must be the Expr (pushed after p2 which binds ?v at index 1). assert!( @@ -587,13 +579,12 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_expr_no_vars_goes_to_end() { - use crate::storage::index::Indexes; let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":val"), var("v"))); let expr = WhereClause::Expr { expr: Expr::Lit(Value::Boolean(true)), binding: None, }; - let (planned, _deferred) = plan(vec![p1, expr], &Indexes::new()); + let (planned, _deferred) = plan(vec![p1, expr]); assert_eq!(planned.len(), 2); assert!( matches!(planned[1].0, WhereClause::Expr { .. }), @@ -604,7 +595,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_expr_unbound_var_goes_to_end() { - use crate::storage::index::Indexes; // ?x is never bound by any pattern let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":val"), var("v"))); let expr = WhereClause::Expr { @@ -615,7 +605,7 @@ mod tests { ), binding: None, }; - let (planned, _deferred) = plan(vec![p1, expr], &Indexes::new()); + let (planned, _deferred) = plan(vec![p1, expr]); assert_eq!(planned.len(), 2); assert!( matches!(planned[1].0, WhereClause::Expr { .. }), @@ -628,7 +618,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_pushed_after_binding_pattern() { - use crate::storage::index::Indexes; // p1 binds ?e/?n, p2 binds ?e/?v. Not body needs ?v, bound at p2 (pos 1). // Expected: [p1, p2, not, p3]. let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":name"), var("n"))); @@ -639,7 +628,7 @@ mod tests { kw(":flag"), EdnValue::Boolean(true), ))]); - let (planned, deferred) = plan(vec![p1, p2, p3, not_clause], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, p2, p3, not_clause]); assert_eq!(planned.len(), 4); assert!(deferred.is_empty(), "not clause must be placeable"); assert!( @@ -655,7 +644,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_with_unbound_var_is_deferred() { - use crate::storage::index::Indexes; // Not body references ?z, which no pattern in this list ever binds // (e.g. it's bound only by an Or clause the executor applies afterward). let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":name"), var("n"))); @@ -664,7 +652,7 @@ mod tests { kw(":flag"), EdnValue::Boolean(true), ))]); - let (planned, deferred) = plan(vec![p1, not_clause.clone()], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, not_clause.clone()]); assert_eq!( planned.len(), 1, @@ -677,7 +665,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_join_pushed_to_correct_position() { - use crate::storage::index::Indexes; // not-join requires only ?e bound (join_vars), which p1 binds at pos 0. // A local existential ?x inside the body is NOT required from the outer scope. let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":name"), var("n"))); @@ -690,7 +677,7 @@ mod tests { var("x"), ))], }; - let (planned, deferred) = plan(vec![p1, p2, nj], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, p2, nj]); assert_eq!(planned.len(), 3); assert!(deferred.is_empty()); assert!( @@ -702,7 +689,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_join_unbound_join_var_is_deferred() { - use crate::storage::index::Indexes; let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":name"), var("n"))); let nj = WhereClause::NotJoin { join_vars: vec!["?missing".to_string()], @@ -712,7 +698,7 @@ mod tests { var("x"), ))], }; - let (planned, deferred) = plan(vec![p1, nj], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, nj]); assert_eq!(planned.len(), 1); assert_eq!( deferred.len(), @@ -724,7 +710,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_with_no_required_vars_goes_first() { - use crate::storage::index::Indexes; // Not body is fully self-contained (no outer vars referenced) — safe to run // before every pattern. let p1 = WhereClause::Pattern(make_pattern(var("e"), kw(":name"), var("n"))); @@ -733,7 +718,7 @@ mod tests { kw(":flag"), EdnValue::Boolean(true), ))]); - let (planned, deferred) = plan(vec![p1, not_clause], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, not_clause]); assert_eq!(planned.len(), 2); assert!(deferred.is_empty()); assert!( @@ -745,7 +730,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_not_wildcard_var_does_not_block_placement() { - use crate::storage::index::Indexes; // `?_x` is the wildcard idiom — it must not count as an outer dependency, // matching the parser's own treatment of `?_`-prefixed vars (and the // NotJoin arm's `join_vars` filter below). @@ -756,7 +740,7 @@ mod tests { kw(":name"), var("_x"), ))]); - let (planned, deferred) = plan(vec![p1, p2, not_clause], &Indexes::new()); + let (planned, deferred) = plan(vec![p1, p2, not_clause]); assert_eq!(planned.len(), 3); assert!( deferred.is_empty(), @@ -771,7 +755,6 @@ mod tests { #[cfg(not(feature = "wasm"))] #[test] fn test_multiple_not_clauses_at_same_position_stay_cheapest_first() { - use crate::storage::index::Indexes; // Both Not bodies need only ?e (bound by p1) and land at the same insertion // point. The cheaper one (fully-bound pattern, cost 1) must end up before // the more expensive one (2-bound pattern, cost 10) in the final plan. @@ -794,10 +777,7 @@ mod tests { ]); // Declared expensive-first, so a naive "preserve input order" bug wouldn't // be masked by already-correct declaration order. - let (planned, deferred) = plan( - vec![p1, expensive_not.clone(), cheap_not.clone()], - &Indexes::new(), - ); + let (planned, deferred) = plan(vec![p1, expensive_not.clone(), cheap_not.clone()]); assert_eq!(planned.len(), 3); assert!(deferred.is_empty()); let cheap_pos = planned diff --git a/src/storage/btree_v6.rs b/src/storage/btree_v6.rs deleted file mode 100644 index bbeb22795..000000000 --- a/src/storage/btree_v6.rs +++ /dev/null @@ -1,2366 +0,0 @@ -//! On-disk B+tree for covering index persistence (file format v6). -//! -//! Each node maps to exactly one 4KB page. The `PageCache` serves all reads. -//! `build_btree` does a bulk-build (write-all-leaves, then internal levels -//! bottom-up). Range scans traverse the tree through the cache. - -use crate::error::{ErrorCode, bail_coded, err_coded}; -use crate::storage::cache::PageCache; -use crate::storage::index::FactRef; -use crate::storage::{PAGE_SIZE, StorageBackend}; -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; -use std::sync::Mutex; - -// ─── Page type constants ─────────────────────────────────────────────────────── - -use crate::storage::page::{PAGE_HEADER_SIZE, PageAllocator}; -pub use crate::storage::page::{PAGE_TYPE_INTERNAL, PAGE_TYPE_LEAF}; - -// ─── Fixed sizes ────────────────────────────────────────────────────────────── - -/// Leaf page fixed header: the common page header (entry count in `count`). -const LEAF_HEADER_SIZE: usize = PAGE_HEADER_SIZE; -/// Offset of an internal node's `rightmost_child` (u64), right after the common header. -const RIGHTMOST_CHILD_OFFSET: usize = PAGE_HEADER_SIZE; -/// Internal page fixed header: common header (key count in `count`) + rightmost_child(8). -const INTERNAL_HEADER_SIZE: usize = PAGE_HEADER_SIZE + 8; -/// Slot directory entry: offset(u16) + length(u16) = 4 bytes. -const SLOT_SIZE: usize = 4; -/// Fill-factor threshold: stop packing once total used bytes exceed this (~75% of PAGE_SIZE). -const PAGE_FILL_BYTES: usize = PAGE_SIZE * 3 / 4; - -// ─── Safe slice access helpers ─────────────────────────────────────────────── - -/// Read a u16 from 2 bytes at the given offset, returning an error if out of bounds. -fn read_u16_at(page: &[u8], offset: usize) -> Result { - let bytes = page.get(offset..offset.saturating_add(2)).ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("out of bounds: read_u16 at {offset} (len {})", page.len()) - ) - })?; - Ok(u16::from_le_bytes(bytes.try_into().map_err(|_| { - err_coded!(ErrorCode::Int049, format!("slice at {offset} not 2 bytes")) - })?)) -} - -/// Read a u64 from 8 bytes at the given offset, returning an error if out of bounds. -fn read_u64_at(page: &[u8], offset: usize) -> Result { - let bytes = page.get(offset..offset.saturating_add(8)).ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("out of bounds: read_u64 at {offset} (len {})", page.len()) - ) - })?; - Ok(u64::from_le_bytes(bytes.try_into().map_err(|_| { - err_coded!(ErrorCode::Int049, format!("slice at {offset} not 8 bytes")) - })?)) -} - -// ─── Low-level page writers ─────────────────────────────────────────────────── - -/// Encode a leaf page: fixed header, slot directory, entries written end-to-start. -/// -/// `entries`: each element is the postcard-serialised `(K, FactRef)` bytes for -/// one index entry, in sort order. -#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] -fn encode_leaf_page(entries: &[Vec]) -> Result> { - let entry_count = u16::try_from(entries.len()).map_err(|_| { - err_coded!( - ErrorCode::Int049, - format!("too many entries: {}", entries.len()) - ) - })?; - let used = - LEAF_HEADER_SIZE + entries.len() * SLOT_SIZE + entries.iter().map(Vec::len).sum::(); - if used > PAGE_SIZE { - bail_coded!( - ErrorCode::Int049, - format!("leaf entries need {used} bytes, more than a page") - ); - } - let mut page = crate::storage::page::new_page(PAGE_TYPE_LEAF, entry_count); - - // Slot directory follows the header; data written end-to-start - let mut write_pos = PAGE_SIZE; - for (i, entry) in entries.iter().enumerate() { - write_pos -= entry.len(); - page[write_pos..write_pos + entry.len()].copy_from_slice(entry); - let slot_off = LEAF_HEADER_SIZE + i * SLOT_SIZE; - let write_pos_u16 = u16::try_from(write_pos).map_err(|_| { - err_coded!( - ErrorCode::Int048, - format!("write_pos {write_pos} exceeds u16") - ) - })?; - let entry_len_u16 = u16::try_from(entry.len()).map_err(|_| { - err_coded!( - ErrorCode::Int048, - format!("entry len {} exceeds u16", entry.len()) - ) - })?; - page[slot_off..slot_off + 2].copy_from_slice(&write_pos_u16.to_le_bytes()); - page[slot_off + 2..slot_off + 4].copy_from_slice(&entry_len_u16.to_le_bytes()); - } - Ok(page) -} - -/// Write a single leaf page at a newly allocated id and insert it into the cache. -fn write_leaf_page( - backend: &mut dyn StorageBackend, - cache: &PageCache, - alloc: &mut PageAllocator, - entries: &[Vec], -) -> Result { - let page = encode_leaf_page(entries)?; - let page_id = alloc.alloc()?; - alloc.write(backend, cache, page_id, page)?; - Ok(page_id) -} - -/// Write a single internal node page at a newly allocated id and insert it into the cache. -/// -/// `child_ids`: all child page IDs in order; the last one is `rightmost_child`. -/// `sep_bytes`: postcard-serialised Key bytes for each separator key. -/// `sep_bytes[j]` = first key of `child_ids[j+1]`'s subtree. -/// `sep_bytes.len()` == `child_ids.len() - 1`. -#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] -fn write_internal_page( - backend: &mut dyn StorageBackend, - cache: &PageCache, - alloc: &mut PageAllocator, - child_ids: &[u64], - sep_bytes: &[Vec], -) -> Result { - debug_assert_eq!(child_ids.len(), sep_bytes.len() + 1); - // Defensive check: empty child_ids would cause panic on .last() - if child_ids.is_empty() { - bail_coded!(ErrorCode::Stg011); - } - let key_count = u16::try_from(sep_bytes.len()).map_err(|_| { - err_coded!( - ErrorCode::Int049, - format!("too many sep keys: {}", sep_bytes.len()) - ) - })?; - let rightmost_child = *child_ids - .last() - .ok_or_else(|| err_coded!(ErrorCode::Int049, "child_ids is empty".to_string()))?; - - let used = INTERNAL_HEADER_SIZE - + sep_bytes.len() * (8 + SLOT_SIZE) - + sep_bytes.iter().map(Vec::len).sum::(); - if used > PAGE_SIZE { - bail_coded!( - ErrorCode::Int049, - format!("internal node needs {used} bytes, more than a page") - ); - } - let mut page = crate::storage::page::new_page(PAGE_TYPE_INTERNAL, key_count); - page[RIGHTMOST_CHILD_OFFSET..RIGHTMOST_CHILD_OFFSET + 8] - .copy_from_slice(&rightmost_child.to_le_bytes()); - - // Child array: key_count entries after the fixed header - let child_arr_start = INTERNAL_HEADER_SIZE; - for (i, &cid) in child_ids[..child_ids.len() - 1].iter().enumerate() { - let off = child_arr_start + i * 8; - page[off..off + 8].copy_from_slice(&cid.to_le_bytes()); - } - - // Slot directory for separator keys: after child array - let slot_dir_start = INTERNAL_HEADER_SIZE + (key_count as usize) * 8; - - // Separator key data written end-to-start - let mut write_pos = PAGE_SIZE; - for (i, sep) in sep_bytes.iter().enumerate() { - write_pos -= sep.len(); - page[write_pos..write_pos + sep.len()].copy_from_slice(sep); - let slot_off = slot_dir_start + i * SLOT_SIZE; - let write_pos_u16 = u16::try_from(write_pos).map_err(|_| { - err_coded!( - ErrorCode::Int048, - format!("write_pos {write_pos} exceeds u16") - ) - })?; - let sep_len_u16 = u16::try_from(sep.len()).map_err(|_| { - err_coded!( - ErrorCode::Int048, - format!("sep len {} exceeds u16", sep.len()) - ) - })?; - page[slot_off..slot_off + 2].copy_from_slice(&write_pos_u16.to_le_bytes()); - page[slot_off + 2..slot_off + 4].copy_from_slice(&sep_len_u16.to_le_bytes()); - } - - let page_id = alloc.alloc()?; - alloc.write(backend, cache, page_id, page)?; - Ok(page_id) -} - -/// True when adding an entry of `entry_len` bytes to a leaf that already holds -/// `n_entries` entries totalling `data_bytes` would exceed the fill threshold. -#[allow(clippy::arithmetic_side_effects)] -fn leaf_overflows(n_entries: usize, data_bytes: usize, entry_len: usize) -> bool { - LEAF_HEADER_SIZE + (n_entries + 1) * SLOT_SIZE + data_bytes + entry_len > PAGE_FILL_BYTES -} - -// ─── build_btree ────────────────────────────────────────────────────────────── - -/// Serialize `(key, fact_ref)` pairs into the byte format expected by [`build_btree`]. -/// -/// Each item produces `(entry_bytes, key_bytes)` where: -/// - `entry_bytes` = postcard encoding of `(&key, &fact_ref)` — stored in leaf nodes -/// - `key_bytes` = postcard encoding of `&key` alone — used as separator in internal nodes -/// -/// Callers **must sort** entries before calling; this function preserves order. -/// Keeping serialisation in this small generic helper means `build_btree` itself -/// is monomorphised only once. -pub fn btree_entries( - iter: impl Iterator, -) -> Result, Vec)>> { - iter.map(|(key, fact_ref)| { - let entry_bytes = postcard::to_allocvec(&(&key, &fact_ref))?; - let key_bytes = postcard::to_allocvec(&key)?; - Ok((entry_bytes, key_bytes)) - }) - .collect() -} - -/// Build a B+tree from pre-serialised sorted entries and write it to the backend. -/// -/// Each item in `sorted_entries` is `(entry_bytes, key_bytes)` as produced by -/// [`btree_entries`]. Entries **must already be sorted** by key. -/// -/// Pages are taken from `alloc` and stamped with its generation. Returns the -/// root page id. -/// -/// All written pages are inserted into `cache` via `put_dirty`. -#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] -pub fn build_btree( - sorted_entries: impl Iterator, Vec)>, - backend: &mut dyn StorageBackend, - cache: &PageCache, - alloc: &mut PageAllocator, -) -> Result { - // ── Phase 1: pack entries into leaf pages ───────────────────────────────── - let mut leaf_infos: Vec<(u64, Vec)> = Vec::new(); - - let mut cur_entries: Vec> = Vec::new(); - let mut cur_data_bytes: usize = 0; - let mut cur_first_key: Option> = None; - - for (entry_bytes, key_bytes) in sorted_entries { - if !cur_entries.is_empty() - && leaf_overflows(cur_entries.len(), cur_data_bytes, entry_bytes.len()) - { - let page_id = write_leaf_page(backend, cache, alloc, &cur_entries)?; - let first_key = cur_first_key.take().ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - "BUG: cur_first_key empty when writing leaf page".to_string() - ) - })?; - leaf_infos.push((page_id, first_key)); - cur_entries.clear(); - cur_data_bytes = 0; - cur_first_key = None; - } - - if cur_first_key.is_none() { - cur_first_key = Some(key_bytes); - } - cur_data_bytes += entry_bytes.len(); - cur_entries.push(entry_bytes); - } - - // Flush the last (or only) batch - if cur_entries.is_empty() && leaf_infos.is_empty() { - // Empty tree: single empty leaf - return write_leaf_page(backend, cache, alloc, &[]); - } - if !cur_entries.is_empty() { - let page_id = write_leaf_page(backend, cache, alloc, &cur_entries)?; - let first_key = cur_first_key.take().ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - "BUG: cur_first_key empty when flushing last leaf page".to_string() - ) - })?; - leaf_infos.push((page_id, first_key)); - } - - build_internal_levels(leaf_infos, backend, cache, alloc) -} - -/// Build internal levels bottom-up over `leaf_infos` (`(page_id, first_key_bytes)` -/// per leaf, in key order), writing nodes at ids taken from `alloc`. -/// -/// Returns the root page id. A single leaf is its own root. -#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] -fn build_internal_levels( - leaf_infos: Vec<(u64, Vec)>, - backend: &mut dyn StorageBackend, - cache: &PageCache, - alloc: &mut PageAllocator, -) -> Result { - if leaf_infos.is_empty() { - bail_coded!( - ErrorCode::Int049, - "build_internal_levels: no leaves".to_string() - ); - } - let mut current_level = leaf_infos; - - loop { - if current_level.len() == 1 { - return Ok(current_level - .first() - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - "current_level unexpectedly empty".to_string() - ) - })? - .0); - } - - let mut next_level: Vec<(u64, Vec)> = Vec::new(); - let mut i = 0; - - while i < current_level.len() { - let i_start = i; - let first_entry = current_level.get(i).ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("current_level[{i}] out of bounds") - ) - })?; - let mut child_ids: Vec = vec![first_entry.0]; - let mut sep_bytes: Vec> = Vec::new(); - let mut sep_data_bytes: usize = 0; - i += 1; - - while i < current_level.len() { - let entry = current_level.get(i).ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("current_level[{i}] out of bounds") - ) - })?; - let sep = entry.1.clone(); - let projected = INTERNAL_HEADER_SIZE - + (child_ids.len() - 1) * 8 - + (sep_bytes.len() + 1) * SLOT_SIZE - + sep_data_bytes - + sep.len(); - - if projected > PAGE_FILL_BYTES && !sep_bytes.is_empty() { - break; - } - - sep_data_bytes += sep.len(); - sep_bytes.push(sep); - child_ids.push( - current_level - .get(i) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("current_level[{i}] out of bounds") - ) - })? - .0, - ); - i += 1; - } - - let node_page_id = write_internal_page(backend, cache, alloc, &child_ids, &sep_bytes)?; - - let first_key = current_level - .get(i_start) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("current_level[{i_start}] out of bounds") - ) - })? - .1 - .clone(); - next_level.push((node_page_id, first_key)); - } - - current_level = next_level; - } -} - -/// Merge two already-sorted `Vec`s into a single sorted iterator. -/// -/// Used by `PersistentFactStorage::save()` to merge committed B+tree entries -/// with new pending entries before building the replacement B+tree. -pub fn merge_sorted_vecs(a: Vec, b: Vec) -> impl Iterator { - let mut ai = a.into_iter().peekable(); - let mut bi = b.into_iter().peekable(); - std::iter::from_fn(move || match (ai.peek(), bi.peek()) { - (Some(_), Some(_)) => { - if ai.peek() <= bi.peek() { - ai.next() - } else { - bi.next() - } - } - (Some(_), None) => ai.next(), - (None, Some(_)) => bi.next(), - (None, None) => None, - }) -} - -// ─── Incremental rebuild (#315) ─────────────────────────────────────────────── - -/// Collect the raw leaf pages of the B+tree at `root_page_id`, in key order. -/// -/// Leaves are found by walking child pointers depth-first, and their entries -/// are not decoded. Every node visited is also pushed onto `nodes_out` (when -/// given), so `save()` can free the whole old tree. -pub fn collect_leaf_pages( - root_page_id: u64, - backend: &dyn StorageBackend, - cache: &PageCache, - mut nodes_out: Option<&mut Vec>, -) -> Result>>> { - // A tree cannot have more nodes than the file has pages; more visits - // means a cycle in the child pointers. - let max_visits = backend.page_count()?; - let mut visits: u64 = 0; - let mut leaves = Vec::new(); - let mut stack = vec![(root_page_id, 0usize)]; - while let Some((page_id, depth)) = stack.pop() { - visits = visits.saturating_add(1); - if depth > MAX_TREE_DEPTH || visits > max_visits { - bail_coded!( - ErrorCode::Int049, - format!( - "collect_leaf_pages: cycle or corruption in child pointers at page_id={page_id}" - ) - ); - } - let page = cache.get_or_load(page_id, backend)?; - if let Some(nodes) = nodes_out.as_deref_mut() { - nodes.push(page_id); - } - match page.first().copied() { - Some(PAGE_TYPE_LEAF) => { - validate_leaf_slots(&page[..], page_id)?; - leaves.push(page); - } - Some(PAGE_TYPE_INTERNAL) => { - let children = internal_children(&page[..])?; - stack.extend( - children - .into_iter() - .rev() - .map(|c| (c, depth.saturating_add(1))), - ); - } - _ => bail_coded!(ErrorCode::Stg013, page_id), - } - } - Ok(leaves) -} - -/// Check a leaf's slot directory stays inside the page without decoding entries. -/// -/// Untouched leaves are copied verbatim and resealed under a new page id and -/// generation, so damage the page CRC cannot see must be caught here instead of -/// being carried forward. -#[allow(clippy::arithmetic_side_effects)] -fn validate_leaf_slots(page: &[u8], page_id: u64) -> Result<()> { - let count = read_u16_at(page, 2)? as usize; - let data_start = LEAF_HEADER_SIZE + count * SLOT_SIZE; - if data_start > page.len() { - bail_coded!( - ErrorCode::Int049, - format!("leaf page_id={page_id}: slot directory overflows the page") - ); - } - for i in 0..count { - let slot_off = LEAF_HEADER_SIZE + i * SLOT_SIZE; - let offset = read_u16_at(page, slot_off)? as usize; - let length = read_u16_at(page, slot_off + 2)? as usize; - if offset < data_start || offset + length > page.len() { - bail_coded!( - ErrorCode::Int049, - format!("leaf page_id={page_id}: slot {i} out of bounds") - ); - } - } - Ok(()) -} - -/// Decode the key of a leaf's first entry; `None` for an empty leaf. -#[allow(clippy::arithmetic_side_effects)] -fn leaf_first_key(page: &[u8]) -> Result> -where - K: for<'de> Deserialize<'de>, -{ - if read_u16_at(page, 2)? == 0 { - return Ok(None); - } - let offset = read_u16_at(page, LEAF_HEADER_SIZE)? as usize; - let length = read_u16_at(page, LEAF_HEADER_SIZE + 2)? as usize; - let slice = page - .get(offset..offset.saturating_add(length)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!("first entry out of bounds: offset={offset} len={length}") - ) - })?; - let (key, _): (K, FactRef) = postcard::from_bytes(slice)?; - Ok(Some(key)) -} - -/// Writes leaf pages at ids taken from an allocator, recording each leaf's id and -/// first key for [`build_internal_levels`]. -struct LeafEmitter<'a> { - backend: &'a mut dyn StorageBackend, - cache: &'a PageCache, - alloc: &'a mut PageAllocator, - infos: Vec<(u64, Vec)>, -} - -impl LeafEmitter<'_> { - /// Write `page` (header type/count and body set; id, generation and CRC are - /// stamped here) at a new id. - fn push(&mut self, page: Vec, first_key_bytes: Vec) -> Result<()> { - let page_id = self.alloc.alloc()?; - self.alloc - .write(&mut *self.backend, self.cache, page_id, page)?; - self.infos.push((page_id, first_key_bytes)); - Ok(()) - } -} - -/// Serialise sorted entries and emit them as leaves. -/// -/// Entries that fit in one physical page stay in one leaf. Otherwise they are -/// split into pages of at most `PAGE_FILL_BYTES`, balanced by size, so a leaf that -/// overflows by one entry splits roughly in half. Splitting "full page + remainder" -/// instead left a near-empty leaf behind on every checkpoint that inserted into a -/// full leaf, growing the index without bound (#315). -#[allow(clippy::arithmetic_side_effects)] -fn emit_packed( - emitter: &mut LeafEmitter<'_>, - entries: impl Iterator, -) -> Result<()> { - // Each entry costs its bytes plus one slot-directory entry. - let mut serialised: Vec<(Vec, K)> = Vec::new(); - let mut total = 0usize; - for (key, fact_ref) in entries { - let entry = postcard::to_allocvec(&(&key, &fact_ref))?; - total += entry.len() + SLOT_SIZE; - serialised.push((entry, key)); - } - if serialised.is_empty() { - return Ok(()); - } - let target = if LEAF_HEADER_SIZE + total <= PAGE_SIZE { - total - } else { - let usable = PAGE_FILL_BYTES - LEAF_HEADER_SIZE; - total.div_ceil(total.div_ceil(usable)) - }; - - let mut cur: Vec> = Vec::new(); - let mut cur_size = 0usize; - let mut cur_first: Option> = None; - for (entry, key) in serialised { - let size = entry.len() + SLOT_SIZE; - if !cur.is_empty() && cur_size + size > target { - let first = cur_first.take().ok_or_else(|| { - err_coded!(ErrorCode::Int049, "BUG: leaf without first key".to_string()) - })?; - emitter.push(encode_leaf_page(&cur)?, first)?; - cur.clear(); - cur_size = 0; - } - if cur_first.is_none() { - cur_first = Some(postcard::to_allocvec(&key)?); - } - cur_size += size; - cur.push(entry); - } - if let Some(first) = cur_first { - emitter.push(encode_leaf_page(&cur)?, first)?; - } - Ok(()) -} - -/// Rebuild a B+tree from its old leaves plus sorted `pending` entries. -/// -/// Leaves that receive no pending entry are copied verbatim (only the page id, -/// generation and CRC are restamped); only leaves that do are decoded, merged -/// and repacked. Internal -/// levels are rebuilt from scratch. Pending keys route to leaf `i` when -/// `first_key[i] <= key < first_key[i + 1]`; keys below the first leaf's first -/// key go to leaf 0. With no non-empty old leaves this is a plain bulk build. -/// -/// Every page is written at an id from `alloc`, which never hands out a page -/// the old tree uses. Returns the root page id. -pub fn rebuild_btree_incremental( - old_leaves: Vec>>, - pending: Vec<(K, FactRef)>, - backend: &mut dyn StorageBackend, - cache: &PageCache, - alloc: &mut PageAllocator, -) -> Result -where - K: Serialize + for<'de> Deserialize<'de> + Ord, -{ - let mut leaves: Vec<(Arc>, K)> = Vec::with_capacity(old_leaves.len()); - for page in old_leaves { - if let Some(first_key) = leaf_first_key::(&page[..])? { - leaves.push((page, first_key)); - } - } - if leaves.is_empty() { - return build_btree( - btree_entries(pending.into_iter())?.into_iter(), - backend, - cache, - alloc, - ); - } - - let mut emitter = LeafEmitter { - backend: &mut *backend, - cache, - alloc: &mut *alloc, - infos: Vec::new(), - }; - let mut pending = pending.into_iter().peekable(); - for (i, (page, first_key)) in leaves.iter().enumerate() { - let upper = leaves.get(i.saturating_add(1)).map(|(_, k)| k); - let mut batch = Vec::new(); - while let Some((key, _)) = pending.peek() { - if upper.is_some_and(|u| key >= u) { - break; - } - if let Some(entry) = pending.next() { - batch.push(entry); - } - } - if batch.is_empty() { - emitter.push((**page).clone(), postcard::to_allocvec(first_key)?)?; - } else { - let old = read_leaf_entries::(&page[..])?; - emit_packed(&mut emitter, merge_sorted_vecs(old, batch))?; - } - } - let leaf_infos = emitter.infos; - build_internal_levels(leaf_infos, backend, cache, alloc) -} - -// ─── Leaf traversal helpers ─────────────────────────────────────────────────── - -/// Read all `(K, FactRef)` entries from a leaf page's slot directory. -#[allow(clippy::arithmetic_side_effects)] -fn read_leaf_entries(page: &[u8]) -> Result> -where - K: for<'de> Deserialize<'de>, -{ - let entry_count = read_u16_at(page, 2)? as usize; - let mut entries = Vec::with_capacity(entry_count); - for i in 0..entry_count { - let slot_off = LEAF_HEADER_SIZE + i * SLOT_SIZE; - let offset = read_u16_at(page, slot_off)? as usize; - let length = read_u16_at(page, slot_off + 2)? as usize; - let slice = page - .get(offset..offset.saturating_add(length)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!( - "entry slice out of bounds: offset={offset} len={length} page_len={}", - page.len() - ) - ) - })?; - let (k, fr): (K, FactRef) = postcard::from_bytes(slice)?; - entries.push((k, fr)); - } - Ok(entries) -} - -// ─── LeafCursor ─────────────────────────────────────────────────────────────── - -/// Deepest tree a reader descends. Every internal node has at least two -/// children, so a real tree this deep would need 2^32 leaves; anything deeper -/// is a cycle in the child pointers or corruption. -const MAX_TREE_DEPTH: usize = 32; - -/// Child page ids of an internal node, in key order (`rightmost_child` last). -#[allow(clippy::arithmetic_side_effects)] -fn internal_children(page: &[u8]) -> Result> { - let key_count = read_u16_at(page, 2)? as usize; - let mut children = Vec::with_capacity(key_count + 1); - for i in 0..key_count { - children.push(read_u64_at(page, INTERNAL_HEADER_SIZE + i * 8)?); - } - children.push(read_u64_at(page, RIGHTMOST_CHILD_OFFSET)?); - Ok(children) -} - -/// One internal node on the cursor's path from the root. -/// -/// Separators are decoded on demand (binary search decodes O(log n) of them), -/// so descending through a node never decodes every key it holds. -struct Frame { - page: Arc>, - key_count: usize, - /// Index into the node's children of the subtree the cursor is in; - /// `key_count` means `rightmost_child`. - child_idx: usize, -} - -impl Frame { - fn new(page: Arc>) -> Result { - let key_count = read_u16_at(&page[..], 2)? as usize; - Ok(Frame { - page, - key_count, - child_idx: 0, - }) - } - - /// Child page id `i` (`i == key_count` is `rightmost_child`). - #[allow(clippy::arithmetic_side_effects)] - fn child(&self, i: usize) -> Result { - if i == self.key_count { - read_u64_at(&self.page[..], RIGHTMOST_CHILD_OFFSET) - } else { - read_u64_at(&self.page[..], INTERNAL_HEADER_SIZE + i * 8) - } - } - - /// Separator `i`: the first key of child `i + 1`'s subtree, as written by - /// [`write_internal_page`]. - #[allow(clippy::arithmetic_side_effects)] - fn sep Deserialize<'de>>(&self, i: usize) -> Result { - let slot_off = INTERNAL_HEADER_SIZE + self.key_count * 8 + i * SLOT_SIZE; - let sep_offset = read_u16_at(&self.page[..], slot_off)? as usize; - let sep_length = read_u16_at(&self.page[..], slot_off + 2)? as usize; - let sep_slice = self - .page - .get(sep_offset..sep_offset.saturating_add(sep_length)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int049, - format!( - "sep slice out of bounds: offset={sep_offset} len={sep_length} page_len={}", - self.page.len() - ) - ) - })?; - Ok(postcard::from_bytes(sep_slice)?) - } - - /// The child to take for `key`, searching children `from..`: the number of - /// separators `<= key`, counted from `from`. - #[allow(clippy::arithmetic_side_effects)] - fn route(&self, key: &K, from: usize) -> Result - where - K: for<'de> Deserialize<'de> + Ord, - { - let (mut lo, mut hi) = (from, self.key_count); - while lo < hi { - let mid = lo + (hi - lo) / 2; - if self.sep::(mid)? <= *key { - lo = mid + 1; - } else { - hi = mid; - } - } - Ok(lo) - } -} - -/// Forward cursor over a B+tree's entries in key order. -/// -/// Holds the path from the root as a stack of internal nodes, so moving to -/// the next leaf or seeking forward re-reads only the pages below the lowest -/// ancestor that still covers the target. Leaf `next_leaf` pointers are never -/// read: a copy-on-write tree (#374) cannot keep them. -pub(crate) struct LeafCursor<'a, K> { - backend: &'a dyn StorageBackend, - cache: &'a PageCache, - stack: Vec, - entries: Vec<(K, FactRef)>, - pos: usize, - /// Set once the cursor has moved past the last leaf. - done: bool, -} - -impl<'a, K> LeafCursor<'a, K> -where - K: for<'de> Deserialize<'de> + Ord + Clone, -{ - /// A cursor positioned before the first entry `>= start` (`None`: the first entry). - pub(crate) fn new( - root: u64, - start: Option<&K>, - backend: &'a dyn StorageBackend, - cache: &'a PageCache, - ) -> Result { - let mut cursor = LeafCursor { - backend, - cache, - stack: Vec::new(), - entries: Vec::new(), - pos: 0, - done: false, - }; - cursor.descend(root, start)?; - Ok(cursor) - } - - /// The next entry in key order, or `None` past the last one. - #[cfg(test)] - pub(crate) fn next(&mut self) -> Result> { - Ok(self.next_ref()?.cloned()) - } - - /// Like [`next`](Self::next), but borrows the entry instead of cloning it. - #[allow(clippy::arithmetic_side_effects)] - pub(crate) fn next_ref(&mut self) -> Result> { - loop { - if self.done { - return Ok(None); - } - if self.pos < self.entries.len() { - self.pos += 1; - return Ok(self.entries.get(self.pos - 1)); - } - if !self.advance_leaf()? { - return Ok(None); - } - } - } - - /// Move forward to the first entry `>= key`. A key at or before the - /// current position leaves the cursor where it is. - // Only tests call this until the streaming engine's joins (#432) do. - #[allow(dead_code)] - #[allow(clippy::arithmetic_side_effects)] - pub(crate) fn seek(&mut self, key: &K) -> Result<()> { - if self.done { - return Ok(()); - } - if let Some((next_key, _)) = self.entries.get(self.pos) - && key <= next_key - { - return Ok(()); - } - // Find the lowest node whose range covers `key`. The child a frame - // is in ends at that frame's separator `child_idx`; a rightmost child - // inherits its parent's bound. `reroute` is the frame to route `key` - // in again; `stack.len()` means the current leaf covers it. - let mut reroute = self.stack.len(); - for (d, f) in self.stack.iter().enumerate().rev() { - if f.child_idx < f.key_count { - if *key < f.sep::(f.child_idx)? { - break; - } - reroute = d; - } - } - if reroute == self.stack.len() { - let p = self.entries.partition_point(|(k, _)| k < key); - self.pos = self.pos.max(p); - return Ok(()); - } - self.stack.truncate(reroute + 1); - let top = self - .stack - .last_mut() - .ok_or_else(|| err_coded!(ErrorCode::Int049, "seek: empty cursor stack".to_string()))?; - let c = top.route(key, top.child_idx)?; - top.child_idx = c; - let child = top.child(c)?; - self.descend(child, Some(key)) - } - - /// Move to the start of the next leaf. Returns false past the last leaf. - #[allow(clippy::arithmetic_side_effects)] - pub(crate) fn advance_leaf(&mut self) -> Result { - loop { - let Some(top) = self.stack.last_mut() else { - self.done = true; - self.entries.clear(); - self.pos = 0; - return Ok(false); - }; - if top.child_idx < top.key_count { - top.child_idx += 1; - let child = top.child(top.child_idx)?; - self.descend(child, None)?; - return Ok(true); - } - self.stack.pop(); - } - } - - /// Descend from `page_id` to the leaf that holds `key`, or to the - /// leftmost leaf when `key` is `None`. - fn descend(&mut self, page_id: u64, key: Option<&K>) -> Result<()> { - let mut page_id = page_id; - loop { - if self.stack.len() >= MAX_TREE_DEPTH { - bail_coded!( - ErrorCode::Int049, - format!( - "tree deeper than {MAX_TREE_DEPTH} at page_id={page_id}: cycle or corruption" - ) - ); - } - let page = self.cache.get_or_load(page_id, self.backend)?; - match page.first().copied() { - Some(PAGE_TYPE_LEAF) => { - self.entries = read_leaf_entries(&page[..])?; - self.pos = key.map_or(0, |k| self.entries.partition_point(|(e, _)| e < k)); - return Ok(()); - } - Some(PAGE_TYPE_INTERNAL) => { - let mut frame = Frame::new(page)?; - frame.child_idx = match key { - Some(k) => frame.route(k, 0)?, - None => 0, - }; - page_id = frame.child(frame.child_idx)?; - self.stack.push(frame); - } - _ => bail_coded!(ErrorCode::Stg013, page_id), - } - } - } -} - -// ─── stream_all_entries ─────────────────────────────────────────────────────── - -/// Stream all `(K, FactRef)` entries from a B+tree in sorted order. -/// -/// Test-only since #315: `save()` snapshots raw leaves via [`collect_leaf_pages`] -/// instead of decoding every entry. -#[cfg(test)] -pub fn stream_all_entries( - root_page_id: u64, - backend: &dyn StorageBackend, - cache: &PageCache, -) -> Result> -where - K: for<'de> Deserialize<'de> + Ord + Clone, -{ - let mut cursor = LeafCursor::new(root_page_id, None, backend, cache)?; - let mut result = Vec::new(); - while let Some(entry) = cursor.next()? { - result.push(entry); - } - Ok(result) -} - -// ─── range_scan ─────────────────────────────────────────────────────────────── - -/// Scan the B+tree for all `FactRef`s whose key is in `[start, end)`. -/// -/// `end: None` means unbounded (scan to the last leaf). -pub fn range_scan( - root_page_id: u64, - start: &K, - end: Option<&K>, - backend: &dyn StorageBackend, - cache: &PageCache, -) -> Result> -where - K: for<'de> Deserialize<'de> + Ord + Clone, -{ - let mut cursor = LeafCursor::new(root_page_id, Some(start), backend, cache)?; - let mut result = Vec::new(); - while let Some((k, fr)) = cursor.next_ref()? { - if end.is_some_and(|e| k >= e) { - break; - } - result.push(*fr); - } - Ok(result) -} - -// ─── MutexStorageBackend ────────────────────────────────────────────────────── - -/// Read-only [`StorageBackend`] adapter that locks `Arc>` only for the -/// duration of a single [`StorageBackend::read_page`] call. -/// -/// Used by [`OnDiskIndexReader::range_scan_*`] and [`crate::storage::persistent_facts`] -/// so that the backend mutex is held only while reading one cold page from disk, -/// rather than for the entire operation. On a cache hit [`PageCache::get_or_load`] -/// never calls `read_page`, so no lock is acquired at all. All methods other than -/// `read_page` are unimplemented and will panic if called. -pub(crate) struct MutexStorageBackend(pub(crate) Arc>); - -impl StorageBackend for MutexStorageBackend { - fn read_page(&self, page_id: u64) -> anyhow::Result> { - self.0 - .lock() - .map_err(|_| err_coded!(ErrorCode::Int050, "MutexStorageBackend"))? - .read_page(page_id) - } - - #[allow(clippy::unimplemented)] - fn write_page(&mut self, _page_id: u64, _data: &[u8]) -> anyhow::Result<()> { - unimplemented!("MutexStorageBackend is read-only; write_page must not be called") - } - - #[allow(clippy::unimplemented)] - fn sync(&mut self) -> anyhow::Result<()> { - unimplemented!("MutexStorageBackend is read-only; sync must not be called") - } - - #[allow(clippy::unimplemented)] - fn page_count(&self) -> anyhow::Result { - unimplemented!("MutexStorageBackend is read-only; page_count must not be called") - } - - #[allow(clippy::unimplemented)] - fn close(&mut self) -> anyhow::Result<()> { - unimplemented!("MutexStorageBackend is read-only; close must not be called") - } - - #[allow(clippy::unimplemented)] - fn backend_name(&self) -> &'static str { - unimplemented!("MutexStorageBackend is read-only; backend_name must not be called") - } -} - -// ─── OnDiskIndexReader ──────────────────────────────────────────────────────── - -/// Implements `CommittedIndexReader` by delegating to `range_scan` on -/// on-disk B+tree pages via the page cache. -// Fields are read by range_scan_* methods of the CommittedIndexReader impl. -#[allow(dead_code)] -pub struct OnDiskIndexReader { - backend: Arc>, - cache: Arc, - pub(crate) eavt_root: u64, - pub(crate) aevt_root: u64, - pub(crate) avet_root: u64, - pub(crate) vaet_root: u64, -} - -impl OnDiskIndexReader { - pub fn new( - backend: Arc>, - cache: Arc, - eavt_root: u64, - aevt_root: u64, - avet_root: u64, - vaet_root: u64, - ) -> Self { - OnDiskIndexReader { - backend, - cache, - eavt_root, - aevt_root, - avet_root, - vaet_root, - } - } -} - -impl crate::storage::CommittedIndexReader for OnDiskIndexReader { - fn range_scan_eavt( - &self, - start: &crate::storage::index::EavtKey, - end: Option<&crate::storage::index::EavtKey>, - ) -> anyhow::Result> { - if self.eavt_root == 0 { - return Ok(vec![]); - } - let adapter = MutexStorageBackend(Arc::clone(&self.backend)); - range_scan(self.eavt_root, start, end, &adapter, &self.cache) - } - - fn range_scan_aevt( - &self, - start: &crate::storage::index::AevtKey, - end: Option<&crate::storage::index::AevtKey>, - ) -> anyhow::Result> { - if self.aevt_root == 0 { - return Ok(vec![]); - } - let adapter = MutexStorageBackend(Arc::clone(&self.backend)); - range_scan(self.aevt_root, start, end, &adapter, &self.cache) - } - - fn range_scan_avet( - &self, - start: &crate::storage::index::AvetKey, - end: Option<&crate::storage::index::AvetKey>, - ) -> anyhow::Result> { - if self.avet_root == 0 { - return Ok(vec![]); - } - let adapter = MutexStorageBackend(Arc::clone(&self.backend)); - range_scan(self.avet_root, start, end, &adapter, &self.cache) - } - - fn range_scan_vaet( - &self, - start: &crate::storage::index::VaetKey, - end: Option<&crate::storage::index::VaetKey>, - ) -> anyhow::Result> { - if self.vaet_root == 0 { - return Ok(vec![]); - } - let adapter = MutexStorageBackend(Arc::clone(&self.backend)); - range_scan(self.vaet_root, start, end, &adapter, &self.cache) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// `build_btree` appending from `start` in generation 1; returns - /// `(root, next_free_page_id)`. - fn build_btree_at( - entries: impl Iterator, Vec)>, - backend: &mut dyn StorageBackend, - cache: &PageCache, - start: u64, - ) -> Result<(u64, u64)> { - let mut alloc = PageAllocator::new(Vec::new(), start, 1); - let root = build_btree(entries, backend, cache, &mut alloc)?; - Ok((root, alloc.next_append())) - } - - /// `rebuild_btree_incremental` appending from `start` in generation 1; - /// returns `(root, next_free_page_id)`. - fn rebuild_btree_incremental_at( - old_leaves: Vec>>, - pending: Vec<(K, FactRef)>, - backend: &mut dyn StorageBackend, - cache: &PageCache, - start: u64, - ) -> Result<(u64, u64)> - where - K: Serialize + for<'de> Deserialize<'de> + Ord, - { - let mut alloc = PageAllocator::new(Vec::new(), start, 1); - let root = rebuild_btree_incremental(old_leaves, pending, backend, cache, &mut alloc)?; - Ok((root, alloc.next_append())) - } - use crate::storage::backend::MemoryBackend; - use crate::storage::index::{EavtKey, FactRef}; - use uuid::Uuid; - - fn make_eavt(n: u128, attr: &str, tx: u64) -> (EavtKey, FactRef) { - ( - EavtKey { - entity: Uuid::from_u128(n), - attribute: attr.to_string(), - valid_from: 0, - valid_to: i64::MAX, - tx_count: tx, - value_bytes: Vec::new(), - asserted: true, - }, - FactRef { - page_id: tx + 1, - slot_index: 0, - }, - ) - } - - /// Deterministic xorshift64 so randomized tests are reproducible without deps. - struct Rng(u64); - impl Rng { - fn next(&mut self) -> u64 { - let mut x = self.0; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - self.0 = x; - x - } - } - - /// `n` EAVT entries with random entities; `counter` keeps keys unique. - fn random_eavt(rng: &mut Rng, n: usize, counter: &mut u64) -> Vec<(EavtKey, FactRef)> { - let mut v: Vec<(EavtKey, FactRef)> = (0..n) - .map(|_| { - *counter += 1; - let entity = (u128::from(rng.next()) << 64) | u128::from(*counter); - make_eavt(entity, ":attr", *counter) - }) - .collect(); - v.sort(); - v - } - - fn sorted_union(a: &[(EavtKey, FactRef)], b: &[(EavtKey, FactRef)]) -> Vec<(EavtKey, FactRef)> { - let mut v: Vec<_> = a.iter().chain(b.iter()).cloned().collect(); - v.sort(); - v - } - - /// Build `committed` at page 1, then rebuild with `pending` starting at `start`. - fn build_then_incremental( - committed: &[(EavtKey, FactRef)], - pending: &[(EavtKey, FactRef)], - start: u64, - ) -> (MemoryBackend, PageCache, u64, u64) { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let ser = btree_entries(committed.iter().cloned()).unwrap(); - let (old_root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let leaves = collect_leaf_pages(old_root, &backend, &cache, None).unwrap(); - let (root, next_free) = - rebuild_btree_incremental_at(leaves, pending.to_vec(), &mut backend, &cache, start) - .unwrap(); - (backend, cache, root, next_free) - } - - /// Stream equality, a next_leaf chain of non-empty leaves, and range_scan - /// agreement on random bounds. - fn assert_tree_exact( - root: u64, - backend: &MemoryBackend, - cache: &PageCache, - expected: &[(EavtKey, FactRef)], - rng: &mut Rng, - ) { - let got: Vec<(EavtKey, FactRef)> = stream_all_entries(root, backend, cache).unwrap(); - assert_eq!(got.len(), expected.len(), "entry count differs"); - assert!(got == expected, "streamed entries differ from expected"); - - let leaves = collect_leaf_pages(root, backend, cache, None).unwrap(); - if expected.is_empty() { - assert_eq!(leaves.len(), 1, "empty tree is one empty leaf"); - } else { - for page in &leaves { - assert!(read_u16_at(&page[..], 2).unwrap() > 0, "empty leaf in tree"); - } - } - - for _ in 0..20 { - if expected.is_empty() { - break; - } - let a = (rng.next() as usize) % expected.len(); - let b = (rng.next() as usize) % expected.len(); - let (lo, hi) = (a.min(b), a.max(b)); - let start = &expected[lo].0; - let end = &expected[hi].0; - let want: Vec = expected[lo..hi].iter().map(|(_, r)| *r).collect(); - let refs = range_scan(root, start, Some(end), backend, cache).unwrap(); - assert!(refs == want, "range_scan differs from expected slice"); - } - } - - /// Build a multi-leaf tree and return (backend, cache, root, leaf page ids). - fn multi_leaf_tree() -> (MemoryBackend, PageCache, u64, Vec) { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries: Vec<_> = (0u128..500).map(|n| make_eavt(n, ":a", n as u64)).collect(); - let ser = btree_entries(entries.into_iter()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let ids = leaf_ids(root, &backend, &cache); - assert!(ids.len() >= 3, "fixture needs several leaves"); - (backend, cache, root, ids) - } - - fn overwrite_page(backend: &mut MemoryBackend, cache: &PageCache, id: u64, page: Vec) { - backend.write_page(id, &page).unwrap(); - cache.put_dirty(id, page); - } - - #[test] - fn cursor_cycle_in_child_pointers_is_bounded() { - // A child pointer leading back up the tree must be an error, not a hang. - let (mut backend, cache, root, _ids) = multi_leaf_tree(); - let mut page = cache.get_or_load(root, &backend).unwrap().to_vec(); - assert_eq!(page[0], PAGE_TYPE_INTERNAL, "fixture root must be internal"); - // First child id lives at INTERNAL_HEADER_SIZE; point it at the root. - page[INTERNAL_HEADER_SIZE..INTERNAL_HEADER_SIZE + 8].copy_from_slice(&root.to_le_bytes()); - overwrite_page(&mut backend, &cache, root, page); - assert!( - collect_leaf_pages(root, &backend, &cache, None).is_err(), - "cycle must be rejected by collect_leaf_pages" - ); - assert!( - stream_all_entries::(root, &backend, &cache).is_err(), - "cycle must be rejected by the cursor" - ); - } - - #[test] - fn test_collect_leaf_pages_rejects_corrupt_slot_directory() { - // Untouched leaves are copied without decoding, so a damaged slot must be - // caught here rather than sealed under a fresh checksum. - let (mut backend, cache, root, ids) = multi_leaf_tree(); - let mut page = cache.get_or_load(ids[1], &backend).unwrap().to_vec(); - let bad_offset = u16::try_from(PAGE_SIZE - 2).unwrap(); - page[LEAF_HEADER_SIZE..LEAF_HEADER_SIZE + 2].copy_from_slice(&bad_offset.to_le_bytes()); - overwrite_page(&mut backend, &cache, ids[1], page); - assert!( - collect_leaf_pages(root, &backend, &cache, None).is_err(), - "slot pointing past the page end must be rejected" - ); - - let (mut backend, cache, root, ids) = multi_leaf_tree(); - let mut page = cache.get_or_load(ids[2], &backend).unwrap().to_vec(); - page[2..4].copy_from_slice(&u16::MAX.to_le_bytes()); - overwrite_page(&mut backend, &cache, ids[2], page); - assert!( - collect_leaf_pages(root, &backend, &cache, None).is_err(), - "entry count overflowing the page must be rejected" - ); - } - - #[test] - fn test_incremental_no_pending_copies_leaves_verbatim() { - let mut rng = Rng(0x315); - let mut ctr = 0; - let committed = random_eavt(&mut rng, 2000, &mut ctr); - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let ser = btree_entries(committed.iter().cloned()).unwrap(); - let (old_root, old_next) = - build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let old_leaves = collect_leaf_pages(old_root, &backend, &cache, None).unwrap(); - let (root, _) = rebuild_btree_incremental_at( - old_leaves.clone(), - Vec::<(EavtKey, FactRef)>::new(), - &mut backend, - &cache, - old_next, - ) - .unwrap(); - let new_leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); - assert_eq!(new_leaves.len(), old_leaves.len(), "leaf count changed"); - for (o, n) in old_leaves.iter().zip(new_leaves.iter()) { - assert!(o[..4] == n[..4], "leaf type/count changed"); - assert!( - o[LEAF_HEADER_SIZE..] == n[LEAF_HEADER_SIZE..], - "leaf body changed" - ); - } - assert_tree_exact(root, &backend, &cache, &committed, &mut rng); - } - - #[test] - fn test_incremental_matches_merge_random() { - let mut rng = Rng(0xC0FFEE); - let mut ctr = 0; - for _ in 0..40 { - let n_committed = (rng.next() % 3000) as usize; - let committed = random_eavt(&mut rng, n_committed, &mut ctr); - let n_pending = (rng.next() % 200) as usize; - let pending = random_eavt(&mut rng, n_pending, &mut ctr); - let (backend, cache, root, _) = build_then_incremental(&committed, &pending, 5000); - let expected = sorted_union(&committed, &pending); - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - } - } - - #[test] - fn test_incremental_pending_outside_old_range() { - let mut rng = Rng(7); - let committed: Vec<_> = (1000u128..3000) - .map(|n| make_eavt(n, ":a", n as u64)) - .collect(); - let pending = vec![ - make_eavt(1, ":a", 1), - make_eavt(2, ":a", 2), - make_eavt(9_000, ":a", 9_000), - make_eavt(9_001, ":a", 9_001), - ]; - let (backend, cache, root, _) = build_then_incremental(&committed, &pending, 5000); - let expected = sorted_union(&committed, &pending); - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - } - - #[test] - fn test_incremental_many_pending_into_one_leaf_splits() { - let mut rng = Rng(11); - // Committed entities are 0, 10_000, 20_000, …: every pending 0 < e < 10_000 - // routes to leaf 0, which must split into several leaves. - let committed: Vec<_> = (0u128..2000) - .map(|n| make_eavt(n * 10_000, ":a", n as u64 + 1)) - .collect(); - let pending: Vec<_> = (1u128..3000) - .map(|n| make_eavt(n, ":a", 100_000 + n as u64)) - .collect(); - let (backend, cache, root, _) = build_then_incremental(&committed, &pending, 5000); - let expected = sorted_union(&committed, &pending); - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - } - - #[test] - fn test_incremental_empty_old_tree_falls_back_to_bulk_build() { - let mut rng = Rng(13); - let mut ctr = 0; - let pending = random_eavt(&mut rng, 500, &mut ctr); - let (backend, cache, root, _) = build_then_incremental(&[], &pending, 5000); - assert_tree_exact(root, &backend, &cache, &pending, &mut rng); - // Nothing old, nothing new: still a valid single empty leaf. - let (backend, cache, root, next) = build_then_incremental(&[], &[], 5000); - assert_eq!(next, root + 1, "empty result is one page"); - assert_tree_exact(root, &backend, &cache, &[], &mut rng); - } - - #[test] - fn test_incremental_new_tree_overlaps_old_pages() { - // save() writes the new tree over the old tree's pages; old leaves are - // snapshotted first, so the result must still be exact. - let mut rng = Rng(17); - let mut ctr = 0; - let committed = random_eavt(&mut rng, 3000, &mut ctr); - let pending = random_eavt(&mut rng, 100, &mut ctr); - let (backend, cache, root, _) = build_then_incremental(&committed, &pending, 3); - let expected = sorted_union(&committed, &pending); - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - } - - #[test] - fn test_incremental_single_inserts_do_not_fragment_leaves() { - // One random insert per round into a bulk-built (75%-full) tree, like one - // checkpoint per new fact. Splitting "75% + remainder" left a near-empty - // leaf per insert; leaves must stay reasonably full instead. - let mut rng = Rng(23); - let mut ctr = 0; - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let mut expected = random_eavt(&mut rng, 3000, &mut ctr); - let ser = btree_entries(expected.iter().cloned()).unwrap(); - let (mut root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let bulk_leaves = collect_leaf_pages(root, &backend, &cache, None) - .unwrap() - .len(); - for _ in 0..600 { - let pending = random_eavt(&mut rng, 1, &mut ctr); - let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); - root = rebuild_btree_incremental_at(leaves, pending.clone(), &mut backend, &cache, 1) - .unwrap() - .0; - expected = sorted_union(&expected, &pending); - } - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - // 20% more entries; at >= ~37% fill that is at most ~2.5x the bulk leaf count. - let leaves = collect_leaf_pages(root, &backend, &cache, None) - .unwrap() - .len(); - assert!( - leaves <= bulk_leaves * 5 / 2, - "leaf count grew from {bulk_leaves} to {leaves}" - ); - } - - #[test] - fn test_incremental_repeated_rounds() { - // Each round consumes the previous round's incrementally built tree, - // writing the new tree over the old pages like save() does. - let mut rng = Rng(19); - let mut ctr = 0; - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let mut expected = random_eavt(&mut rng, 1500, &mut ctr); - let ser = btree_entries(expected.iter().cloned()).unwrap(); - let (mut root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - for round in 0..30u64 { - let n_pending = 1 + (rng.next() % 150) as usize; - let pending = random_eavt(&mut rng, n_pending, &mut ctr); - let leaves = collect_leaf_pages(root, &backend, &cache, None).unwrap(); - let start = 1 + round % 3; - let (r, _) = - rebuild_btree_incremental_at(leaves, pending.clone(), &mut backend, &cache, start) - .unwrap(); - root = r; - expected = sorted_union(&expected, &pending); - assert_tree_exact(root, &backend, &cache, &expected, &mut rng); - } - } - - #[test] - fn test_read_u16_at_oob_rejected() { - let page = vec![0u8; 4]; - assert!(read_u16_at(&page, 3).is_err()); - assert!(read_u16_at(&page, 4).is_err()); - } - - #[test] - fn test_read_u64_at_oob_rejected() { - let page = vec![0u8; 4]; - assert!(read_u64_at(&page, 0).is_err()); - assert!(read_u64_at(&page, 1).is_err()); - } - - #[test] - fn test_build_btree_empty_returns_single_leaf() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(64); - let entries: Vec<(EavtKey, FactRef)> = vec![]; - let ser = btree_entries(entries.into_iter()).unwrap(); - let (root, next_free) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - assert_eq!(root, 1, "root must be at start_page_id"); - assert_eq!(next_free, 2, "single empty leaf = 1 page"); - // Verify it is a leaf page - let page = cache.get_or_load(1, &backend).unwrap(); - assert_eq!(page[0], PAGE_TYPE_LEAF); - let entry_count = read_u16_at(&page[..], 2).unwrap(); - assert_eq!(entry_count, 0); - } - - #[test] - fn test_build_btree_single_entry() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(64); - let entries = vec![make_eavt(1, ":name", 1)]; - let ser = btree_entries(entries.into_iter()).unwrap(); - let (root, next_free) = build_btree_at(ser.into_iter(), &mut backend, &cache, 5).unwrap(); - assert_eq!(root, 5); - assert_eq!(next_free, 6); - let page = cache.get_or_load(5, &backend).unwrap(); - assert_eq!(page[0], PAGE_TYPE_LEAF); - assert_eq!(read_u16_at(&page[..], 2).unwrap(), 1); - } - - #[test] - fn test_build_btree_chained_next_free() { - // Two sequential build_btree calls: second must start where first ended. - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(128); - let entries1 = btree_entries((0u128..5).map(|n| make_eavt(n, ":a", n as u64 + 1))).unwrap(); - let (_, next1) = build_btree_at(entries1.into_iter(), &mut backend, &cache, 1).unwrap(); - - let entries2 = - btree_entries((5u128..10).map(|n| make_eavt(n, ":b", n as u64 + 1))).unwrap(); - let (root2, next2) = - build_btree_at(entries2.into_iter(), &mut backend, &cache, next1).unwrap(); - - assert!(root2 >= next1, "second tree must not overlap with first"); - assert!(next2 > root2); - } - - #[test] - fn test_build_btree_pages_in_cache_after_build() { - // All written pages must be retrievable from cache without backend read - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let entries = - btree_entries((0u128..100).map(|n| make_eavt(n, ":x", n as u64 + 1))).unwrap(); - let (root, next_free) = - build_btree_at(entries.into_iter(), &mut backend, &cache, 1).unwrap(); - - let empty_backend = MemoryBackend::new(); - for page_id in root..next_free { - let result = cache.get_or_load(page_id, &empty_backend); - assert!(result.is_ok(), "page {} missing from cache", page_id); - } - } - - #[test] - fn test_build_btree_fill_factor_no_overflow() { - // With many entries, leaf pages must not exceed PAGE_SIZE - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let entries = btree_entries( - (0u128..200).map(|n| make_eavt(n, ":verylongattributename", n as u64 + 1)), - ) - .unwrap(); - let (root, next_free) = - build_btree_at(entries.into_iter(), &mut backend, &cache, 1).unwrap(); - - for page_id in root..next_free { - let page = cache.get_or_load(page_id, &backend).unwrap(); - assert_eq!( - page.len(), - PAGE_SIZE, - "every page must be exactly PAGE_SIZE" - ); - } - } - - #[test] - fn test_build_btree_internal_node_created_for_many_entries() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(512); - // ~300 entries should force at least 2 leaf pages and 1 internal node - let entries = (0u128..300).map(|n| make_eavt(n, ":attr", n as u64 + 1)); - let ser = btree_entries(entries).unwrap(); - let (root, next_free) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let root_page = cache.get_or_load(root, &backend).unwrap(); - let pages_written = next_free - 1; - assert!( - pages_written >= 2, - "300 entries must need multiple pages; got {}", - pages_written - ); - // With 300 entries at 75% fill factor (~3072 bytes/leaf), we always get multiple - // leaf pages, so the root MUST be an internal node. - assert_eq!( - root_page[0], PAGE_TYPE_INTERNAL, - "300 entries should produce an internal node root, got page type 0x{:02x}", - root_page[0] - ); - } - - #[test] - fn test_merge_sorted_vecs() { - let a = vec![1u32, 3, 5, 7]; - let b = vec![2u32, 4, 6, 8]; - let merged: Vec = merge_sorted_vecs(a, b).collect(); - assert_eq!(merged, vec![1, 2, 3, 4, 5, 6, 7, 8]); - } - - #[test] - fn test_merge_sorted_vecs_empty_left() { - let merged: Vec = merge_sorted_vecs(vec![], vec![1u32, 2, 3]).collect(); - assert_eq!(merged, vec![1, 2, 3]); - } - - #[test] - fn test_build_btree_spans_leaves_and_seals_every_page() { - // ~100 entries with long keys span several leaves. Every page written is - // sealed for generation 1 at its own id, and below next_free. - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let entries = (0u128..100).map(|n| make_eavt(n, ":verylongattributename", n as u64 + 1)); - let ser = btree_entries(entries).unwrap(); - let (root, next_free) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let leaves = leaf_ids(root, &backend, &cache); - assert!( - leaves.len() >= 2, - "100 long-key entries should span multiple leaves" - ); - let total_entries: u64 = leaves - .iter() - .map(|&pid| { - let p = cache.get_or_load(pid, &backend).unwrap(); - read_u16_at(&p[..], 2).unwrap() as u64 - }) - .sum(); - assert_eq!(total_entries, 100); - for pid in 1..next_free { - let raw = backend.read_page(pid).unwrap(); - crate::storage::page::verify(&raw, pid, 1).expect("sealed page"); - } - } - - #[test] - fn test_merge_sorted_vecs_duplicates() { - let a = vec![1u32, 3, 3, 5]; - let b = vec![2u32, 3, 4]; - let merged: Vec = merge_sorted_vecs(a, b).collect(); - assert_eq!(merged, vec![1, 2, 3, 3, 3, 4, 5]); - } - - #[test] - fn test_stream_all_entries_roundtrip() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let input: Vec<(EavtKey, FactRef)> = (0u128..50) - .map(|n| make_eavt(n, ":name", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let output: Vec<(EavtKey, FactRef)> = stream_all_entries(root, &backend, &cache).unwrap(); - - assert_eq!(output.len(), 50); - for w in output.windows(2) { - assert!(w[0].0 <= w[1].0, "entries must be in sorted order"); - } - for (original, recovered) in input.iter().zip(output.iter()) { - assert_eq!(original.1, recovered.1); - } - } - - #[test] - fn test_stream_all_entries_empty_tree() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(16); - let entries: Vec<(EavtKey, FactRef)> = vec![]; - let ser = btree_entries(entries.into_iter()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - let out: Vec<(EavtKey, FactRef)> = stream_all_entries(root, &backend, &cache).unwrap(); - assert_eq!(out.len(), 0); - } - - #[test] - fn test_range_scan_exact_match() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let input: Vec<(EavtKey, FactRef)> = (0u128..100) - .map(|n| make_eavt(n, ":v", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let target_entity = Uuid::from_u128(42); - let start = EavtKey { - entity: target_entity, - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let next_entity = Uuid::from_u128(43); - let end = EavtKey { - entity: next_entity, - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - - let refs = range_scan(root, &start, Some(&end), &backend, &cache).unwrap(); - assert_eq!(refs.len(), 1, "exactly one entry for entity 42"); - // make_eavt(42, ":v", 43) → FactRef { page_id: 43+1=44, slot_index: 0 } - assert_eq!( - refs[0], - FactRef { - page_id: 44, - slot_index: 0 - } - ); - } - - #[test] - fn test_range_scan_empty_range() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let input: Vec<(EavtKey, FactRef)> = (0u128..50) - .map(|n| make_eavt(n, ":v", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let start = EavtKey { - entity: Uuid::from_u128(999), - attribute: String::new(), - valid_from: 0, - valid_to: 0, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let refs = range_scan::(root, &start, None, &backend, &cache).unwrap(); - assert_eq!(refs.len(), 0); - } - - #[test] - fn test_range_scan_unbounded_end() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(256); - let input: Vec<(EavtKey, FactRef)> = (0u128..10) - .map(|n| make_eavt(n, ":v", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let start = EavtKey { - entity: Uuid::from_u128(5), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let refs = range_scan::(root, &start, None, &backend, &cache).unwrap(); - assert_eq!(refs.len(), 5, "entities 5..9 = 5 entries"); - } - - #[test] - fn test_range_scan_multi_leaf_span() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(512); - let input: Vec<(EavtKey, FactRef)> = (0u128..500) - .map(|n| make_eavt(n, ":a", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let start = EavtKey { - entity: Uuid::from_u128(100), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let end = EavtKey { - entity: Uuid::from_u128(200), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let refs = range_scan(root, &start, Some(&end), &backend, &cache).unwrap(); - // NOTE: The end key has attribute="" which sorts BEFORE ":a". So entity 200's - // actual entry {200, ":a", ...} sorts AFTER the end key and is EXCLUDED. - // Result: entities 100..199 = 100 entries. - assert_eq!( - refs.len(), - 100, - "entities 100..199 (end key excludes entity 200's entry since its attr ':a' > '')" - ); - } - - // ══ #359: STG-0xx regression tests ═══════════════════════════════════ - // - // STG-011 ("internal page has no children") is guarded by - // `debug_assert_eq!(child_ids.len(), sep_bytes.len() + 1)` immediately - // above the `bail_coded!` call site in `write_internal_page`, and no - // combination of arguments satisfies that assertion when `child_ids` is - // empty (`sep_bytes.len() + 1` can never be `0`). So under `cargo - // test`'s debug build, the `debug_assert!` always panics first, - // preempting the bail entirely -- this call site is only live as a - // release-build defensive guard and isn't reachable from a normal test - // build. No dedicated regression test for STG-011 as a result. - - /// A child pointer corrupted to point at a page that is not a B+tree node - /// must surface as the coded STG-013 when a scan descends into it. - #[test] - fn cursor_child_pointing_at_non_btree_page_returns_stg_013() { - let (mut backend, cache, root, _ids) = multi_leaf_tree(); - let mut page = cache.get_or_load(root, &backend).unwrap().to_vec(); - assert_eq!(page[0], PAGE_TYPE_INTERNAL, "fixture root must be internal"); - // A page that is neither leaf nor internal (as a fact page would be). - let bogus_id = root + 1_000; - let mut bogus = vec![0u8; PAGE_SIZE]; - bogus[0] = crate::storage::packed_pages::PAGE_TYPE_PACKED; - overwrite_page(&mut backend, &cache, bogus_id, bogus); - // Route the leftmost subtree to it. - page[INTERNAL_HEADER_SIZE..INTERNAL_HEADER_SIZE + 8] - .copy_from_slice(&bogus_id.to_le_bytes()); - overwrite_page(&mut backend, &cache, root, page); - - let start = make_eavt(0, ":a", 0).0; - let err = range_scan::(root, &start, None, &backend, &cache) - .expect_err("descending into a non-B+tree page must fail"); - let coded: crate::error::MinigrafError = err.into(); - assert_eq!(coded.code(), "STG-013"); - } - - #[test] - fn test_on_disk_index_reader_range_scan_eavt() { - use crate::storage::CommittedIndexReader; - use std::sync::Arc; - - let mut backend = MemoryBackend::new(); - let cache = Arc::new(PageCache::new(256)); - let input: Vec<(EavtKey, FactRef)> = (0u128..20) - .map(|n| make_eavt(n, ":x", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (eavt_root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - let reader = - OnDiskIndexReader::new(Arc::new(Mutex::new(backend)), cache, eavt_root, 0, 0, 0); - - let start = EavtKey { - entity: Uuid::from_u128(5), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let end = EavtKey { - entity: Uuid::from_u128(10), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let refs = reader.range_scan_eavt(&start, Some(&end)).unwrap(); - // Same exclusion logic: entity 10's entry {10, ":x", ...} > end {10, "", ...} - // So entities 5..9 = 5 entries - assert_eq!(refs.len(), 5, "entities 5..9 (end excludes entity 10)"); - } - - #[test] - #[cfg(not(target_os = "wasi"))] - fn test_concurrent_range_scans_correctness() { - use crate::storage::CommittedIndexReader; - use std::sync::{Arc, Barrier}; - use std::thread; - - let mut backend = MemoryBackend::new(); - // build_btree takes &PageCache (not Arc), so construct without Arc first - let cache = PageCache::new(256); - // 50 entries — enough to span multiple leaf pages - let input: Vec<(EavtKey, FactRef)> = (0u128..50) - .map(|n| make_eavt(n, ":x", n as u64 + 1)) - .collect(); - let ser = btree_entries(input.iter().cloned()).unwrap(); - let (eavt_root, _) = build_btree_at(ser.into_iter(), &mut backend, &cache, 1).unwrap(); - - // Wrap in Arc after build_btree is done — OnDiskIndexReader requires Arc - let reader = Arc::new(OnDiskIndexReader::new( - Arc::new(Mutex::new(backend)), - Arc::new(cache), - eavt_root, - 0, - 0, - 0, - )); - - // Scan entities 10..19 (10 entries expected) - let start = EavtKey { - entity: Uuid::from_u128(10), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - let end = EavtKey { - entity: Uuid::from_u128(20), - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - tx_count: 0, - value_bytes: Vec::new(), - asserted: true, - }; - - let barrier = Arc::new(Barrier::new(8)); - let handles: Vec<_> = (0..8) - .map(|_| { - let r = Arc::clone(&reader); - let b = Arc::clone(&barrier); - let s = start.clone(); - let e = end.clone(); - thread::spawn(move || { - b.wait(); // all 8 threads start simultaneously - r.range_scan_eavt(&s, Some(&e)).unwrap() - }) - }) - .collect(); - - let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect(); - let expected_len = results[0].len(); - assert_eq!(expected_len, 10, "expected 10 entries for entities 10..19"); - for (i, res) in results.iter().enumerate() { - assert_eq!( - res.len(), - expected_len, - "thread {} returned {} refs, expected {}", - i, - res.len(), - expected_len - ); - } - } - - // ─── LeafCursor ────────────────────────────────────────────────────────── - - /// `MemoryBackend` that counts `read_page` calls, for seek cost tests. - struct CountingBackend { - inner: MemoryBackend, - reads: std::sync::atomic::AtomicU64, - } - - impl CountingBackend { - fn reads(&self) -> u64 { - self.reads.load(std::sync::atomic::Ordering::Relaxed) - } - fn reset(&self) { - self.reads.store(0, std::sync::atomic::Ordering::Relaxed); - } - } - - impl StorageBackend for CountingBackend { - fn write_page(&mut self, page_id: u64, data: &[u8]) -> Result<()> { - self.inner.write_page(page_id, data) - } - fn read_page(&self, page_id: u64) -> Result> { - self.reads - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.inner.read_page(page_id) - } - fn sync(&mut self) -> Result<()> { - self.inner.sync() - } - fn page_count(&self) -> Result { - self.inner.page_count() - } - fn close(&mut self) -> Result<()> { - self.inner.close() - } - fn backend_name(&self) -> &'static str { - "counting" - } - } - - /// Entities `0, 2, 4, ...` so every odd entity is a key that is not in the tree. - fn even_entries(n: u128) -> Vec<(EavtKey, FactRef)> { - (0..n) - .map(|i| make_eavt(i * 2, ":attr", i as u64)) - .collect() - } - - fn key_for(entity: u128) -> EavtKey { - make_eavt(entity, ":attr", 0).0 - } - - fn build_tree( - backend: &mut B, - cache: &PageCache, - entries: &[(EavtKey, FactRef)], - ) -> u64 { - let ser = btree_entries(entries.iter().cloned()).unwrap(); - build_btree_at(ser.into_iter(), backend, cache, 1) - .unwrap() - .0 - } - - fn drain(cursor: &mut LeafCursor<'_, EavtKey>) -> Vec<(EavtKey, FactRef)> { - let mut v = Vec::new(); - while let Some(e) = cursor.next().unwrap() { - v.push(e); - } - v - } - - /// Leaf page ids in key order, found through the tree (not next_leaf). - fn leaf_ids(root: u64, backend: &dyn StorageBackend, cache: &PageCache) -> Vec { - let mut ids = Vec::new(); - let mut stack = vec![root]; - while let Some(id) = stack.pop() { - let page = cache.get_or_load(id, backend).unwrap(); - if page[0] == PAGE_TYPE_LEAF { - ids.push(id); - } else { - let children = internal_children(&page[..]).unwrap(); - stack.extend(children.into_iter().rev()); - } - } - ids - } - - fn first_key_of_leaf(id: u64, backend: &dyn StorageBackend, cache: &PageCache) -> EavtKey { - let page = cache.get_or_load(id, backend).unwrap(); - read_leaf_entries::(&page[..]).unwrap()[0] - .0 - .clone() - } - - fn last_key_of_leaf(id: u64, backend: &dyn StorageBackend, cache: &PageCache) -> EavtKey { - let page = cache.get_or_load(id, backend).unwrap(); - read_leaf_entries::(&page[..]) - .unwrap() - .last() - .unwrap() - .0 - .clone() - } - - #[test] - fn cursor_full_scan_matches_input() { - for n in [0u128, 1, 10, 500, 5_000] { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(n); - let root = build_tree(&mut backend, &cache, &entries); - if n == 5_000 { - let root_page = cache.get_or_load(root, &backend).unwrap(); - assert_eq!( - root_page[0], PAGE_TYPE_INTERNAL, - "5000 entries need depth >= 2" - ); - } - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - let got = drain(&mut cursor); - assert_eq!(got.len(), entries.len(), "entry count differs"); - assert!(got == entries, "cursor scan differs from input"); - } - } - - #[test] - fn cursor_start_key_positions_at_first_ge() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(2_000); - let root = build_tree(&mut backend, &cache, &entries); - let leaves = leaf_ids(root, &backend, &cache); - assert!(leaves.len() >= 3, "fixture needs several leaves"); - let second_leaf_first = first_key_of_leaf(leaves[1], &backend, &cache); - let pos_of = |k: &EavtKey| entries.partition_point(|(e, _)| e < k); - - let starts = [ - key_for(0), - key_for(1), - key_for(500), - key_for(501), - second_leaf_first, - key_for(3_998), - key_for(3_999), - ]; - for start in &starts { - let mut cursor = LeafCursor::new(root, Some(start), &backend, &cache).unwrap(); - let got = cursor.next().unwrap(); - let want = entries.get(pos_of(start)).cloned(); - assert!(got == want, "first entry after start differs"); - } - } - - #[test] - fn cursor_seek_matches_fresh_descent_random() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(20_000); - let root = build_tree(&mut backend, &cache, &entries); - let mut rng = Rng(0x9E37_79B9_7F4A_7C15); - let mut targets: Vec = (0..2_000) - .map(|_| u128::from(rng.next() % 40_010)) - .collect(); - targets.sort_unstable(); - - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - for t in targets { - let key = key_for(t); - cursor.seek(&key).unwrap(); - let got = cursor.next().unwrap(); - let mut fresh = LeafCursor::new(root, Some(&key), &backend, &cache).unwrap(); - let want = fresh.next().unwrap(); - assert!(got == want, "seek differs from a fresh descent"); - // Put the entry back in play for the next seek (targets may repeat). - cursor = LeafCursor::new(root, Some(&key), &backend, &cache).unwrap(); - } - } - - #[test] - fn cursor_seek_sequence_without_reset_visits_ascending_entries() { - // Monotone seeks on one cursor, interleaved with next(): each result is - // the first entry >= max(target, previous result + 1). - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(20_000); - let root = build_tree(&mut backend, &cache, &entries); - let mut rng = Rng(0xD1B5_4A32_D192_ED03); - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - let mut consumed = 0usize; // entries before this index have been returned - let mut target: u128 = 0; - while consumed < entries.len() { - target += u128::from(rng.next() % 200); - let key = key_for(target); - cursor.seek(&key).unwrap(); - let expect_idx = consumed.max(entries.partition_point(|(e, _)| e < &key)); - let got = cursor.next().unwrap(); - assert!( - got == entries.get(expect_idx).cloned(), - "seek/next result differs" - ); - consumed = expect_idx + 1; - } - assert!( - cursor.next().unwrap().is_none(), - "cursor must end after the last entry" - ); - } - - #[test] - fn cursor_seek_within_leaf_sibling_and_across_subtrees() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(20_000); - let root = build_tree(&mut backend, &cache, &entries); - let leaves = leaf_ids(root, &backend, &cache); - assert!(leaves.len() > 20, "fixture needs many leaves"); - let first = first_key_of_leaf(leaves[0], &backend, &cache); - let last_in_first = last_key_of_leaf(leaves[0], &backend, &cache); - let sibling = first_key_of_leaf(leaves[1], &backend, &cache); - let far = first_key_of_leaf(leaves[leaves.len() - 2], &backend, &cache); - - let mut cursor = LeafCursor::new(root, Some(&first), &backend, &cache).unwrap(); - for target in [&last_in_first, &sibling, &far] { - cursor.seek(target).unwrap(); - let (k, _) = cursor.next().unwrap().unwrap(); - assert!(k == *target, "seek must land on the target key"); - } - } - - #[test] - fn cursor_seek_past_end() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(5_000); - let root = build_tree(&mut backend, &cache, &entries); - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - cursor.seek(&key_for(1_000_000)).unwrap(); - assert!( - cursor.next().unwrap().is_none(), - "nothing after the last entry" - ); - cursor.seek(&key_for(2_000_000)).unwrap(); - assert!(cursor.next().unwrap().is_none(), "still at the end"); - } - - #[test] - fn cursor_seek_into_gap_after_leaf_end() { - // A key between leaf L's last entry and leaf L+1's first entry: the - // cursor parks at the end of L, and next() moves to L+1. - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(5_000); - let root = build_tree(&mut backend, &cache, &entries); - let leaves = leaf_ids(root, &backend, &cache); - let last_in_l = last_key_of_leaf(leaves[2], &backend, &cache); - let first_in_next = first_key_of_leaf(leaves[3], &backend, &cache); - // Entities are even, so last + 1 is absent and lies in the gap. - let gap = key_for(last_in_l.entity.as_u128() + 1); - assert!( - gap > last_in_l && gap < first_in_next, - "gap key must sit between leaves" - ); - - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - cursor.seek(&gap).unwrap(); - let (k, _) = cursor.next().unwrap().unwrap(); - assert!( - k == first_in_next, - "next() after a gap seek is the next leaf's first entry" - ); - } - - #[test] - fn cursor_seek_backwards_is_noop() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(4096); - let entries = even_entries(5_000); - let root = build_tree(&mut backend, &cache, &entries); - let mut cursor = LeafCursor::new(root, None, &backend, &cache).unwrap(); - cursor.seek(&key_for(6_000)).unwrap(); - cursor.seek(&key_for(10)).unwrap(); - let (k, _) = cursor.next().unwrap().unwrap(); - assert!( - k.entity.as_u128() == 6_000, - "backward seek must not move the cursor" - ); - } - - #[test] - fn cursor_seek_to_next_leaf_reads_one_page() { - // Cache disabled: every page access reaches the backend. - let mut backend = CountingBackend { - inner: MemoryBackend::new(), - reads: std::sync::atomic::AtomicU64::new(0), - }; - let cache = PageCache::new(0); - let entries = even_entries(20_000); - let root = build_tree(&mut backend, &cache, &entries); - let leaves = leaf_ids(root, &backend, &cache); - assert!(leaves.len() > 3, "fixture needs several leaves"); - - let first = first_key_of_leaf(leaves[0], &backend, &cache); - let next_leaf_first = first_key_of_leaf(leaves[1], &backend, &cache); - let mut cursor = LeafCursor::new(root, Some(&first), &backend, &cache).unwrap(); - backend.reset(); - cursor.seek(&next_leaf_first).unwrap(); - assert_eq!( - backend.reads(), - 1, - "seek to the sibling leaf reads only that leaf" - ); - let (k, _) = cursor.next().unwrap().unwrap(); - assert!( - k == next_leaf_first, - "seek lands on the sibling's first entry" - ); - } - - /// An entry or separator too large for its page is an error, never a page - /// whose data region silently overlaps its slot directory. - #[test] - fn oversized_entry_or_separator_is_rejected() { - let mut backend = MemoryBackend::new(); - let cache = PageCache::new(0); - let mut alloc = PageAllocator::new(Vec::new(), 2, 1); - let big = vec![7u8; PAGE_SIZE - LEAF_HEADER_SIZE - SLOT_SIZE + 1]; - assert!(encode_leaf_page(&[big]).is_err(), "leaf overflow"); - let fits = vec![7u8; PAGE_SIZE - LEAF_HEADER_SIZE - SLOT_SIZE]; - assert!(encode_leaf_page(&[fits]).is_ok(), "exact fit"); - - let sep = vec![7u8; PAGE_SIZE - INTERNAL_HEADER_SIZE - 8 - SLOT_SIZE + 1]; - let err = write_internal_page(&mut backend, &cache, &mut alloc, &[2, 3], &[sep]); - assert!(err.is_err(), "internal overflow"); - let sep = vec![7u8; PAGE_SIZE - INTERNAL_HEADER_SIZE - 8 - SLOT_SIZE]; - write_internal_page(&mut backend, &cache, &mut alloc, &[2, 3], &[sep]).unwrap(); - } - - /// The largest key a maximum-size fact can produce fits in an internal node. - #[test] - fn max_size_fact_key_fits_an_internal_node() { - use crate::graph::types::{Fact, VALID_TIME_FOREVER, Value}; - // tx_id 0 encodes in one byte: the smallest fact for a given key. - let attr = ":a".to_string(); - let overhead = |n: usize| { - let f = Fact::with_valid_time( - uuid::Uuid::from_u128(u128::MAX), - attr.clone(), - Value::String("x".repeat(n)), - 0, - u64::MAX, - i64::MIN, - VALID_TIME_FOREVER, - ); - postcard::to_allocvec(&f).unwrap().len() - }; - let mut n = crate::storage::packed_pages::MAX_FACT_BYTES; - while overhead(n) > crate::storage::packed_pages::MAX_FACT_BYTES { - n -= 1; - } - let f = Fact::with_valid_time( - uuid::Uuid::from_u128(u128::MAX), - attr, - Value::String("x".repeat(n)), - 0, - u64::MAX, - i64::MIN, - VALID_TIME_FOREVER, - ); - let keys = [ - postcard::to_allocvec(&EavtKey::from_fact(&f)) - .unwrap() - .len(), - postcard::to_allocvec(&crate::storage::index::AevtKey::from_fact(&f)) - .unwrap() - .len(), - postcard::to_allocvec(&crate::storage::index::AvetKey::from_fact(&f)) - .unwrap() - .len(), - ]; - for k in keys { - assert!( - INTERNAL_HEADER_SIZE + 8 + SLOT_SIZE + k <= PAGE_SIZE, - "a max-size key must fit beside one child pointer" - ); - } - } -} diff --git a/src/storage/dict.rs b/src/storage/dict.rs index 4e2b574c1..ab0cda7c4 100644 --- a/src/storage/dict.rs +++ b/src/storage/dict.rs @@ -18,9 +18,7 @@ use crate::graph::types::{Fact, Value}; use crate::storage::StorageBackend; use crate::storage::btree; use crate::storage::cache::PageCache; -use crate::storage::keys::{ - self, DICT_LONG_VALUE, Index, KeyFact, KeyValue, SHORT_STRING_MAX, ValueRef, -}; +use crate::storage::keys::{self, Index, KeyFact, KeyValue, SHORT_STRING_MAX, ValueRef}; use crate::storage::node::Entry; use crate::storage::page::PageAllocator; use crate::storage::value_pages::{ValueWriter, read_value}; @@ -407,8 +405,9 @@ impl Encoder { } /// True if `key` is a DICT long-value entry (its value ref keeps a value page alive). +#[cfg(test)] pub fn is_long_value_entry(key: &[u8]) -> bool { - key.first() == Some(&DICT_LONG_VALUE) + key.first() == Some(&keys::DICT_LONG_VALUE) } #[cfg(test)] diff --git a/src/storage/index.rs b/src/storage/index.rs index efcbadb79..841fbb483 100644 --- a/src/storage/index.rs +++ b/src/storage/index.rs @@ -1,24 +1,11 @@ -//! Index key types, FactRef, and canonical value encoding for the four -//! covering indexes (EAVT, AEVT, AVET, VAET). +//! Keys of the in-memory pending indexes, and the canonical value encoding. //! -//! `FactRef` identifies a fact's location on disk. In Phase 6.1, one fact -//! occupies one page (`slot_index` is always 0). In Phase 6.2, `slot_index` -//! identifies the record slot within a packed page. +//! Facts written since the last checkpoint are indexed in memory by entity +//! (EAVT) and by attribute (AEVT) over their UUIDs and strings, mapping to the +//! fact's position in `FactStorage`'s pending list. Committed facts are read +//! through the on-disk covering indexes instead ([`crate::storage::keys`]). use crate::graph::types::{Attribute, EntityId, Fact, Value}; -use serde::{Deserialize, Serialize}; - -// ─── FactRef ──────────────────────────────────────────────────────────────── - -/// Disk location of a fact. -/// -/// `slot_index` is always `0` in Phase 6.1 (one fact per page). -/// In Phase 6.2 it identifies the record within a packed page. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -pub struct FactRef { - pub page_id: u64, - pub slot_index: u16, -} // ─── Canonical Value Encoding ─────────────────────────────────────────────── @@ -89,7 +76,7 @@ pub fn encode_value(v: &Value) -> Vec { /// `value_bytes` and `asserted` come last so entity/attribute range scans keep /// their order; they keep facts that differ only in value or assert/retract /// from colliding (#371, #287). -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct EavtKey { pub entity: EntityId, pub attribute: Attribute, @@ -105,7 +92,7 @@ pub struct EavtKey { /// `value_bytes` and `asserted` come last so entity/attribute range scans keep /// their order; they keep facts that differ only in value or assert/retract /// from colliding (#371, #287). -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct AevtKey { pub attribute: Attribute, pub entity: EntityId, @@ -116,34 +103,6 @@ pub struct AevtKey { pub asserted: bool, } -/// AVET: sort by (Attribute, ValueBytes, ValidFrom, ValidTo, Entity, TxCount, Asserted) -/// -/// `value_bytes` is the canonical encoding from `encode_value`. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct AvetKey { - pub attribute: Attribute, - pub value_bytes: Vec, - pub valid_from: i64, - pub valid_to: i64, - pub entity: EntityId, - pub tx_count: u64, - pub asserted: bool, -} - -/// VAET: sort by (RefTarget, Attribute, ValidFrom, ValidTo, SourceEntity, TxCount, Asserted) -/// -/// Only facts with `Value::Ref` are indexed here. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct VaetKey { - pub ref_target: EntityId, - pub attribute: Attribute, - pub valid_from: i64, - pub valid_to: i64, - pub source_entity: EntityId, - pub tx_count: u64, - pub asserted: bool, -} - impl EavtKey { pub fn from_fact(f: &Fact) -> Self { EavtKey { @@ -206,49 +165,14 @@ impl AevtKey { } } -impl AvetKey { - pub fn from_fact(f: &Fact) -> Self { - AvetKey { - attribute: f.attribute.clone(), - value_bytes: encode_value(&f.value), - valid_from: f.valid_from, - valid_to: f.valid_to, - entity: f.entity, - tx_count: f.tx_count, - asserted: f.asserted, - } - } -} - -impl VaetKey { - /// `None` unless the fact's value is a `Value::Ref` (only refs are VAET-indexed). - pub fn from_fact(f: &Fact) -> Option { - match &f.value { - Value::Ref(target) => Some(VaetKey { - ref_target: *target, - attribute: f.attribute.clone(), - valid_from: f.valid_from, - valid_to: f.valid_to, - source_entity: f.entity, - tx_count: f.tx_count, - asserted: f.asserted, - }), - _ => None, - } - } -} - // ─── Indexes ───────────────────────────────────────────────────────────────── -/// All four covering indexes held in memory alongside the fact list. -/// -/// Populated on every `transact`, `retract`, and `load_fact`. +/// The pending (uncheckpointed) facts' indexes: each key maps to the fact's +/// position in `FactStorage`'s pending list. #[derive(Default, Clone)] pub struct Indexes { - pub(crate) eavt: std::collections::BTreeMap, - pub(crate) aevt: std::collections::BTreeMap, - pub(crate) avet: std::collections::BTreeMap, - pub(crate) vaet: std::collections::BTreeMap, + pub(crate) eavt: std::collections::BTreeMap, + pub(crate) aevt: std::collections::BTreeMap, } impl Indexes { @@ -256,73 +180,10 @@ impl Indexes { Self::default() } - /// Insert a fact into all applicable indexes. - /// - /// `fact_ref` is the disk location. In Phase 6.1, callers pass - /// `FactRef { page_id: 0, slot_index: 0 }` as a placeholder; real - /// page IDs are assigned by `save()` and updated via `reindex_from_facts`. - pub fn insert(&mut self, fact: &Fact, fact_ref: FactRef) { - self.eavt.insert(EavtKey::from_fact(fact), fact_ref); - self.aevt.insert(AevtKey::from_fact(fact), fact_ref); - self.avet.insert(AvetKey::from_fact(fact), fact_ref); - if let Some(k) = VaetKey::from_fact(fact) { - self.vaet.insert(k, fact_ref); - } - } - - /// Query EAVT index for a specific entity (returns all facts for that entity). - pub fn lookup_eavt_entity(&self, entity: EntityId) -> Vec { - self.eavt - .range(EavtKey::entity_start(entity)..) - .take_while(|(k, _)| k.entity == entity) - .map(|(_, v)| *v) - .collect() - } - - /// Query AEVT index for a specific attribute (returns all facts with that attribute). - pub fn lookup_aevt_attr(&self, attribute: &str) -> Vec { - self.aevt - .range(AevtKey::attribute_start(attribute)..) - .take_while(|(k, _)| k.attribute == attribute) - .map(|(_, v)| *v) - .collect() - } - - /// Query AVET index for attribute + value. - pub fn lookup_avet_attr_value(&self, attribute: &str, value: &Value) -> Vec { - let value_bytes = encode_value(value); - let start = AvetKey { - attribute: attribute.to_string(), - value_bytes: value_bytes.clone(), - valid_from: i64::MIN, - valid_to: i64::MIN, - entity: EntityId::nil(), - tx_count: 0, - asserted: false, - }; - self.avet - .range(start..) - .take_while(|(k, _)| k.attribute == attribute && k.value_bytes == value_bytes) - .map(|(_, v)| *v) - .collect() - } - - /// Query VAET index for ref target (reverse references). - pub fn lookup_vaet_ref(&self, target: EntityId) -> Vec { - let start = VaetKey { - ref_target: target, - attribute: String::new(), - valid_from: i64::MIN, - valid_to: i64::MIN, - source_entity: EntityId::nil(), - tx_count: 0, - asserted: false, - }; - self.vaet - .range(start..) - .take_while(|(k, _)| k.ref_target == target) - .map(|(_, v)| *v) - .collect() + /// Index the pending fact at position `pos`. + pub fn insert(&mut self, fact: &Fact, pos: usize) { + self.eavt.insert(EavtKey::from_fact(fact), pos); + self.aevt.insert(AevtKey::from_fact(fact), pos); } } @@ -346,101 +207,26 @@ mod tests { f } - /// #371: two values of one attribute in one transaction must both be indexed. + /// #371: two values of one attribute in one transaction must both be indexed, + /// and an assertion and a retraction of one value must not collide. #[test] - fn same_tx_multi_value_keeps_both_entries_in_all_indexes() { + fn same_tx_values_and_assert_retract_keep_distinct_entries() { let e = Uuid::from_u128(1); - let t1 = Uuid::from_u128(10); - let t2 = Uuid::from_u128(11); let mut idx = Indexes::new(); idx.insert( - &fact_at(e, ":kind", Value::Ref(t1), true), - FactRef { - page_id: 0, - slot_index: 0, - }, - ); - idx.insert( - &fact_at(e, ":kind", Value::Ref(t2), true), - FactRef { - page_id: 0, - slot_index: 1, - }, - ); - assert_eq!( - idx.lookup_eavt_entity(e).len(), - 2, - "EAVT must keep both values" - ); - assert_eq!( - idx.lookup_aevt_attr(":kind").len(), - 2, - "AEVT must keep both values" - ); - assert_eq!(idx.eavt.len(), 2); - assert_eq!(idx.aevt.len(), 2); - assert_eq!(idx.avet.len(), 2); - assert_eq!(idx.vaet.len(), 2); - } - - /// An assertion and a retraction of the same value at the same tx_count - /// must not collide in any index. - #[test] - fn same_tx_assert_and_retract_keep_both_entries_in_all_indexes() { - let e = Uuid::from_u128(1); - let t = Uuid::from_u128(10); - let mut idx = Indexes::new(); - idx.insert( - &fact_at(e, ":kind", Value::Ref(t), true), - FactRef { - page_id: 0, - slot_index: 0, - }, - ); - idx.insert( - &fact_at(e, ":kind", Value::Ref(t), false), - FactRef { - page_id: 0, - slot_index: 1, - }, - ); - assert_eq!(idx.eavt.len(), 2, "EAVT must keep assert and retract"); - assert_eq!(idx.aevt.len(), 2, "AEVT must keep assert and retract"); - assert_eq!(idx.avet.len(), 2, "AVET must keep assert and retract"); - assert_eq!(idx.vaet.len(), 2, "VAET must keep assert and retract"); - } - - #[test] - fn lookups_do_not_leak_neighbouring_entities_or_attributes() { - let (a, b) = (Uuid::from_u128(1), Uuid::from_u128(2)); - let mut idx = Indexes::new(); - idx.insert( - &fact_at(a, ":x", Value::Integer(1), true), - FactRef { - page_id: 0, - slot_index: 0, - }, + &fact_at(e, ":kind", Value::Ref(Uuid::from_u128(10)), true), + 0, ); idx.insert( - &fact_at(b, ":x", Value::Integer(1), true), - FactRef { - page_id: 0, - slot_index: 1, - }, + &fact_at(e, ":kind", Value::Ref(Uuid::from_u128(11)), true), + 1, ); idx.insert( - &fact_at(a, ":xy", Value::Integer(1), true), - FactRef { - page_id: 0, - slot_index: 2, - }, - ); - assert_eq!(idx.lookup_eavt_entity(a).len(), 2); - assert_eq!(idx.lookup_aevt_attr(":x").len(), 2); - assert_eq!( - idx.lookup_avet_attr_value(":x", &Value::Integer(1)).len(), - 2 + &fact_at(e, ":kind", Value::Ref(Uuid::from_u128(11)), false), + 2, ); + assert_eq!(idx.eavt.len(), 3, "EAVT keeps every value and op"); + assert_eq!(idx.aevt.len(), 3, "AEVT keeps every value and op"); } #[test] @@ -448,198 +234,25 @@ mod tests { let e = Uuid::from_u128(5); let f = fact_at(e, ":a", Value::Null, false); assert!(EavtKey::entity_start(e) <= EavtKey::from_fact(&f)); + assert!(EavtKey::entity_attribute_start(e, ":a") <= EavtKey::from_fact(&f)); assert!(AevtKey::attribute_start(":a") <= AevtKey::from_fact(&f)); assert!(EavtKey::entity_start(Uuid::from_u128(6)) > EavtKey::from_fact(&f)); } #[test] - fn test_fact_ref_fields() { - let r = FactRef { - page_id: 42, - slot_index: 7, - }; - assert_eq!(r.page_id, 42); - assert_eq!(r.slot_index, 7); - } - - #[test] - fn test_encode_value_sort_order_integers() { - let neg = encode_value(&Value::Integer(-1)); - let zero = encode_value(&Value::Integer(0)); - let pos = encode_value(&Value::Integer(1)); - assert!(neg < zero, "neg should sort before zero"); - assert!(zero < pos, "zero should sort before pos"); - } - - #[test] - fn test_encode_value_large_negative_before_large_positive() { - let a = encode_value(&Value::Integer(i64::MIN)); - let b = encode_value(&Value::Integer(i64::MAX)); - assert!(a < b); - } - - #[test] - fn test_encode_value_sort_order_cross_type() { + fn test_encode_value_sort_order() { + let ints = [i64::MIN, -1, 0, 1, i64::MAX].map(|n| encode_value(&Value::Integer(n))); + assert!(ints.windows(2).all(|w| w[0] < w[1]), "integers in order"); + let floats = [f64::NEG_INFINITY, -1.0, 0.0, 1.0, f64::INFINITY] + .map(|f| encode_value(&Value::Float(f))); + assert!(floats.windows(2).all(|w| w[0] < w[1]), "floats in order"); let null = encode_value(&Value::Null); - let bool_val = encode_value(&Value::Boolean(false)); - let int_val = encode_value(&Value::Integer(0)); - assert!(null < bool_val); - assert!(bool_val < int_val); - } - - #[test] - fn test_encode_value_ref_structure() { - let id = Uuid::new_v4(); - let bytes = encode_value(&Value::Ref(id)); - assert_eq!(bytes[0], 0x06); // Ref discriminant - assert_eq!(&bytes[1..17], id.as_bytes()); - } - - #[test] - fn test_eavt_key_ordering_by_entity() { - let e1 = Uuid::from_u128(1); - let e2 = Uuid::from_u128(2); - let k1 = EavtKey { - entity: e1, - attribute: ":age".to_string(), - valid_from: 0, - valid_to: i64::MAX, - tx_count: 1, - value_bytes: Vec::new(), - asserted: true, - }; - let k2 = EavtKey { - entity: e2, - attribute: ":age".to_string(), - valid_from: 0, - valid_to: i64::MAX, - tx_count: 1, - value_bytes: Vec::new(), - asserted: true, - }; - assert!(k1 < k2); - } - - #[test] - fn test_avet_key_orders_by_value_bytes() { - let e = Uuid::new_v4(); - let k1 = AvetKey { - attribute: ":score".to_string(), - value_bytes: encode_value(&Value::Integer(10)), - valid_from: 0, - valid_to: i64::MAX, - entity: e, - tx_count: 1, - asserted: true, - }; - let k2 = AvetKey { - attribute: ":score".to_string(), - value_bytes: encode_value(&Value::Integer(20)), - valid_from: 0, - valid_to: i64::MAX, - entity: e, - tx_count: 2, - asserted: true, - }; - assert!(k1 < k2); - } - - #[test] - fn test_indexes_insert_vaet_only_for_ref() { - let entity = Uuid::new_v4(); - let target = Uuid::new_v4(); - let mut indexes = Indexes::new(); - - // Non-Ref value: should NOT appear in VAET - let non_ref_fact = Fact::with_valid_time( - entity, - ":name".to_string(), - Value::String("Alice".to_string()), - 0, - 1, - 0, - VALID_TIME_FOREVER, - ); - indexes.insert( - &non_ref_fact, - FactRef { - page_id: 1, - slot_index: 0, - }, - ); - assert!( - indexes.vaet.is_empty(), - "VAET must not contain non-Ref fact" - ); - - // Ref value: SHOULD appear in VAET - let ref_fact = Fact::with_valid_time( - entity, - ":friend".to_string(), - Value::Ref(target), - 0, - 2, - 0, - VALID_TIME_FOREVER, - ); - indexes.insert( - &ref_fact, - FactRef { - page_id: 2, - slot_index: 0, - }, - ); - assert_eq!(indexes.vaet.len(), 1); - } - - #[test] - fn test_indexes_insert_populates_all_four() { - let entity = Uuid::new_v4(); - let target = Uuid::new_v4(); - let mut indexes = Indexes::new(); - let ref_fact = Fact::with_valid_time( - entity, - ":friend".to_string(), - Value::Ref(target), - 0, - 1, - 0, - VALID_TIME_FOREVER, - ); - indexes.insert( - &ref_fact, - FactRef { - page_id: 1, - slot_index: 0, - }, + let b = encode_value(&Value::Boolean(false)); + assert!(null < b && b < ints[2], "cross-type order"); + assert_eq!( + encode_value(&Value::Float(f64::NAN)), + encode_value(&Value::Float(-f64::NAN)), + "NaN is canonical" ); - assert_eq!(indexes.eavt.len(), 1); - assert_eq!(indexes.aevt.len(), 1); - assert_eq!(indexes.avet.len(), 1); - assert_eq!(indexes.vaet.len(), 1); - } - - #[test] - fn test_encode_value_sort_order_floats() { - let neg_inf = encode_value(&Value::Float(f64::NEG_INFINITY)); - let neg_one = encode_value(&Value::Float(-1.0)); - let zero = encode_value(&Value::Float(0.0)); - let pos_one = encode_value(&Value::Float(1.0)); - let pos_inf = encode_value(&Value::Float(f64::INFINITY)); - assert!(neg_inf < neg_one, "-inf < -1.0"); - assert!(neg_one < zero, "-1.0 < 0.0"); - assert!(zero < pos_one, "0.0 < 1.0"); - assert!(pos_one < pos_inf, "1.0 < +inf"); - } - - #[test] - fn test_encode_value_nan_is_canonical() { - let nan1 = encode_value(&Value::Float(f64::NAN)); - let nan2 = encode_value(&Value::Float(f64::NAN)); - // All NaN values produce the same bytes - assert_eq!(nan1, nan2); - // NaN sorts above all positive finite values (it uses quiet NaN bit pattern) - // Just verify it doesn't panic and produces a fixed-length result - assert_eq!(nan1.len(), 9); } } diff --git a/src/storage/keys.rs b/src/storage/keys.rs index 0b13f6be2..6edbad4ce 100644 --- a/src/storage/keys.rs +++ b/src/storage/keys.rs @@ -90,10 +90,6 @@ impl<'a> Reader<'a> { self.pos >= self.buf.len() } - pub fn rest(&self) -> &'a [u8] { - self.buf.get(self.pos..).unwrap_or(&[]) - } - fn byte(&mut self) -> Result { let b = self .buf diff --git a/src/storage/meta.rs b/src/storage/meta.rs index c97086857..1da59778c 100644 --- a/src/storage/meta.rs +++ b/src/storage/meta.rs @@ -177,13 +177,14 @@ impl MetaPage { Ok(()) } - /// The four index roots, in EAVT, AEVT, AVET, VAET order. - pub fn index_roots(&self) -> [u64; 4] { + /// All five tree roots: the four indexes, then DICT. + pub fn tree_roots(&self) -> [u64; 5] { [ self.eavt_root, self.aevt_root, self.avet_root, self.vaet_root, + self.dict_root, ] } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 620f1ddde..bd1ee14bd 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -7,7 +7,6 @@ /// Inspired by SQLite's VFS (Virtual File System) architecture. pub mod backend; pub(crate) mod btree; -pub mod btree_v6; pub mod cache; pub(crate) mod dict; pub(crate) mod dir_sync; @@ -19,6 +18,7 @@ pub(crate) mod node; pub mod packed_pages; pub(crate) mod page; pub mod persistent_facts; +pub(crate) mod reader; pub(crate) mod value_pages; use crate::error::{ErrorCode, bail_coded, err_coded}; @@ -335,59 +335,28 @@ impl Default for LegacyHeaderV7 { } } -/// Reads committed (checkpointed) facts from persistent storage. +/// Reads committed (checkpointed) facts through the on-disk covering indexes. /// -/// Implemented by `CommittedFactLoaderImpl` in `persistent_facts.rs` and set on -/// `FactStorage` after load, so index-driven reads resolve `FactRef`s to `Fact` -/// objects via the page cache without keeping the entire fact list in memory. -pub trait CommittedFactReader: Send + Sync { - /// Resolve a single committed fact by its disk reference. - #[allow(dead_code)] - fn resolve( - &self, - fact_ref: crate::storage::index::FactRef, - ) -> Result; - /// Stream all committed facts (for full scans). - fn stream_all(&self) -> Result>; -} - -/// Provides bounded range scans over the four committed (on-disk) covering indexes. -/// -/// Implemented by `OnDiskIndexReader` in `btree_v6.rs`. Set on `FactStorage` -/// after load/migration/checkpoint so query methods can merge committed and -/// pending index entries without loading the full index into RAM. -pub trait CommittedIndexReader: Send + Sync { - /// Returns all committed EAVT entries in `[start, end)`. `end: None` means unbounded upper. - #[allow(dead_code)] - fn range_scan_eavt( - &self, - start: &crate::storage::index::EavtKey, - end: Option<&crate::storage::index::EavtKey>, - ) -> anyhow::Result>; - - /// Returns all committed AEVT entries in `[start, end)`. `end: None` means unbounded upper. - #[allow(dead_code)] - fn range_scan_aevt( +/// Implemented by [`reader::OnDiskReader`] and set on `FactStorage` after every +/// open, migration and checkpoint. These are the only committed read paths the +/// query layer uses; results are in index order (eid, then iid), not insertion +/// order. +pub trait CommittedReader: Send + Sync { + /// Every committed fact (an EAVT scan). + fn all_facts(&self) -> Result>; + /// Every committed fact of `entity`. + fn facts_for_entity( &self, - start: &crate::storage::index::AevtKey, - end: Option<&crate::storage::index::AevtKey>, - ) -> anyhow::Result>; - - /// Returns all committed AVET entries in `[start, end)`. `end: None` means unbounded upper. - #[allow(dead_code)] - fn range_scan_avet( - &self, - start: &crate::storage::index::AvetKey, - end: Option<&crate::storage::index::AvetKey>, - ) -> anyhow::Result>; - - /// Returns all committed VAET entries in `[start, end)`. `end: None` means unbounded upper. - #[allow(dead_code)] - fn range_scan_vaet( + entity: &crate::graph::types::EntityId, + ) -> Result>; + /// Every committed fact of `(entity, attribute)`. + fn facts_for_entity_attribute( &self, - start: &crate::storage::index::VaetKey, - end: Option<&crate::storage::index::VaetKey>, - ) -> anyhow::Result>; + entity: &crate::graph::types::EntityId, + attribute: &str, + ) -> Result>; + /// Every committed fact with `attribute` (an AEVT scan). + fn facts_for_attribute(&self, attribute: &str) -> Result>; } #[cfg(test)] diff --git a/src/storage/packed_pages.rs b/src/storage/packed_pages.rs index e55d2e62b..df3d583a2 100644 --- a/src/storage/packed_pages.rs +++ b/src/storage/packed_pages.rs @@ -1,336 +1,127 @@ -//! Packed fact pages. +//! Format v7 packed fact pages, read only to migrate a v7 file (spec §9). //! -//! Format v8 (interim, page_type = 0x41, until fact pages are replaced by covering -//! indexes) uses the common 24-byte page header ([`crate::storage::page`]), with -//! `record_count` in its `count` field. Format v7 (page_type = 0x02) used a 12-byte -//! header with a `next_page` field that was always 0; it is read only to migrate a -//! v7 file. -//! -//! Layout after the header: +//! Format v8 has no fact pages: the covering indexes hold every fact. A v7 +//! fact page (page_type = 0x02) has a 12-byte header: type, reserved, +//! `record_count u16`, and a `next_page` field that was always 0. After it: //! ```text //! [record directory: record_count × 4 bytes each] -//! per entry: offset u16 LE | length u16 LE -//! (offset measured from page start) -//! -//! [record data: variable-length postcard-serialised Facts] -//! written from end of page backwards +//! per entry: offset u16 LE | length u16 LE (from page start) +//! [record data: postcard-serialised Facts, written from the end backwards] //! ``` -//! -//! Overflow pages (`page_type = 0x03`) are reserved for future use. -//! The `next_page` field is always written as 0 in Phase 6.2. use crate::error::{ErrorCode, bail_coded, err_coded}; use crate::graph::types::Fact; -use crate::storage::index::FactRef; use crate::storage::{PAGE_SIZE, StorageBackend}; use anyhow::Result; -/// Page type byte for format v8 (interim) packed fact pages. -pub const PAGE_TYPE_PACKED: u8 = crate::storage::page::PAGE_TYPE_FACT_INTERIM; -/// Page type byte for format v7 packed fact pages (read only, for migration). +/// Page type byte for format v7 packed fact pages. pub const PAGE_TYPE_PACKED_V7: u8 = 0x02; - -/// Packed page header size in bytes (the common v8 page header). -pub const PACKED_HEADER_SIZE: usize = crate::storage::page::PAGE_HEADER_SIZE; /// Format v7 packed page header size in bytes. const PACKED_HEADER_SIZE_V7: usize = 12; -/// Header size of a packed page of either format, by its type byte. -fn header_size_of(page_type: u8) -> Option { - match page_type { - PAGE_TYPE_PACKED => Some(PACKED_HEADER_SIZE), - PAGE_TYPE_PACKED_V7 => Some(PACKED_HEADER_SIZE_V7), - _ => None, - } +fn u16_at(page: &[u8], at: usize) -> Result { + page.get(at..at.saturating_add(2)) + .and_then(|b| <[u8; 2]>::try_from(b).ok()) + .map(u16::from_le_bytes) + .ok_or_else(|| err_coded!(ErrorCode::Int024, format!("u16 at {at} out of bounds"))) } -/// Maximum serialised size (postcard bytes) for a single fact in a packed page. -/// -/// Every index key derived from a fact is at least one byte smaller than the -/// fact's own encoding (it drops `tx_id`), and one key must fit in a B+tree -/// internal node beside its header, `rightmost_child`, one child pointer and -/// one slot: `PAGE_SIZE (4096) - 24 - 8 - 8 - 4 = 4052`. That is tighter than -/// the packed page's own bound (`4096 - 24 - 4`), so it is the limit. -/// -/// In practice the usable space for a `Value::String` is roughly 3 900–4 000 bytes -/// after accounting for the fixed overhead of the other `Fact` fields (two UUIDs, -/// attribute string, counters, timestamps, boolean flag). -/// -/// File-backed databases reject facts that exceed this limit at insertion time. -/// In-memory databases (`Minigraf::in_memory()`) have no size constraint. -pub const MAX_FACT_BYTES: usize = PAGE_SIZE - PACKED_HEADER_SIZE - 8 - 8 - 4; - -/// Pack a slice of facts into packed pages. -/// -/// Returns `(pages, fact_refs)` where: -/// - `pages[i]` is exactly `PAGE_SIZE` bytes of packed page data -/// - `fact_refs[j]` is the `FactRef { page_id, slot_index }` for `facts[j]` -/// -/// `start_page_id` is assigned to `pages[0]`; subsequent pages get -/// `start_page_id + 1`, `start_page_id + 2`, etc. -pub fn pack_facts(facts: &[Fact], start_page_id: u64) -> Result<(Vec>, Vec)> { - let mut pages: Vec> = Vec::new(); - let mut fact_refs: Vec = Vec::with_capacity(facts.len()); - - let mut current_page: Vec = new_packed_page(); - let mut current_record_count: u16 = 0; - let mut dir_offset: usize = PACKED_HEADER_SIZE; - let mut data_offset: usize = PAGE_SIZE; // data written from end backwards - - for fact in facts { - let serialised = postcard::to_allocvec(fact)?; - let len = serialised.len(); - let dir_entry_size = 4usize; - - // Check if this fact exceeds the maximum slot size. - if len > MAX_FACT_BYTES { - bail_coded!( - ErrorCode::Int023, - format!("{len} bytes exceeds maximum slot size {MAX_FACT_BYTES} bytes") - ); - } - - // Check if this fact fits on the current page. - // Free space = data_offset - dir_offset - dir_entry_size (for the new dir entry). - // saturating_sub is safe: dir_offset + dir_entry_size is bounded by PAGE_SIZE. - let free = data_offset.saturating_sub(dir_offset.saturating_add(dir_entry_size)); - if len > free || current_record_count == u16::MAX { - // Flush current page and start a new one. - write_record_count(&mut current_page, current_record_count); - pages.push(current_page); - current_page = new_packed_page(); - current_record_count = 0; - dir_offset = PACKED_HEADER_SIZE; - data_offset = PAGE_SIZE; - } - - // Write data from end of page backwards. - // len <= MAX_FACT_BYTES <= PAGE_SIZE, and we checked len <= free <= data_offset, - // so this subtraction cannot underflow. - data_offset = data_offset.wrapping_sub(len); - current_page - .get_mut(data_offset..data_offset.saturating_add(len)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!( - "data region {}..{} out of bounds", - data_offset, - data_offset.saturating_add(len) - ) - ) - })? - .copy_from_slice(&serialised); - - // Write directory entry: offset (u16 LE) | length (u16 LE). - // data_offset <= PAGE_SIZE (4096) which fits in u16; len <= MAX_FACT_BYTES < u16::MAX. - let offset_u16 = u16::try_from(data_offset).map_err(|_| { - err_coded!( - ErrorCode::Int024, - format!("data_offset {data_offset} overflows u16") - ) - })?; - let len_u16 = u16::try_from(len).map_err(|_| { - err_coded!( - ErrorCode::Int024, - format!("serialised fact too large: {len} bytes") - ) - })?; - current_page - .get_mut(dir_offset..dir_offset.saturating_add(2)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!( - "dir out of bounds at {}..{}", - dir_offset, - dir_offset.saturating_add(2) - ) - ) - })? - .copy_from_slice(&offset_u16.to_le_bytes()); - current_page - .get_mut(dir_offset.saturating_add(2)..dir_offset.saturating_add(4)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!( - "dir out of bounds at {}..{}", - dir_offset.saturating_add(2), - dir_offset.saturating_add(4) - ) - ) - })? - .copy_from_slice(&len_u16.to_le_bytes()); - dir_offset = dir_offset.saturating_add(4); - - let page_id = start_page_id.saturating_add( - u64::try_from(pages.len()) - .map_err(|_| err_coded!(ErrorCode::Int024, "too many pages: overflows u64"))?, - ); - fact_refs.push(FactRef { - page_id, - slot_index: current_record_count, - }); - current_record_count = current_record_count.saturating_add(1); - } - - // Always flush the last page (even if facts slice is empty). - write_record_count(&mut current_page, current_record_count); - pages.push(current_page); - - Ok((pages, fact_refs)) -} - -/// Read a single fact from a packed page at the given slot index. -pub fn read_slot(page: &[u8], slot: u16) -> Result { +/// Read the fact in `slot` of a v7 packed page. +pub fn read_slot_v7(page: &[u8], slot: u16) -> Result { if page.len() < PAGE_SIZE { bail_coded!( ErrorCode::Int024, format!( - "Page too short: {} bytes (expected {})", - page.len(), - PAGE_SIZE + "Page too short: {} bytes (expected {PAGE_SIZE})", + page.len() ) ); } - let page_type = *page - .first() - .ok_or_else(|| err_coded!(ErrorCode::Int024, "packed page empty"))?; - let Some(header_size) = header_size_of(page_type) else { - bail_coded!(ErrorCode::Stg014, format!("{:02x}", page_type)); - }; - let b2 = *page - .get(2) - .ok_or_else(|| err_coded!(ErrorCode::Int024, "too short for record_count byte 2"))?; - let b3 = *page - .get(3) - .ok_or_else(|| err_coded!(ErrorCode::Int024, "too short for record_count byte 3"))?; - let record_count = u16::from_le_bytes([b2, b3]); + let page_type = page.first().copied().unwrap_or(0); + if page_type != PAGE_TYPE_PACKED_V7 { + bail_coded!(ErrorCode::Stg014, format!("{page_type:02x}")); + } + let record_count = u16_at(page, 2)?; if slot >= record_count { bail_coded!( ErrorCode::Int024, format!("Slot {slot} out of bounds (page has {record_count} records)") ); } - // dir_base = PACKED_HEADER_SIZE + slot * 4; slot < record_count <= u16::MAX, - // so slot as usize * 4 <= (65534 * 4) which fits in usize. - let slot_usize = usize::from(slot); - let dir_base = header_size.saturating_add(slot_usize.saturating_mul(4)); - let db0 = *page - .get(dir_base) - .ok_or_else(|| err_coded!(ErrorCode::Int024, format!("dir entry {slot} out of bounds")))?; - let db1 = *page.get(dir_base.saturating_add(1)).ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!("dir entry {slot} byte 1 out of bounds") - ) - })?; - let db2 = *page.get(dir_base.saturating_add(2)).ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!("dir entry {slot} byte 2 out of bounds") - ) - })?; - let db3 = *page.get(dir_base.saturating_add(3)).ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!("dir entry {slot} byte 3 out of bounds") - ) - })?; - let offset = usize::from(u16::from_le_bytes([db0, db1])); - let length = usize::from(u16::from_le_bytes([db2, db3])); + let dir = PACKED_HEADER_SIZE_V7.saturating_add(usize::from(slot).saturating_mul(4)); + let offset = usize::from(u16_at(page, dir)?); + let length = usize::from(u16_at(page, dir.saturating_add(2))?); if offset.saturating_add(length) > PAGE_SIZE { bail_coded!(ErrorCode::Stg015, slot); } - let fact: Fact = postcard::from_bytes( - page.get(offset..offset.saturating_add(length)) - .ok_or_else(|| { - err_coded!( - ErrorCode::Int024, - format!( - "record {}..{} out of bounds", - offset, - offset.saturating_add(length) - ) - ) - })?, - )?; - Ok(fact) -} - -/// Read every fact on one packed page, in slot order. -pub fn read_page_facts(page: &[u8]) -> Result> { - let b2 = page.get(2).copied().unwrap_or(0); - let b3 = page.get(3).copied().unwrap_or(0); - (0..u16::from_le_bytes([b2, b3])) - .map(|slot| read_slot(page, slot)) - .collect() + let bytes = page + .get(offset..offset.saturating_add(length)) + .ok_or_else(|| err_coded!(ErrorCode::Int024, "record out of bounds"))?; + Ok(postcard::from_bytes(bytes)?) } -/// Read every fact from the `num_pages` format v7 fact pages starting at -/// `first_page_id`, together with the `FactRef` of its on-disk location. -/// -/// Used only to migrate a v7 file; pages that are not v7 fact pages are skipped. -pub fn read_all_with_refs_v7( +/// Read every fact from the `num_pages` v7 fact pages starting at +/// `first_page_id`. Pages that are not v7 fact pages are skipped. +pub fn read_all_v7( backend: &dyn StorageBackend, first_page_id: u64, num_pages: u64, -) -> Result<(Vec, Vec)> { +) -> Result> { let mut facts = Vec::new(); - let mut refs = Vec::new(); for i in 0..num_pages { - let page_id = first_page_id.saturating_add(i); - let page = backend.read_page(page_id)?; - let page_type = page.first().copied().unwrap_or(0); - if page.len() < PAGE_SIZE || page_type != PAGE_TYPE_PACKED_V7 { + let page = backend.read_page(first_page_id.saturating_add(i))?; + if page.len() < PAGE_SIZE || page.first().copied() != Some(PAGE_TYPE_PACKED_V7) { continue; } - let b2 = page.get(2).copied().unwrap_or(0); - let b3 = page.get(3).copied().unwrap_or(0); - let record_count = u16::from_le_bytes([b2, b3]); - for slot in 0..record_count { - facts.push(read_slot(&page, slot)?); - refs.push(FactRef { - page_id, - slot_index: slot, - }); + for slot in 0..u16_at(&page, 2)? { + facts.push(read_slot_v7(&page, slot)?); } } - Ok((facts, refs)) + Ok(facts) } -// ── helpers ────────────────────────────────────────────────────────────────── - -fn new_packed_page() -> Vec { - let mut page = vec![0u8; PAGE_SIZE]; - // Safety: page is PAGE_SIZE bytes; index 0 is always valid. - if let Some(b) = page.get_mut(0) { - *b = PAGE_TYPE_PACKED; - } - // byte 1: reserved = 0x00 (already zero from vec initialisation) - // bytes 2-3: record_count = 0 (written later via write_record_count) - // bytes 4-23: crc, page id, generation (set by `page::seal` when written) - page -} - -fn write_record_count(page: &mut [u8], count: u16) { - if let Some(slot) = page.get_mut(2..4) { - slot.copy_from_slice(&count.to_le_bytes()); - } +/// Pack facts into v7 fact pages, as v2.x wrote them (test fixtures only). +#[cfg(test)] +pub fn pack_facts_v7(facts: &[Fact]) -> Vec> { + let new_page = || { + let mut p = vec![0u8; PAGE_SIZE]; + p[0] = PAGE_TYPE_PACKED_V7; + p + }; + let mut pages = Vec::new(); + let mut page = new_page(); + let (mut count, mut dir, mut data) = (0u16, PACKED_HEADER_SIZE_V7, PAGE_SIZE); + for fact in facts { + let rec = postcard::to_allocvec(fact).unwrap(); + if rec.len() + 4 > data - dir { + page[2..4].copy_from_slice(&count.to_le_bytes()); + pages.push(std::mem::replace(&mut page, new_page())); + (count, dir, data) = (0, PACKED_HEADER_SIZE_V7, PAGE_SIZE); + } + data -= rec.len(); + page[data..data + rec.len()].copy_from_slice(&rec); + page[dir..dir + 2].copy_from_slice(&u16::try_from(data).unwrap().to_le_bytes()); + page[dir + 2..dir + 4].copy_from_slice(&u16::try_from(rec.len()).unwrap().to_le_bytes()); + dir += 4; + count += 1; + } + page[2..4].copy_from_slice(&count.to_le_bytes()); + pages.push(page); + pages } #[cfg(test)] mod tests { use super::*; - use crate::graph::types::{Fact, VALID_TIME_FOREVER, Value}; + use crate::graph::types::{VALID_TIME_FOREVER, Value}; + use crate::storage::backend::MemoryBackend; use uuid::Uuid; fn make_fact(n: u64) -> Fact { Fact::with_valid_time( - Uuid::from_u128(n as u128), + Uuid::from_u128(u128::from(n)), ":attr".to_string(), - Value::Integer(n as i64), + Value::Integer(i64::try_from(n).unwrap()), n, n, 0, @@ -339,197 +130,34 @@ mod tests { } #[test] - fn test_single_fact_roundtrip() { - let facts = vec![make_fact(1)]; - let (pages, refs) = pack_facts(&facts, 1).unwrap(); - assert_eq!(pages.len(), 1); - assert_eq!(refs.len(), 1); - assert_eq!(refs[0].page_id, 1); - assert_eq!(refs[0].slot_index, 0); - let recovered = read_slot(&pages[0], 0).unwrap(); - assert_eq!(recovered.entity, facts[0].entity); - assert_eq!(recovered.tx_count, facts[0].tx_count); - } - - #[test] - fn test_multiple_facts_pack_fewer_pages() { - let facts: Vec = (0..50).map(make_fact).collect(); - let (pages, refs) = pack_facts(&facts, 1).unwrap(); - assert!( - pages.len() < 50, - "packed pages ({}) should be < 50", - pages.len() - ); - assert_eq!(refs.len(), 50); - } - - #[test] - fn test_slot_index_roundtrip() { - let facts: Vec = (0..30).map(make_fact).collect(); - let (pages, refs) = pack_facts(&facts, 1).unwrap(); - for (i, fact) in facts.iter().enumerate() { - let r = &refs[i]; - let page = &pages[(r.page_id - 1) as usize]; // page_id is 1-based, pages vec is 0-based - let recovered = read_slot(page, r.slot_index).unwrap(); - assert_eq!(recovered.entity, fact.entity, "fact {} mismatched", i); + fn v7_pages_read_back_every_fact() { + let facts: Vec = (0..300).map(make_fact).collect(); + let pages = pack_facts_v7(&facts); + assert!(pages.len() > 1, "fixture spans pages"); + let mut backend = MemoryBackend::new(); + for (i, p) in pages.iter().enumerate() { + backend + .write_page(u64::try_from(i).unwrap() + 1, p) + .unwrap(); } - } - - #[test] - fn test_page_type_byte_is_0x41() { - let facts = vec![make_fact(1)]; - let (pages, _) = pack_facts(&facts, 1).unwrap(); - assert_eq!(pages[0][0], PAGE_TYPE_PACKED); - } - - /// Pin the packed-page fields of the common 24-byte header to their byte offsets. - /// - /// Like the v7 header layout test, a roundtrip would not catch accidental - /// field swaps or `to_ne_bytes()` use on big-endian platforms. We assert - /// the raw bytes directly against the spec in the module doc comment. - #[test] - fn test_packed_page_header_byte_layout() { - let facts: Vec = (0..3).map(make_fact).collect(); - let (pages, _) = pack_facts(&facts, 1).unwrap(); - let page = &pages[0]; - - // byte 0: page_type = 0x41 - assert_eq!(page[0], 0x41, "byte 0 must be PAGE_TYPE_PACKED (0x41)"); - - // byte 1: _reserved = 0x00 - assert_eq!(page[1], 0x00, "byte 1 must be reserved zero"); - - // bytes 2..4: record_count (u16 LE) — 3 facts, all fit in one page - let record_count = u16::from_le_bytes([page[2], page[3]]); - assert_eq!(record_count, 3, "record_count at bytes 2-3 must be 3"); - // Also verify raw LE encoding: low byte first - assert_eq!(page[2], 3, "record_count low byte at offset 2"); - assert_eq!(page[3], 0, "record_count high byte at offset 3"); - - // bytes 4..24: crc, page id, generation — zero until sealed on write - assert!(page[4..24].iter().all(|&b| b == 0), "unsealed header tail"); - } - - /// Verify the record directory entry layout: each entry is 4 bytes, - /// (offset: u16 LE, length: u16 LE), starting at byte 24. - #[test] - fn test_packed_page_record_directory_layout() { - let facts = vec![make_fact(1)]; - let (pages, _) = pack_facts(&facts, 1).unwrap(); - let page = &pages[0]; - - // With 1 fact, record_count = 1; directory entry at bytes 24..28. - let record_count = u16::from_le_bytes([page[2], page[3]]); - assert_eq!(record_count, 1); - - // Directory entry 0: offset (u16 LE) at bytes 24-25, length (u16 LE) at 26-27. - let offset = u16::from_le_bytes([page[24], page[25]]) as usize; - let length = u16::from_le_bytes([page[26], page[27]]) as usize; - - // Offset must be within the page and after the header + directory. + let read = read_all_v7(&backend, 1, u64::try_from(pages.len()).unwrap()).unwrap(); + assert_eq!(read.len(), 300); assert!( - offset >= PACKED_HEADER_SIZE + 4, - "data offset must be past header+directory" + read.iter().zip(&facts).all(|(a, b)| a == b), + "facts round trip" ); - assert!(offset < PAGE_SIZE, "data offset must be within page"); - - // Length must be nonzero and the data must fit within the page. - assert!(length > 0, "record length must be nonzero"); - assert!(offset + length <= PAGE_SIZE, "record must fit within page"); - - // Verify the bytes at the directory offset are the LE encoding of those values. - assert_eq!( - &page[24..26], - &(offset as u16).to_le_bytes(), - "directory offset LE" - ); - assert_eq!( - &page[26..28], - &(length as u16).to_le_bytes(), - "directory length LE" - ); - } - - #[test] - fn test_read_page_facts_roundtrip() { - let facts: Vec = (0..60).map(make_fact).collect(); - let (pages, _refs) = pack_facts(&facts, 1).unwrap(); - let recovered: Vec = pages - .iter() - .flat_map(|p| read_page_facts(p).unwrap()) - .collect(); - assert_eq!(recovered.len(), 60); - for (orig, rec) in facts.iter().zip(recovered.iter()) { - assert_eq!(orig.entity, rec.entity); - } - } - - #[test] - fn test_oversized_fact_returns_error() { - // Create a fact with a very large string value (> MAX_FACT_BYTES) - let big_string = "x".repeat(5000); - let fact = Fact::with_valid_time( - Uuid::from_u128(999), - ":big".to_string(), - Value::String(big_string), - 1, - 1, - 0, - VALID_TIME_FOREVER, - ); - let result = pack_facts(&[fact], 1); - assert!(result.is_err(), "oversized fact must return Err, not panic"); } - // ══ #359: STG-0xx regression tests ═══════════════════════════════════ - - /// A page whose type byte is not `PAGE_TYPE_PACKED` must surface as the - /// coded STG-014, not a generic error. #[test] - fn read_slot_wrong_page_type_returns_stg_014() { - let facts = vec![make_fact(1)]; - let (mut pages, _) = pack_facts(&facts, 1).unwrap(); - pages[0][0] = 0x01; // corrupt: not a packed page type - let err = read_slot(&pages[0], 0).expect_err("wrong page type must fail to read"); - let coded: crate::error::MinigrafError = err.into(); - assert_eq!(coded.code(), "STG-014"); - } - - /// A record directory entry whose `offset + length` extends past the - /// page boundary must surface as the coded STG-015, not a generic - /// error. - #[test] - fn read_slot_record_beyond_page_boundary_returns_stg_015() { - let facts = vec![make_fact(1)]; - let (mut pages, _) = pack_facts(&facts, 1).unwrap(); - // Directory entry 0 is at bytes 24..28 (offset u16 LE, length u16 LE). - // Corrupt the length field so offset + length > PAGE_SIZE. - pages[0][26] = 0xFF; - pages[0][27] = 0xFF; - let err = read_slot(&pages[0], 0) - .expect_err("a record extending past the page boundary must fail to read"); - let coded: crate::error::MinigrafError = err.into(); - assert_eq!(coded.code(), "STG-015"); - } - - /// A v7 page (type 0x02, 12-byte header) still reads, for migration. - #[test] - fn v7_page_reads_through_legacy_layout() { - use crate::storage::backend::MemoryBackend; - let facts: Vec = (0..3).map(make_fact).collect(); - let (pages, _) = pack_facts(&facts, 1).unwrap(); - // Re-lay the v8 page as v7: same records, directory moved 12 bytes earlier. - let v8 = &pages[0]; - let mut v7 = vec![0u8; PAGE_SIZE]; - v7[0] = PAGE_TYPE_PACKED_V7; - v7[2..4].copy_from_slice(&v8[2..4]); - v7[12..12 + 3 * 4].copy_from_slice(&v8[24..24 + 3 * 4]); - v7[100..].copy_from_slice(&v8[100..]); - let mut backend = MemoryBackend::new(); - backend.write_page(1, &v7).unwrap(); - let (read, refs) = read_all_with_refs_v7(&backend, 1, 1).unwrap(); - assert_eq!(read.len(), 3); - assert_eq!(refs[2].slot_index, 2); - assert_eq!(read[1].entity, facts[1].entity); + fn wrong_type_is_stg_014_and_overlong_record_is_stg_015() { + let mut pages = pack_facts_v7(&[make_fact(1)]); + let mut p = pages[0].clone(); + p[0] = 0x01; + let err = read_slot_v7(&p, 0).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-014"); + pages[0][14] = 0xFF; + pages[0][15] = 0xFF; + let err = read_slot_v7(&pages[0], 0).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "STG-015"); } } diff --git a/src/storage/page.rs b/src/storage/page.rs index 9c3f12ef2..5e4640bac 100644 --- a/src/storage/page.rs +++ b/src/storage/page.rs @@ -20,12 +20,9 @@ use anyhow::Result; /// Size of the common page header. pub const PAGE_HEADER_SIZE: usize = 24; -/// Packed fact page. Interim: exists only until fact pages are removed by the -/// covering-index change; files carrying it are pre-release and the value is -/// then retired. -pub const PAGE_TYPE_FACT_INTERIM: u8 = 0x41; -/// Long-value page (spec §6.3). Not written yet. -#[allow(dead_code)] +// 0x41 was the interim fact page of pre-release v8 builds. It is retired and +// never reused: such a page fails the known-type check. +/// Long-value page (spec §6.3). pub const PAGE_TYPE_VALUE: u8 = 0x51; /// Value overflow page. Reserved, never written. #[allow(dead_code)] @@ -44,8 +41,7 @@ const GEN_RANGE: std::ops::Range = 16..24; fn is_known_type(t: u8) -> bool { matches!( t, - PAGE_TYPE_FACT_INTERIM - | PAGE_TYPE_VALUE + PAGE_TYPE_VALUE | PAGE_TYPE_VALUE_OVERFLOW | PAGE_TYPE_LEAF | PAGE_TYPE_INTERNAL diff --git a/src/storage/persistent_facts.rs b/src/storage/persistent_facts.rs index d947a42cd..f7ee8650c 100644 --- a/src/storage/persistent_facts.rs +++ b/src/storage/persistent_facts.rs @@ -9,77 +9,16 @@ use crate::error::{ErrorCode, bail_coded, err_coded}; use crate::graph::FactStorage; use crate::graph::types::Fact; -use crate::storage::btree_v6::{ - LeafCursor, MutexStorageBackend, OnDiskIndexReader, btree_entries, build_btree, - collect_leaf_pages, rebuild_btree_incremental, -}; +use crate::storage::btree::{build_btree, collect_leaf_pages, rebuild_btree_incremental}; use crate::storage::cache::PageCache; -use crate::storage::index::{AevtKey, AvetKey, EavtKey, FactRef, VaetKey}; +use crate::storage::dict::{DictReader, Encoded, Encoder}; use crate::storage::meta::{MetaPage, SlotState, slot_page}; -use crate::storage::packed_pages::{PAGE_TYPE_PACKED, pack_facts}; use crate::storage::page::PageAllocator; +use crate::storage::reader::OnDiskReader; use crate::storage::{LegacyHeaderV7, PAGE_SIZE, StorageBackend, freelist, page}; use anyhow::Result; use std::sync::{Arc, Mutex}; -/// CommittedFactReader backed by a PageCache + shared backend. -/// -/// Resolves FactRefs to Fact objects by reading packed pages through the page -/// cache, so indexes can resolve committed facts without keeping the entire -/// fact list in memory. -struct CommittedFactLoaderImpl { - page_cache: Arc, - /// Pre-built adapter reused on every `resolve()` call; the backend mutex is - /// only acquired on cache misses (see `MutexStorageBackend`). - backend_adapter: MutexStorageBackend, - /// EAVT root of the committed meta; `stream_all` finds the fact pages here. - eavt_root: u64, -} - -impl crate::storage::CommittedFactReader - for CommittedFactLoaderImpl -{ - fn resolve( - &self, - fact_ref: crate::storage::index::FactRef, - ) -> anyhow::Result { - let page = self - .page_cache - .get_or_load(fact_ref.page_id, &self.backend_adapter)?; - crate::storage::packed_pages::read_slot(&page, fact_ref.slot_index) - } - - /// Every committed fact, in insertion order. - /// - /// Fact pages are only ever appended, so ascending page id is insertion - /// order. The pages are found through EAVT, which references every fact. - fn stream_all(&self) -> anyhow::Result> { - if self.eavt_root == 0 { - return Ok(Vec::new()); - } - let mut page_ids = std::collections::BTreeSet::new(); - let mut cursor = LeafCursor::::new( - self.eavt_root, - None, - &self.backend_adapter, - &self.page_cache, - )?; - while let Some((_, fr)) = cursor.next_ref()? { - page_ids.insert(fr.page_id); - } - let mut facts = Vec::new(); - for id in page_ids { - let page = self.page_cache.get_or_load(id, &self.backend_adapter)?; - let page_type = page.first().copied().unwrap_or(0); - if page_type != PAGE_TYPE_PACKED { - bail_coded!(ErrorCode::Stg014, format!("{page_type:02x}")); - } - facts.extend(crate::storage::packed_pages::read_page_facts(&page)?); - } - Ok(facts) - } -} - /// Persistent fact storage with page-based persistence. /// /// Committed facts live on disk and are read on demand through the on-disk @@ -221,29 +160,16 @@ impl PersistentFactStorage { Ok(meta) } - /// Make `meta` the active state: cache bound and committed readers. + /// Make `meta` the active state: cache bound and committed reader. fn activate(&mut self, meta: MetaPage) { self.meta = meta; self.page_cache.set_generation_bound(meta.generation); - let loader: Arc = - Arc::new(CommittedFactLoaderImpl { - page_cache: self.page_cache.clone(), - backend_adapter: MutexStorageBackend(self.backend.clone()), - eavt_root: meta.eavt_root, - }); - self.storage.set_committed_reader(loader); - if meta.eavt_root != 0 { - let index_reader: Arc = - Arc::new(OnDiskIndexReader::new( - self.backend.clone(), - self.page_cache.clone(), - meta.eavt_root, - meta.aevt_root, - meta.avet_root, - meta.vaet_root, - )); - self.storage.set_committed_index_reader(index_reader); - } + let reader: Arc = Arc::new(OnDiskReader::new( + self.backend.clone(), + self.page_cache.clone(), + &meta, + )); + self.storage.set_committed_reader(reader); } /// Migrate a format v7 file to v8 (spec §9). @@ -256,8 +182,7 @@ impl PersistentFactStorage { fn migrate_v7(&mut self, header: &LegacyHeaderV7) -> Result { let mut backend = self.lock()?; let num_fact_pages = header.fact_page_count_or_derived(); - let (facts, _) = - crate::storage::packed_pages::read_all_with_refs_v7(&*backend, 1, num_fact_pages)?; + let mut facts = crate::storage::packed_pages::read_all_v7(&*backend, 1, num_fact_pages)?; // Empty transactions allocate a tx_count without producing facts, so the // highest fact tx_count can undercount; never rewind past the header's @@ -265,11 +190,28 @@ impl PersistentFactStorage { let max_fact_tx = facts.iter().map(|f| f.tx_count).max().unwrap_or(0); let max_tx = max_fact_tx.max(header.last_checkpointed_tx_count); + // Ids are assigned in transaction order (spec §9 step 2). + facts.sort_by_key(|f| f.tx_count); let old_page_count = header.page_count.max(2); let mut alloc = PageAllocator::new(Vec::new(), old_page_count, 1); - let refs = append_fact_pages(&facts, &mut alloc, &mut *backend, &self.page_cache)?; - let [eavt_root, aevt_root, avet_root, vaet_root] = - build_all_trees(&facts, &refs, &mut alloc, &mut *backend, &self.page_cache)?; + let encoded = encode( + &facts, + &MetaPage::empty(1), + &mut alloc, + &mut *backend, + &self.page_cache, + )?; + let fact_count = + u64::try_from(encoded.index[0].len()).map_err(|_| err_coded!(ErrorCode::Stg024))?; + let (next_eid, next_iid) = (encoded.next_eid, encoded.next_iid); + let [eavt_root, aevt_root, avet_root, vaet_root, dict_root] = { + let mut roots = [0u64; 5]; + let Encoded { index, dict, .. } = encoded; + for (root, entries) in roots.iter_mut().zip(index.into_iter().chain([dict])) { + *root = build_btree(entries, &mut *backend, &self.page_cache, &mut alloc)?; + } + roots + }; // Free list: old pages 2.. (page 1 is meta slot B) plus the backup page, // which is appended right after the chain's own pages. @@ -294,12 +236,15 @@ impl PersistentFactStorage { let meta = MetaPage { generation: 1, page_count: alloc.next_append(), - fact_count: u64::try_from(facts.len()).map_err(|_| err_coded!(ErrorCode::Stg024))?, + fact_count, last_checkpointed_tx_count: max_tx, eavt_root, aevt_root, avet_root, vaet_root, + dict_root, + next_eid, + next_iid, freelist_head, freelist_count: u64::try_from(free.len()) .map_err(|_| err_coded!(ErrorCode::Int048, "free-list length"))?, @@ -339,12 +284,12 @@ impl PersistentFactStorage { /// Commit pending facts as the next generation (spec §8.3, full-rewrite form). /// - /// Fact pages are appended. The four trees are rebuilt into pages the active - /// meta does not reference (its free list first, then appended). Every page of - /// the old trees and the old free-list chain joins the new free list. After a - /// sync, the new meta goes to the other slot and is synced: the only commit - /// point. A failure at any step leaves the active meta and all it references - /// untouched. + /// New long values are appended to fresh value pages. The five trees are + /// rebuilt into pages the active meta does not reference (its free list + /// first, then appended). Every page of the old trees and the old free-list + /// chain joins the new free list. After a sync, the new meta goes to the + /// other slot and is synced: the only commit point. A failure at any step + /// leaves the active meta and all it references untouched. pub fn save(&mut self) -> Result<()> { if !self.dirty { return Ok(()); @@ -358,8 +303,8 @@ impl PersistentFactStorage { // Snapshot the old trees (their leaves, and every node for freeing) and // the old free list. Nothing here is written. let mut freed: Vec = Vec::new(); - let mut old_leaves = Vec::with_capacity(4); - for root in m.index_roots() { + let mut old_leaves = Vec::with_capacity(5); + for root in m.tree_roots() { old_leaves.push(if root == 0 { Vec::new() } else { @@ -374,39 +319,32 @@ impl PersistentFactStorage { freed.extend(old_chain); let mut alloc = PageAllocator::new(old_free, m.page_count, next_gen); - let refs = append_fact_pages(&pending_facts, &mut alloc, &mut *backend, &self.page_cache)?; - let (pending_eavt, pending_aevt, pending_avet, pending_vaet) = - build_sorted_index_entries(&pending_facts, &refs); - - let mut old_leaves = old_leaves.into_iter(); - let eavt_root = rebuild_btree_incremental( - old_leaves.next().unwrap_or_default(), - pending_eavt, - &mut *backend, - &self.page_cache, - &mut alloc, - )?; - let aevt_root = rebuild_btree_incremental( - old_leaves.next().unwrap_or_default(), - pending_aevt, - &mut *backend, - &self.page_cache, + let encoded = encode( + &pending_facts, + &m, &mut alloc, - )?; - let avet_root = rebuild_btree_incremental( - old_leaves.next().unwrap_or_default(), - pending_avet, &mut *backend, &self.page_cache, - &mut alloc, - )?; - let vaet_root = rebuild_btree_incremental( - old_leaves.next().unwrap_or_default(), - pending_vaet, - &mut *backend, - &self.page_cache, - &mut alloc, )?; + let added = + u64::try_from(encoded.index[0].len()).map_err(|_| err_coded!(ErrorCode::Stg024))?; + let (next_eid, next_iid) = (encoded.next_eid, encoded.next_iid); + let Encoded { index, dict, .. } = encoded; + let mut roots = [0u64; 5]; + for ((root, leaves), entries) in roots + .iter_mut() + .zip(old_leaves) + .zip(index.into_iter().chain([dict])) + { + *root = rebuild_btree_incremental( + leaves, + entries, + &mut *backend, + &self.page_cache, + &mut alloc, + )?; + } + let [eavt_root, aevt_root, avet_root, vaet_root, dict_root] = roots; // New free list: what is left of the old one, plus everything the old // meta referenced that the new one does not. @@ -417,20 +355,21 @@ impl PersistentFactStorage { backend.sync()?; - let pending_len = - u64::try_from(pending_facts.len()).map_err(|_| err_coded!(ErrorCode::Stg024))?; let new_meta = MetaPage { generation: next_gen, page_count: alloc.next_append(), fact_count: m .fact_count - .checked_add(pending_len) + .checked_add(added) .ok_or_else(|| err_coded!(ErrorCode::Int048, "fact_count overflow"))?, last_checkpointed_tx_count: self.storage.current_tx_count(), eavt_root, aevt_root, avet_root, vaet_root, + dict_root, + next_eid, + next_iid, freelist_head, freelist_count: u64::try_from(new_free.len()) .map_err(|_| err_coded!(ErrorCode::Int048, "free-list length"))?, @@ -595,115 +534,23 @@ fn is_torn_initial_meta(backend: &dyn StorageBackend, page0: &[u8]) -> Result Result> { - if facts.is_empty() { - return Ok(Vec::new()); - } - let start = alloc.next_append(); - let (pages, refs) = pack_facts(facts, start)?; - for (i, page) in pages.into_iter().enumerate() { - let id = alloc.alloc_append()?; - let expected = u64::try_from(i) - .ok() - .and_then(|i| start.checked_add(i)) - .ok_or_else(|| err_coded!(ErrorCode::Stg022))?; - if id != expected { - bail_coded!(ErrorCode::Int049, "fact pages must be contiguous appends"); +) -> Result { + let mut encoder = Encoder::new(m.next_eid, m.next_iid); + { + let mut dict = DictReader::new(m.dict_root, &*backend, cache); + for f in facts { + encoder.stage(f, &mut dict)?; } - alloc.write(backend, cache, id, page)?; } - Ok(refs) -} - -/// Bulk-build all four trees from `facts` and return their roots -/// (EAVT, AEVT, AVET, VAET). -fn build_all_trees( - facts: &[Fact], - refs: &[FactRef], - alloc: &mut PageAllocator, - backend: &mut dyn StorageBackend, - cache: &PageCache, -) -> Result<[u64; 4]> { - let (eavt, aevt, avet, vaet) = build_sorted_index_entries(facts, refs); - Ok([ - build_btree( - btree_entries(eavt.into_iter())?.into_iter(), - backend, - cache, - alloc, - )?, - build_btree( - btree_entries(aevt.into_iter())?.into_iter(), - backend, - cache, - alloc, - )?, - build_btree( - btree_entries(avet.into_iter())?.into_iter(), - backend, - cache, - alloc, - )?, - build_btree( - btree_entries(vaet.into_iter())?.into_iter(), - backend, - cache, - alloc, - )?, - ]) -} - -/// Build sorted index entry vecs for a slice of facts and their corresponding FactRefs. -/// -/// Returns `(eavt_entries, aevt_entries, avet_entries, vaet_entries)`, each sorted by their -/// respective key type. The `vaet` vec only contains entries whose value is a `Value::Ref`. -#[allow(clippy::type_complexity)] -fn build_sorted_index_entries( - facts: &[Fact], - refs: &[FactRef], -) -> ( - Vec<(EavtKey, FactRef)>, - Vec<(AevtKey, FactRef)>, - Vec<(AvetKey, FactRef)>, - Vec<(VaetKey, FactRef)>, -) { - let mut eavt: Vec<(EavtKey, FactRef)> = facts - .iter() - .zip(refs.iter()) - .map(|(f, &fr)| (EavtKey::from_fact(f), fr)) - .collect(); - eavt.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - let mut aevt: Vec<(AevtKey, FactRef)> = facts - .iter() - .zip(refs.iter()) - .map(|(f, &fr)| (AevtKey::from_fact(f), fr)) - .collect(); - aevt.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - let mut avet: Vec<(AvetKey, FactRef)> = facts - .iter() - .zip(refs.iter()) - .map(|(f, &fr)| (AvetKey::from_fact(f), fr)) - .collect(); - avet.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - let mut vaet: Vec<(VaetKey, FactRef)> = facts - .iter() - .zip(refs.iter()) - .filter_map(|(f, &fr)| VaetKey::from_fact(f).map(|k| (k, fr))) - .collect(); - vaet.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); - - (eavt, aevt, avet, vaet) + encoder.finish(alloc, backend, cache) } #[cfg(all(test, not(target_arch = "wasm32")))] @@ -712,7 +559,8 @@ mod tests { use crate::graph::types::Value; use crate::storage::backend::FaultInjectingBackend; use crate::storage::backend::MemoryBackend; - use crate::storage::btree_v6::stream_all_entries; + use crate::storage::keys::Index; + use crate::storage::reader::all_entries_as_facts; use std::collections::BTreeSet; use uuid::Uuid; @@ -724,20 +572,25 @@ mod tests { crate::error::MinigrafError::from(e).code() } - /// Page ids the meta references: tree nodes, fact pages, free-list chain pages. + /// Page ids the meta references: tree nodes, value pages (through DICT's + /// long-value entries), free-list chain pages. fn reachable(meta: &MetaPage, backend: &dyn StorageBackend) -> BTreeSet { let cache = PageCache::new(0); let mut nodes = Vec::new(); - for root in meta.index_roots() { + for root in meta.tree_roots() { if root != 0 { collect_leaf_pages(root, backend, &cache, Some(&mut nodes)).unwrap(); } } let mut set: BTreeSet = nodes.into_iter().collect(); - if meta.eavt_root != 0 { - let entries: Vec<(EavtKey, FactRef)> = - stream_all_entries(meta.eavt_root, backend, &cache).unwrap(); - set.extend(entries.iter().map(|(_, fr)| fr.page_id)); + if meta.dict_root != 0 { + let prefix = [crate::storage::keys::DICT_LONG_VALUE]; + for (k, _) in + crate::storage::btree::prefix_scan(meta.dict_root, &prefix, backend, &cache) + .unwrap() + { + set.insert(crate::storage::keys::dict_long_value_ref(&k).unwrap().page); + } } if meta.freelist_head != 0 { let (_, chain) = @@ -779,39 +632,49 @@ mod tests { } } - /// The four indexes equal a derivation from the facts EAVT references. + /// The four indexes hold the same facts (VAET: the ref facts), each in its + /// own key order, and EAVT holds `fact_count` entries. fn assert_indexes_exact(meta: &MetaPage, backend: &dyn StorageBackend) { let cache = PageCache::new(0); - let eavt: Vec<(EavtKey, FactRef)> = - stream_all_entries(meta.eavt_root, backend, &cache).unwrap(); - let refs: Vec = eavt.iter().map(|(_, fr)| *fr).collect(); - let facts: Vec = refs - .iter() - .map(|fr| { - let p = cache.get_or_load(fr.page_id, backend).unwrap(); - crate::storage::packed_pages::read_slot(&p, fr.slot_index).unwrap() - }) - .collect(); - let (e, a, av, v) = build_sorted_index_entries(&facts, &refs); - fn sorted(mut v: Vec<(K, FactRef)>) -> Vec<(K, FactRef)> { + let facts = |index, root| { + let mut v: Vec<(Uuid, String, Vec, u64, i64, i64, bool)> = + all_entries_as_facts(index, root, meta.dict_root, backend, &cache) + .unwrap() + .into_iter() + .map(|f| { + ( + f.entity, + f.attribute, + crate::storage::index::encode_value(&f.value), + f.tx_count, + f.valid_from, + f.valid_to, + f.asserted, + ) + }) + .collect(); v.sort(); v - } - let got_e: Vec<(EavtKey, FactRef)> = - stream_all_entries(meta.eavt_root, backend, &cache).unwrap(); - let got_a: Vec<(AevtKey, FactRef)> = - stream_all_entries(meta.aevt_root, backend, &cache).unwrap(); - let got_av: Vec<(AvetKey, FactRef)> = - stream_all_entries(meta.avet_root, backend, &cache).unwrap(); - let got_v: Vec<(VaetKey, FactRef)> = - stream_all_entries(meta.vaet_root, backend, &cache).unwrap(); - assert!(got_a.is_sorted_by(|x, y| x.0 <= y.0), "AEVT order"); - assert!(got_av.is_sorted_by(|x, y| x.0 <= y.0), "AVET order"); - assert!(got_v.is_sorted_by(|x, y| x.0 <= y.0), "VAET order"); - assert!(sorted(got_e) == sorted(e), "EAVT differs from derivation"); - assert!(sorted(got_a) == sorted(a), "AEVT differs from derivation"); - assert!(sorted(got_av) == sorted(av), "AVET differs from derivation"); - assert!(sorted(got_v) == sorted(v), "VAET differs from derivation"); + }; + let e = facts(Index::Eavt, meta.eavt_root); + assert_eq!(e.len() as u64, meta.fact_count, "fact_count"); + assert!( + facts(Index::Aevt, meta.aevt_root) == e, + "AEVT differs from EAVT" + ); + assert!( + facts(Index::Avet, meta.avet_root) == e, + "AVET differs from EAVT" + ); + let refs: Vec<_> = e + .iter() + .filter(|f| f.2.first() == Some(&0x06)) + .cloned() + .collect(); + assert!( + facts(Index::Vaet, meta.vaet_root) == refs, + "VAET differs from EAVT refs" + ); } /// One transact of `n` facts: new and reused entities, strings and refs. @@ -1249,16 +1112,9 @@ mod tests { /// A v7 file: header in page 0 and v7 (0x02, 12-byte header) fact pages from 1. fn v7_file(facts: &[Fact], last_tx: u64) -> MemoryBackend { let mut mem = MemoryBackend::new(); - let (pages, _) = pack_facts(facts, 1).unwrap(); - for (i, v8) in pages.iter().enumerate() { - let count = usize::from(u16::from_le_bytes([v8[2], v8[3]])); - let mut v7 = vec![0u8; PAGE_SIZE]; - v7[0] = crate::storage::packed_pages::PAGE_TYPE_PACKED_V7; - v7[2..4].copy_from_slice(&v8[2..4]); - v7[12..12 + 4 * count].copy_from_slice(&v8[24..24 + 4 * count]); - let data_start = 24 + 4 * count; - v7[data_start..].copy_from_slice(&v8[data_start..]); - mem.write_page(i as u64 + 1, &v7).unwrap(); + let pages = crate::storage::packed_pages::pack_facts_v7(facts); + for (i, p) in pages.iter().enumerate() { + mem.write_page(i as u64 + 1, p).unwrap(); } let mut h = LegacyHeaderV7::new(); h.page_count = pages.len() as u64 + 1; diff --git a/src/storage/reader.rs b/src/storage/reader.rs new file mode 100644 index 000000000..f780514f6 --- /dev/null +++ b/src/storage/reader.rs @@ -0,0 +1,125 @@ +//! Covering reads of committed facts (spec §7). +//! +//! Every index entry holds a whole fact, so a read touches only index leaves, +//! the DICT pages that translate its ids, and a value page for a long string. + +use crate::graph::types::{EntityId, Fact}; +use crate::storage::btree::{LeafCursor, MutexStorageBackend, prefix_scan}; +use crate::storage::cache::PageCache; +use crate::storage::dict::DictReader; +use crate::storage::keys::{self, Index, KeyFact}; +use crate::storage::meta::MetaPage; +use crate::storage::{CommittedReader, StorageBackend}; +use anyhow::Result; +use std::sync::{Arc, Mutex}; + +/// [`CommittedReader`] over one committed meta's trees. +pub struct OnDiskReader { + backend: MutexStorageBackend, + cache: Arc, + eavt_root: u64, + aevt_root: u64, + dict_root: u64, +} + +impl OnDiskReader { + pub fn new(backend: Arc>, cache: Arc, meta: &MetaPage) -> Self { + OnDiskReader { + backend: MutexStorageBackend(backend), + cache, + eavt_root: meta.eavt_root, + aevt_root: meta.aevt_root, + dict_root: meta.dict_root, + } + } + + fn dict(&self) -> DictReader<'_> { + DictReader::new(self.dict_root, &self.backend, &self.cache) + } + + /// Facts of every `index` entry starting with `prefix`. + fn scan( + &self, + index: Index, + root: u64, + prefix: &[u8], + dict: &mut DictReader<'_>, + ) -> Result> { + if root == 0 { + return Ok(Vec::new()); + } + prefix_scan(root, prefix, &self.backend, &self.cache)? + .iter() + .map(|(k, _)| dict.fact(&KeyFact::decode(index, k)?)) + .collect() + } +} + +impl CommittedReader for OnDiskReader { + fn all_facts(&self) -> Result> { + if self.eavt_root == 0 { + return Ok(Vec::new()); + } + let mut dict = self.dict(); + let mut cursor = LeafCursor::new(self.eavt_root, None, &self.backend, &self.cache)?; + let mut facts = Vec::new(); + while let Some((k, _)) = cursor.next_ref()? { + facts.push(dict.fact(&KeyFact::decode(Index::Eavt, k)?)?); + } + Ok(facts) + } + + fn facts_for_entity(&self, entity: &EntityId) -> Result> { + let mut dict = self.dict(); + let Some(e) = dict.eid_of(entity)? else { + return Ok(Vec::new()); + }; + self.scan( + Index::Eavt, + self.eavt_root, + &keys::entity_prefix(e), + &mut dict, + ) + } + + fn facts_for_entity_attribute(&self, entity: &EntityId, attribute: &str) -> Result> { + let mut dict = self.dict(); + let (Some(e), Some(a)) = (dict.eid_of(entity)?, dict.iid_of(attribute)?) else { + return Ok(Vec::new()); + }; + let prefix = keys::entity_attribute_prefix(e, a); + self.scan(Index::Eavt, self.eavt_root, &prefix, &mut dict) + } + + fn facts_for_attribute(&self, attribute: &str) -> Result> { + let mut dict = self.dict(); + let Some(a) = dict.iid_of(attribute)? else { + return Ok(Vec::new()); + }; + self.scan( + Index::Aevt, + self.aevt_root, + &keys::attribute_prefix(a), + &mut dict, + ) + } +} + +/// Every fact in `root`'s tree of kind `index`, in key order (tests, verify). +#[cfg(test)] +pub fn all_entries_as_facts( + index: Index, + root: u64, + dict_root: u64, + backend: &dyn StorageBackend, + cache: &PageCache, +) -> Result> { + let mut dict = DictReader::new(dict_root, backend, cache); + if root == 0 { + return Ok(Vec::new()); + } + prefix_scan(root, &[], backend, cache)? + .iter() + .map(|(k, _)| dict.fact(&KeyFact::decode(index, k)?)) + .collect() +} diff --git a/src/wal.rs b/src/wal.rs index a70faa9c2..fa3a634af 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -27,9 +27,9 @@ use crate::db::SyncMode; use crate::error::{ErrorCode, bail_coded, err_coded}; -use crate::graph::types::Fact; +use crate::graph::types::{Fact, Value}; use crate::storage::dir_sync::sync_parent_dir; -use crate::storage::packed_pages::MAX_FACT_BYTES; +use crate::storage::keys::{MAX_IDENT_BYTES, MAX_VALUE_BYTES}; use anyhow::Result; use std::fs::{File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; @@ -105,16 +105,32 @@ fn check_wal_header_length(file: &mut File) -> Result { // ─── Entry serialization ──────────────────────────────────────────────────── +/// Reject a fact a checkpoint could not store (WAL-003): a string value longer +/// than one value page's payload, or an attribute or keyword longer than an +/// ident may be (spec §6.3). +fn check_fact_size(fact: &Fact) -> Result<()> { + if fact.attribute.len() > MAX_IDENT_BYTES { + bail_coded!(ErrorCode::Wal003, fact.attribute.len(), MAX_IDENT_BYTES); + } + match &fact.value { + Value::String(s) if s.len() > MAX_VALUE_BYTES => { + bail_coded!(ErrorCode::Wal003, s.len(), MAX_VALUE_BYTES) + } + Value::Keyword(k) if k.len() > MAX_IDENT_BYTES => { + bail_coded!(ErrorCode::Wal003, k.len(), MAX_IDENT_BYTES) + } + _ => Ok(()), + } +} + fn serialize_entry(tx_count: u64, facts: &[Fact]) -> Result> { // Build payload (everything covered by the checksum) let mut payload: Vec = Vec::new(); payload.extend_from_slice(&tx_count.to_le_bytes()); payload.extend_from_slice(&(facts.len() as u64).to_le_bytes()); for fact in facts { + check_fact_size(fact)?; let fact_bytes = postcard::to_allocvec(fact)?; - if fact_bytes.len() > MAX_FACT_BYTES { - bail_coded!(ErrorCode::Wal003, fact_bytes.len(), MAX_FACT_BYTES); - } let fact_len = u32::try_from(fact_bytes.len()) .map_err(|_| err_coded!(ErrorCode::Wal004, fact_bytes.len()))?; payload.extend_from_slice(&fact_len.to_le_bytes()); @@ -769,7 +785,7 @@ mod tests { let mut writer = WalWriter::open_or_create(&path, SyncMode::Full).unwrap(); let entity = Uuid::new_v4(); - let huge_string = "a".repeat(MAX_FACT_BYTES + 1000); + let huge_string = "a".repeat(MAX_VALUE_BYTES + 1); let fact = Fact::new(entity, ":test".to_string(), Value::String(huge_string), 1); let result = writer.append_entry(1, &[fact]); diff --git a/tests/edge_cases_test.rs b/tests/edge_cases_test.rs index 94180260e..e5fe0aaf8 100644 --- a/tests/edge_cases_test.rs +++ b/tests/edge_cases_test.rs @@ -19,7 +19,7 @@ fn test_oversized_fact_rejected_at_insertion_file_backed() { .open() .unwrap(); - let large_value = "x".repeat(8192); // well above MAX_FACT_BYTES = 4052 + let large_value = "x".repeat(8192); // well above the 4068-byte value limit let cmd = format!("(transact [[:e :attr \"{}\"]])", large_value); let result = db.execute(&cmd); @@ -29,8 +29,8 @@ fn test_oversized_fact_rejected_at_insertion_file_backed() { ); let msg = format!("{}", result.unwrap_err()); assert!( - msg.contains("4052"), - "error message must cite the 4052-byte limit; got: {}", + msg.contains("4068"), + "error message must cite the 4068-byte limit; got: {}", msg ); } @@ -59,8 +59,8 @@ fn test_oversized_fact_rejected_via_write_transaction() { ); let msg = format!("{}", result.unwrap_err()); assert!( - msg.contains("4052"), - "error message must cite the 4052-byte limit; got: {}", + msg.contains("4068"), + "error message must cite the 4068-byte limit; got: {}", msg ); } diff --git a/tests/error_codes_wal_test.rs b/tests/error_codes_wal_test.rs index 71eae1376..648653ed4 100644 --- a/tests/error_codes_wal_test.rs +++ b/tests/error_codes_wal_test.rs @@ -98,9 +98,9 @@ fn wal_replay_bad_version_returns_wal_002() { assert_eq!(err.code(), "WAL-002"); } -/// Size limit: a single fact whose serialised size exceeds the WAL's -/// per-entry limit must be rejected at `transact` time as `WAL-003`, through -/// the public `execute()` API — not just in the internal WAL unit tests. +/// Size limit: a string value longer than 4068 bytes must be rejected at +/// `transact` time as `WAL-003`, through the public `execute()` API, not just +/// in the internal WAL unit tests. #[test] fn wal_size_limit_oversized_fact_returns_wal_003() { let dir = tempfile::tempdir().unwrap(); @@ -108,7 +108,7 @@ fn wal_size_limit_oversized_fact_returns_wal_003() { let db = Minigraf::open(&db_path).unwrap(); - // Comfortably over MAX_FACT_BYTES (4052 bytes) but well under the + // Comfortably over the 4068-byte value limit but well under the // parser's own 1 MB string cap, so the parser accepts it and the WAL // layer is what rejects it. let huge_value = "a".repeat(20_000); diff --git a/tests/multi_value_test.rs b/tests/multi_value_test.rs index 07e0f94ff..366dc27ef 100644 --- a/tests/multi_value_test.rs +++ b/tests/multi_value_test.rs @@ -461,12 +461,10 @@ fn near_max_size_values_checkpoint_and_read_back() { assert_eq!(by_value.len(), 1, "AVET path must find the large value"); } -/// `near_max_size_values_checkpoint_and_read_back` above leaves roughly 230 -/// bytes of headroom below the real per-fact maximum (`MAX_FACT_BYTES` minus -/// the fixed postcard overhead of the rest of the `Fact` struct, which isn't -/// a public constant). This test locates the exact edge at runtime — the -/// largest value length that still transacts — by searching downward from a -/// length guaranteed to fail, and checkpoints/reads back exactly there. +/// The largest string value is 4068 bytes, one value page's payload (spec +/// §6.3), whatever the rest of the fact. This test finds the edge at runtime, +/// by binary search from a length guaranteed to fail, checks it is exactly +/// that limit, and checkpoints and reads back values there. #[test] fn max_size_value_at_exact_boundary_checkpoint_and_read_back() { let dir = tempfile::tempdir().unwrap(); @@ -481,8 +479,7 @@ fn max_size_value_at_exact_boundary_checkpoint_and_read_back() { // with `big_a`. Same attribute name (`:blob`) as the real facts below: // the attribute string is part of the encoded fact size, so a // differently-sized attribute name would find a different boundary. - // `hi` starts comfortably above MAX_FACT_BYTES (4052), so it is - // guaranteed to fail. + // `hi` starts above the 4068-byte limit, so it is guaranteed to fail. let mut lo = 0usize; let mut hi = 4096usize; while lo + 1 < hi { @@ -495,10 +492,7 @@ fn max_size_value_at_exact_boundary_checkpoint_and_read_back() { } } let max_len = lo; - assert!( - max_len > 3_800, - "boundary search must find a length past the near-max test's headroom" - ); + assert_eq!(max_len, 4068, "the value limit is one value page's payload"); let big_a = "a".repeat(max_len); let big_b = "b".repeat(max_len); diff --git a/tests/wal_test.rs b/tests/wal_test.rs index 58d8bd303..79e3f6191 100644 --- a/tests/wal_test.rs +++ b/tests/wal_test.rs @@ -148,6 +148,63 @@ fn test_no_duplicate_facts_after_post_checkpoint_crash() { ); } +/// #447: reopening next to a WAL whose entries are all checkpointed must not +/// rewind the transaction counter. Before the fix, the next transactions +/// reused tx 1 and 2, so `:as-of` saw them as part of history they never were. +#[test] +fn test_reopen_with_checkpointed_wal_does_not_reuse_tx_counts() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("rewind.graph"); + let wal_path = wal_path_for(&db_path); + { + // usize::MAX: no close-time checkpoint, so the restored WAL survives drop. + let db = OpenOptions::default() + .wal_checkpoint_threshold(usize::MAX) + .path(db_path.to_str().unwrap()) + .open() + .unwrap(); + db.execute("(transact [[:alice :age 30]])").unwrap(); // tx 1 + db.execute("(transact [[:alice :age 31]])").unwrap(); // tx 2 + let stale = std::fs::read(&wal_path).unwrap(); + db.checkpoint().unwrap(); + // Crash between the checkpoint's commit and the WAL delete. + std::fs::write(&wal_path, &stale).unwrap(); + } + let ages = |db: &Minigraf, q: &str| -> Vec { + let QueryResult::QueryResults { results, .. } = db.execute(q).unwrap() else { + panic!("expected query results"); + }; + let mut v: Vec = results + .iter() + .flatten() + .map(|v| match v { + minigraf::Value::Integer(n) => *n, + _ => panic!("expected an integer"), + }) + .collect(); + v.sort_unstable(); + v + }; + for reopen in 0..2 { + let db = Minigraf::open(&db_path).unwrap(); + if reopen == 0 { + db.execute("(retract [[:alice :age 30]])").unwrap(); // tx 3 + db.execute("(transact [[:alice :age 99]])").unwrap(); // tx 4 + } + let when = if reopen == 0 { + "before reopen" + } else { + "after reopen" + }; + let q = |n| format!("(query [:find ?a :as-of {n} :where [:alice :age ?a]])"); + assert_eq!(ages(&db, &q(1)), vec![30], "as-of 1 {when}"); + assert_eq!(ages(&db, &q(2)), vec![30, 31], "as-of 2 {when}"); + assert_eq!(ages(&db, &q(3)), vec![31], "as-of 3 {when}"); + let now = ages(&db, "(query [:find ?a :where [:alice :age ?a]])"); + assert_eq!(now, vec![31, 99], "current {when}"); + } +} + // ── 4. Partial WAL entry is discarded; earlier entries intact ───────────────── /// Write 1 fact, crash (no checkpoint), then append garbage bytes to the WAL From bd07dfec08b02597cae0b419d25d55f89c7f21a2 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 08:33:09 +0530 Subject: [PATCH 8/9] test(storage): covering reads, value-page dedup and mixed v7 migration (#433) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- src/storage/persistent_facts.rs | 242 ++++++++++++++++++++++++++++++++ src/wal.rs | 27 ++++ 2 files changed, 269 insertions(+) diff --git a/src/storage/persistent_facts.rs b/src/storage/persistent_facts.rs index f7ee8650c..63ae3d83a 100644 --- a/src/storage/persistent_facts.rs +++ b/src/storage/persistent_facts.rs @@ -1271,4 +1271,246 @@ mod tests { } assert!(points > 50, "expected many crash points"); } + + // ── covering reads (spec §7, §11) ─────────────────────────────────────── + + /// A fact's identity for set comparison (values via their canonical bytes). + type FactKey = (Uuid, String, Vec, u64, u64, i64, i64, bool); + + fn fact_key(f: &Fact) -> FactKey { + ( + f.entity, + f.attribute.clone(), + crate::storage::index::encode_value(&f.value), + f.tx_id, + f.tx_count, + f.valid_from, + f.valid_to, + f.asserted, + ) + } + + fn as_set(facts: Vec) -> Vec { + let mut v: Vec = facts.iter().map(fact_key).collect(); + v.sort(); + v + } + + /// Mixed facts: short and long strings (some repeated), keywords, refs, + /// numbers, multi-valued attributes and retractions. + fn mixed_batch(round: u64, n: u64) -> (Vec<(Uuid, String, Value)>, Vec<(Uuid, String, Value)>) { + let mut asserts = Vec::new(); + let mut retracts = Vec::new(); + for i in 0..n { + let k = round * 1000 + i; + let e = entity(u128::from(k % 37)); + let a = format!(":attr/{}", k % 7); + let v = match k % 8 { + 0 => Value::String(format!("short {k}")), + 1 => Value::String(format!("{}{}", "long value ".repeat(10), k % 5)), + 2 => Value::Keyword(format!(":kw/{}", k % 4)), + 3 => Value::Ref(entity(u128::from(k % 41))), + 4 => Value::Integer(i64::try_from(k).unwrap() - 500), + 5 => Value::Float(k as f64 / 7.0), + 6 => Value::Boolean(k % 2 == 0), + _ => Value::String("x".repeat(65 + (k % 3) as usize)), + }; + if k % 11 == 0 { + retracts.push((e, a, v)); + } else { + asserts.push((e, a, v)); + } + } + (asserts, retracts) + } + + fn assert_reads_match(pfs: &PersistentFactStorage, model: &FactStorage) { + let s = pfs.storage(); + assert!( + as_set(s.get_all_facts().unwrap()) == as_set(model.get_all_facts().unwrap()), + "all facts" + ); + for i in 0..42u128 { + let e = entity(i); + assert!( + as_set(s.get_facts_by_entity(&e).unwrap()) + == as_set(model.get_facts_by_entity(&e).unwrap()), + "by entity" + ); + for a in 0..7 { + let a = format!(":attr/{a}"); + assert!( + as_set(s.get_facts_by_entity_attribute_indexed(&e, &a).unwrap()) + == as_set(model.get_facts_by_entity_attribute_indexed(&e, &a).unwrap()), + "by entity and attribute" + ); + } + } + for a in 0..8 { + let a = format!(":attr/{a}"); + assert!( + as_set(s.get_facts_by_attribute(&a).unwrap()) + == as_set(model.get_facts_by_attribute(&a).unwrap()), + "by attribute" + ); + } + } + + /// Committed reads return exactly what was written, across checkpoints + /// (new ids, reused ids, dedup) and a reopen. + #[test] + fn committed_reads_match_the_model_across_checkpoints() { + let mem = MemoryBackend::new(); + let mut pfs = PersistentFactStorage::new(mem.clone(), 32).unwrap(); + let model = FactStorage::new(); + for round in 0..8u64 { + let (asserts, retracts) = mixed_batch(round, 120); + pfs.storage().transact(asserts, None).unwrap(); + if !retracts.is_empty() { + pfs.storage().retract(retracts).unwrap(); + } + for f in pfs.storage().get_pending_facts() { + model.load_fact(f).unwrap(); + } + pfs.mark_dirty(); + pfs.save().unwrap(); + assert_reads_match(&pfs, &model); + assert_space_accounted(&pfs.meta(), &mem); + assert_indexes_exact(&pfs.meta(), &mem); + } + drop(pfs); + let pfs = PersistentFactStorage::new(mem, 32).unwrap(); + assert_reads_match(&pfs, &model); + } + + /// Records the id of every page read. + #[derive(Clone)] + struct ReadLog { + inner: MemoryBackend, + read: Arc>>, + } + + impl StorageBackend for ReadLog { + fn write_page(&mut self, page_id: u64, data: &[u8]) -> Result<()> { + self.inner.write_page(page_id, data) + } + fn read_page(&self, page_id: u64) -> Result> { + self.read.lock().unwrap().push(page_id); + self.inner.read_page(page_id) + } + fn sync(&mut self) -> Result<()> { + Ok(()) + } + fn page_count(&self) -> Result { + self.inner.page_count() + } + fn close(&mut self) -> Result<()> { + Ok(()) + } + fn backend_name(&self) -> &'static str { + "read-log" + } + } + + /// Reading facts without long values touches only index and DICT pages. + #[test] + fn reads_without_long_values_touch_no_value_page() { + let log = ReadLog { + inner: MemoryBackend::new(), + read: Arc::new(Mutex::new(Vec::new())), + }; + let reads = log.read.clone(); + let mem = log.inner.clone(); + let mut pfs = PersistentFactStorage::new(log, 0).unwrap(); + let long = "L".repeat(500); + pfs.storage() + .transact( + vec![ + (entity(1), ":name".into(), Value::String("Ann".into())), + (entity(1), ":tag".into(), Value::Keyword(":t/x".into())), + (entity(2), ":bio".into(), Value::String(long.clone())), + ], + None, + ) + .unwrap(); + pfs.mark_dirty(); + pfs.save().unwrap(); + let page_type = |id: u64| mem.read_page(id).unwrap()[0]; + + reads.lock().unwrap().clear(); + assert_eq!( + pfs.storage().get_facts_by_entity(&entity(1)).unwrap().len(), + 2 + ); + let touched = std::mem::take(&mut *reads.lock().unwrap()); + assert!(!touched.is_empty()); + assert!( + touched + .iter() + .all(|&id| page_type(id) != page::PAGE_TYPE_VALUE), + "no value page read for short values" + ); + + let bio = pfs.storage().get_facts_by_entity(&entity(2)).unwrap(); + assert!(matches!(&bio[0].value, Value::String(s) if *s == long)); + let touched = std::mem::take(&mut *reads.lock().unwrap()); + assert!( + touched + .iter() + .any(|&id| page_type(id) == page::PAGE_TYPE_VALUE), + "a long value is read from its value page" + ); + } + + fn value_page_count(mem: &MemoryBackend) -> usize { + (2..mem.page_count().unwrap()) + .filter(|&id| mem.read_page(id).unwrap()[0] == page::PAGE_TYPE_VALUE) + .count() + } + + /// Re-asserting or retracting a committed long value writes no value page. + #[test] + fn long_values_are_stored_once_across_checkpoints() { + let mem = MemoryBackend::new(); + let mut pfs = PersistentFactStorage::new(mem.clone(), 16).unwrap(); + let long = Value::String("y".repeat(300)); + pfs.storage() + .transact(vec![(entity(1), ":doc".into(), long.clone())], None) + .unwrap(); + pfs.mark_dirty(); + pfs.save().unwrap(); + assert_eq!(value_page_count(&mem), 1); + pfs.storage() + .retract(vec![(entity(1), ":doc".into(), long.clone())]) + .unwrap(); + pfs.storage() + .transact(vec![(entity(2), ":doc".into(), long)], None) + .unwrap(); + pfs.mark_dirty(); + pfs.save().unwrap(); + assert_eq!(value_page_count(&mem), 1, "dedup: no new value page"); + assert_eq!(pfs.storage().get_all_facts().unwrap().len(), 3); + assert_space_accounted(&pfs.meta(), &mem); + } + + /// A v7 file with every value type, long strings, refs and retractions + /// migrates to exactly the same facts. + #[test] + fn v7_file_with_mixed_values_migrates_exactly() { + let model = FactStorage::new(); + for round in 0..3 { + let (asserts, retracts) = mixed_batch(round, 90); + model.transact(asserts, None).unwrap(); + model.retract(retracts).unwrap(); + } + let facts = model.get_all_facts().unwrap(); + let mem = v7_file(&facts, model.current_tx_count()); + let pfs = open_mem(&mem, None).unwrap(); + assert!( + as_set(pfs.storage().get_all_facts().unwrap()) == as_set(facts), + "migrated facts equal the v7 facts" + ); + assert_indexes_exact(&pfs.meta(), &mem); + assert_space_accounted(&pfs.meta(), &mem); + } } diff --git a/src/wal.rs b/src/wal.rs index fa3a634af..94b0dbbc3 100644 --- a/src/wal.rs +++ b/src/wal.rs @@ -794,6 +794,33 @@ mod tests { assert_eq!(minigraf_err.code(), "WAL-003"); } + /// Attributes and keyword values are capped at 1024 bytes (WAL-003), so a + /// DICT entry always fits in a node; 1024 itself is accepted. + #[test] + fn test_wal_ident_size_limit_is_wal_003() { + use crate::graph::types::{Fact, Value}; + use uuid::Uuid; + + let dir = tempfile::tempdir().unwrap(); + let mut writer = + WalWriter::open_or_create(&dir.path().join("ident.wal"), SyncMode::Full).unwrap(); + let e = Uuid::new_v4(); + let at = |n: usize| format!(":{}", "a".repeat(n - 1)); + let ok = Fact::new( + e, + at(MAX_IDENT_BYTES), + Value::Keyword(at(MAX_IDENT_BYTES)), + 1, + ); + writer.append_entry(1, &[ok]).unwrap(); + let long_attr = Fact::new(e, at(MAX_IDENT_BYTES + 1), Value::Integer(1), 2); + let long_kw = Fact::new(e, ":a".into(), Value::Keyword(at(MAX_IDENT_BYTES + 1)), 2); + for f in [long_attr, long_kw] { + let err = writer.append_entry(2, &[f]).unwrap_err(); + assert_eq!(crate::error::MinigrafError::from(err).code(), "WAL-003"); + } + } + #[test] fn test_wal_delete_directory_error_code_is_wal_006() { let dir = tempfile::tempdir().unwrap(); From 5a9870f13f1aa30efe4f8cfbf3cadc039a6b4c02 Mon Sep 17 00:00:00 2001 From: Aditya Mukhopadhyay Date: Tue, 6 Oct 2026 09:06:56 +0530 Subject: [PATCH 9/9] perf(storage): copy untouched leaves without decoding them twice (#433) collect_leaf_pages validates a leaf's structure without copying keys, and the incremental rebuild reads a copied leaf's last key from its final restart point. A checkpoint after one fact at 100k facts drops from 516 ms to 43 ms (PR 2: 61 ms). Also: pre-covering-key v8 files are refused with STG-032, spec amendments, CHANGELOG, CLAUDE.md, ERROR_REFERENCE and TEST_COVERAGE for PR 3. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015GgLEs8YozJjYhwPEYgTUn --- CHANGELOG.md | 4 +- CLAUDE.md | 35 ++++++---- docs/ERROR_REFERENCE.md | 2 +- docs/TEST_COVERAGE.md | 4 +- .../2026-10-05-v8-storage-format-design.md | 44 ++++++++---- src/storage/btree.rs | 10 ++- src/storage/meta.rs | 10 +++ src/storage/node.rs | 68 ++++++++++++++++++- src/storage/persistent_facts.rs | 18 +++++ 9 files changed, 155 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 803b70140..dfe1b6f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **File format v8** (#287, #371). Index keys now include the encoded value and the assert/retract flag. A v7 file (written by v2.x) is re-indexed and upgraded to v8 the first time it is opened; the upgrade is one-way and v2.x cannot open a v8 file (`STG-006`). Bindings users get this upgrade silently on open. - **Crash-atomic checkpoints with verified pages** (v8 storage format, PR 2 of 5; #374, #388). Two alternating meta pages replace the 84-byte file header and are the only commit point: a checkpoint writes only pages the last commit does not use, syncs, then writes the other meta page and syncs. A crash or torn write at any point leaves the previous checkpoint intact, so opening never rebuilds an index, and the whole-file index checksum is gone. Every other page carries a 24-byte header (type, CRC32, its own page id, the checkpoint generation that wrote it), checked on every read: a torn, corrupted, misplaced or stale page now fails with `STG-029`, `STG-030` or `STG-031` instead of being read. If the newest meta page is damaged after its WAL was already deleted, open fails with `STG-033` rather than silently going back to the previous checkpoint. New codes `STG-029`–`STG-035`. The WAL header gains a base generation (WAL version 2); a v2.x WAL beside a migrated file is still replayed. Pages freed by a checkpoint are reused by later ones; checkpoints still rewrite all four indexes until copy-on-write insert lands (PR 4). Files written by a v3.0.0 development build before this change fail with `STG-032`. -- **The maximum fact size for file-backed databases is 4,052 bytes** (was 4,080), so that every index key derived from a fact fits in an index page. Index pages now reject an entry that does not fit instead of writing an overlapping page. +- **Covering indexes with dictionary-coded keys** (v8 storage format, PR 3 of 5; #433). Every index entry now holds the whole fact as a byte-comparable key, so a query reads only index pages, never a separate fact page. Entities and idents (attribute names and keyword values) get sequential internal ids from a dictionary stored in the file, and strings longer than 64 bytes are stored once in value pages and deduplicated. Fact pages are gone. A file with 100,000 facts (10 attributes per entity) takes 88 bytes per fact, down from 314. Facts from committed data now come back in internal-id order (entity, then attribute) rather than insertion or UUID order; query results are sets, and code that relied on an order must sort. New codes `STG-036` (dictionary entry missing) and `STG-037` (one transaction with two timestamps: a checkpoint or v7 upgrade refuses to change a stored timestamp). Files written by a v3.0.0 development build before this change fail with `STG-032`. +- **Size limits for file-backed databases:** a string value may be up to 4,068 bytes, whatever the rest of the fact, and an attribute name or keyword value up to 1,024 bytes. Both are checked when the transaction is written and fail with `WAL-003`, whose message now names the value. This replaces the 4,080-byte limit on a whole serialised fact (4,052 in earlier v3.0.0 development builds). In-memory databases have no limit. - **Formats v1–v6 are no longer readable.** Opening one fails with the new `STG-028`; open it once with Minigraf v2.x to upgrade it to v7 first. `STG-003` and `STG-004` are deprecated (no longer emitted, still registered, never reused). `STG-005` and `STG-006` message text changed. ### Fixed - Two values of the same attribute written in one transaction are both returned by every query path. Previously only one came back, and entity-bound and attribute-bound queries could return different ones (#371, #287). - A retract that removes several values of one attribute in one call now hides all of them (#371). +- Reopening next to a WAL whose entries were all already checkpointed no longer rewinds the transaction counter, so later transactions no longer reuse `tx_count` values and `:as-of` history stays correct (#447; also fixed in v2.x). ### Internal diff --git a/CLAUDE.md b/CLAUDE.md index aaea41cec..35e8af25c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,15 +104,21 @@ cargo run < demos/demo_negation.txt - `storage.rs`: `FactStorage` — in-memory store, `transact_batch`, `retract`, `get_facts_as_of`, `get_facts_valid_at`, `net_asserted_facts`, `get_facts_by_entity_attribute_indexed` 2. **`src/storage/`** — Persistence layer - - `mod.rs`: `StorageBackend` trait, `FileHeader` v8 (84 bytes), `CommittedFactReader` / `CommittedIndexReader` traits + - `mod.rs`: `StorageBackend` trait, `LegacyHeaderV7` (v7 migration only), `CommittedReader` trait - `backend/file.rs`: Single `.graph` file backend (4KB pages, cross-platform) - `backend/memory.rs`: In-memory backend for testing - `backend/fault_inject.rs`: `FaultInjectingBackend` — injects I/O errors for durability tests (test builds only) - - `index.rs`: EAVT / AEVT / AVET / VAET key types, `FactRef`, `encode_value` - - `btree_v6.rs`: On-disk B+tree (`build_btree`, `rebuild_btree_incremental`, `OnDiskIndexReader`, `MutexStorageBackend`) + - `index.rs`: pending (uncheckpointed) EAVT/AEVT keys over UUIDs and strings, `encode_value` + - `keys.rs`: byte-comparable v8 keys (FDB integers, value tags, `tx↓`, FOREVER), DICT keys, `MAX_VALUE_BYTES`, `MAX_IDENT_BYTES` + - `node.rs`: prefix-compressed leaf and shortest-separator internal node codecs + - `btree.rs`: On-disk B+tree over byte keys (`build_btree`, `rebuild_btree_incremental`, `LeafCursor` with `seek`, `prefix_scan`, `get`, `MutexStorageBackend`) + - `dict.rs`: `DictReader` (id ↔ UUID/ident, tx timestamps, long values) and `Encoder` (checkpoint-time id assignment and key building) + - `value_pages.rs`: append-only value pages for strings over 64 bytes + - `reader.rs`: `OnDiskReader` — covering reads of committed facts (`CommittedReader`) + - `meta.rs` / `page.rs` / `freelist.rs`: meta pages A/B, the common page header and allocator, free-list chain - `cache.rs`: LRU page cache (`PageCache`, default 256 pages) - `dir_sync.rs`: `sync_parent_dir` — fsyncs the parent directory after creating the `.graph`/WAL or deleting the WAL (no-op off Unix) - - `packed_pages.rs`: Packed fact pages (~25 facts/4KB page), `MAX_FACT_BYTES` + - `packed_pages.rs`: v7 fact pages, read only for migration - `persistent_facts.rs`: `PersistentFactStorage` — v8 save/load, auto-migration v7→v8 3. **`src/query/datalog/`** — Datalog engine @@ -163,23 +169,28 @@ enum Value { String(String), Integer(i64), Float(f64), Boolean(bool), ``` Page 0, 1: Meta pages A/B (alternating commits: odd generations in page 0, even in 1). - Magic "MGRF"/"META", generation, page_count, index roots, free-list head, - required_features; CRC over the whole page. The only commit point. -Page 2+: Any mix of fact pages (0x41, interim until covering indexes), B+tree - leaf/internal pages (0x61/0x62) and free-list pages (0x81). Every one has - a 24-byte header (type, count, CRC32, page id, generation), verified on load. + Magic "MGRF"/"META", generation, page_count, five tree roots (EAVT, AEVT, + AVET, VAET, DICT), free-list head, next_eid/next_iid, required_features; + CRC over the whole page. The only commit point. +Page 2+: Any mix of B+tree leaf/internal pages (0x61/0x62), value pages (0x51) and + free-list pages (0x81). Every one has a 24-byte header (type, count, CRC32, + page id, generation), verified on load. Sidecar: .wal — v2 header records the base generation; CRC32-protected entries; replayed on open; deleted on checkpoint ``` -A checkpoint writes only pages the active meta does not reference, syncs, then writes -the other meta page and syncs, so a crash at any point keeps the previous checkpoint. +Covering indexes: every index entry is a whole fact as a byte-comparable key +(`e a v tx↓ vf vt op` in EAVT order), with entities and idents as sequential ids from +the DICT tree, assigned at checkpoint. Strings over 64 bytes live once in value pages. +Committed scans return facts in id order. A checkpoint writes only pages the active +meta does not reference, syncs, then writes the other meta page and syncs, so a crash +at any point keeps the previous checkpoint. Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are rejected (STG-028). Design: `docs/superpowers/specs/2026-10-05-v8-storage-format-design.md`. ## Test Coverage -**1233 tests** (1225 passing, 8 ignored; unit + integration + doc). +**1223 tests** (1215 passing, 8 ignored; unit + integration + doc). See `docs/TEST_COVERAGE.md` for the full per-file breakdown. **Testing conventions** — see the Testing Conventions section below before writing any tests. diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md index f0a49c487..f43a0464c 100644 --- a/docs/ERROR_REFERENCE.md +++ b/docs/ERROR_REFERENCE.md @@ -2049,7 +2049,7 @@ See the [file format section in README](../README.md#file-format) for version hi **Error text**: `No valid meta page: the file is not a Minigraf v7 or v8 database, or both meta pages are damaged` -**Cause**: Neither meta page (page 0 or page 1) passes its checksum, page 0 is not a valid format v7 header, and no migration backup meta page can be used. Pre-release v8 files written during v3.0.0 development are also rejected this way. +**Cause**: Neither meta page (page 0 or page 1) passes its checksum, page 0 is not a valid format v7 header, and no migration backup meta page can be used. Pre-release v8 files written during v3.0.0 development are also rejected this way: those with a single 84-byte header, and those with meta pages but no dictionary tree (written before covering keys). **Resolution**: - Check that the path points to a Minigraf database. diff --git a/docs/TEST_COVERAGE.md b/docs/TEST_COVERAGE.md index 8f94bda5f..3b43234a2 100644 --- a/docs/TEST_COVERAGE.md +++ b/docs/TEST_COVERAGE.md @@ -4,9 +4,9 @@ **Verified**: 2026-10-05 with `cargo test --quiet` -**Result**: 1,225 passing tests and 8 ignored tests (1,233 total) +**Result**: 1,215 passing tests and 8 ignored tests (1,223 total) -This covers the unreleased file-format-v8 work (#287, #371): the index-key value-collision fix, the v7→v8 migration, and the drop of formats v1–v6. It otherwise still covers the public database API, the Datalog engine, storage and recovery paths, v2.0.0's locking and structured-error-code changes, and v2.0.1's regression test for rebuilding indexes on open after several checkpoints (#370). Merged from the v2.x line: `tests/point_query_history_test.rs` and new storage unit tests check that attribute-narrowed bound-entity lookups return exactly what a full scan returns, and that `net_asserted_facts` matches its previous implementation on randomized histories (#323). Attribute-scan tests check that the AEVT range covers exactly the queried attribute, including non-ASCII names such as `:丿` and prefix siblings such as `:ab` for `:a` (#381). Incremental B+tree rebuild tests check that checkpoints which copy untouched index leaves produce exactly the entries and file checksum a full rebuild would, including over repeated checkpoints, that single-fact checkpoints do not fragment the index, and that a damaged slot directory is rejected rather than copied (#315). B+tree cursor tests check that every scan walks the tree, not the leaf `next_leaf` chain: full scans and start positions match the input, `seek` from the current position lands where a fresh descent from the root would (randomized, plus within a leaf, to a sibling, across subtrees, past the end, into the gap after a leaf, and backwards as a no-op), a seek to the sibling leaf reads exactly one page, reads are unchanged with every `next_leaf` zeroed, a child pointer into a non-B+tree page is STG-013, and a cycle in child pointers is an error, not a hang (v8 storage format PR 1, #374). Directory-sync unit tests check that creating the `.graph` file or the WAL, and deleting the WAL, fsync the parent directory after the file operation, and that reopening an existing file does not (#389). Disjunction tests check that `or-join` returns an empty result, not INT-031, when no rows reach it: on an empty database, after a pattern that matches nothing, after a `not` that removes every row, and in a rule body (#405). A stratification unit test builds the same two-rule negative cycle 64 times and checks that the INT-054 message names the same predicate pair every time (#410). Magic-sets tests check that a recursive rule with a literal start that recurses through another rule returns the same results as without the literal (#297). API tests check that `execute()` and `WriteTransaction::execute()` reject `$slot` bind tokens in every position with API-010 (#407). The REPL `run()` test now runs in a child process with null stdin, so it no longer hangs when the test runner's stdin is an open non-TTY stream (#415). v8 page-format tests (PR 2, #374, #388) check the 24-byte page header (a flipped bit, a wrong page id and a future generation each give their own STG code, legacy page types fail before the CRC, and a failed page is never cached), meta-page encoding, the free-list chain, WAL v2's base generation, and every row of the meta-selection table: both slots valid, a torn newest slot with the WAL's base generation (opens at the older one), a WAL newer than the valid meta, pages of the next generation on the free list or past `page_count` with no WAL (all STG-033, with the file unmodified), an unknown feature bit (STG-034), and a pre-release v8 single header (STG-032). Fault-injection tests crash `save()` at every page write (cleanly and with torn pages, including the meta write) and every sync, and crash a v7 migration at every write; each reopen finds exactly the old or the new checkpoint, and every reachable page plus the free list covers the file exactly. Twenty-five consecutive saves never write a page the previous meta references. A maximum-size fact's index keys fit in an internal node, and an oversized leaf entry or separator is rejected instead of overlapping the slot directory. +This covers the unreleased file-format-v8 work (#287, #371): the index-key value-collision fix, the v7→v8 migration, and the drop of formats v1–v6. It otherwise still covers the public database API, the Datalog engine, storage and recovery paths, v2.0.0's locking and structured-error-code changes, and v2.0.1's regression test for rebuilding indexes on open after several checkpoints (#370). Merged from the v2.x line: `tests/point_query_history_test.rs` and new storage unit tests check that attribute-narrowed bound-entity lookups return exactly what a full scan returns, and that `net_asserted_facts` matches its previous implementation on randomized histories (#323). Attribute-scan tests check that the AEVT range covers exactly the queried attribute, including non-ASCII names such as `:丿` and prefix siblings such as `:ab` for `:a` (#381). Incremental B+tree rebuild tests check that checkpoints which copy untouched index leaves produce exactly the entries and file checksum a full rebuild would, including over repeated checkpoints, that single-fact checkpoints do not fragment the index, and that a damaged slot directory is rejected rather than copied (#315). B+tree cursor tests check that every scan walks the tree, not the leaf `next_leaf` chain: full scans and start positions match the input, `seek` from the current position lands where a fresh descent from the root would (randomized, plus within a leaf, to a sibling, across subtrees, past the end, into the gap after a leaf, and backwards as a no-op), a seek to the sibling leaf reads exactly one page, reads are unchanged with every `next_leaf` zeroed, a child pointer into a non-B+tree page is STG-013, and a cycle in child pointers is an error, not a hang (v8 storage format PR 1, #374). Directory-sync unit tests check that creating the `.graph` file or the WAL, and deleting the WAL, fsync the parent directory after the file operation, and that reopening an existing file does not (#389). Disjunction tests check that `or-join` returns an empty result, not INT-031, when no rows reach it: on an empty database, after a pattern that matches nothing, after a `not` that removes every row, and in a rule body (#405). A stratification unit test builds the same two-rule negative cycle 64 times and checks that the INT-054 message names the same predicate pair every time (#410). Magic-sets tests check that a recursive rule with a literal start that recurses through another rule returns the same results as without the literal (#297). API tests check that `execute()` and `WriteTransaction::execute()` reject `$slot` bind tokens in every position with API-010 (#407). The REPL `run()` test now runs in a child process with null stdin, so it no longer hangs when the test runner's stdin is an open non-TTY stream (#415). v8 page-format tests (PR 2, #374, #388) check the 24-byte page header (a flipped bit, a wrong page id and a future generation each give their own STG code, legacy page types fail before the CRC, and a failed page is never cached), meta-page encoding, the free-list chain, WAL v2's base generation, and every row of the meta-selection table: both slots valid, a torn newest slot with the WAL's base generation (opens at the older one), a WAL newer than the valid meta, pages of the next generation on the free list or past `page_count` with no WAL (all STG-033, with the file unmodified), an unknown feature bit (STG-034), and a pre-release v8 single header (STG-032). Fault-injection tests crash `save()` at every page write (cleanly and with torn pages, including the meta write) and every sync, and crash a v7 migration at every write; each reopen finds exactly the old or the new checkpoint, and every reachable page plus the free list covers the file exactly. Twenty-five consecutive saves never write a page the previous meta references. Covering-key tests (PR 3, #433) check that byte order equals logical order for every key component and value type on random tuples (negative, zero and large integers, NaN and ±0.0, strings with 0x00, 64- and 65-byte strings, FOREVER, newest-transaction-first) and that every component round-trips; that prefix-compressed leaves and shortest-separator internal nodes round-trip, agree with a linear scan, keep `max(left) < s ≤ min(right)`, and reject an overflow; that the byte-key B+tree's builds, incremental rebuilds, cursor and `seek` match their expected sets; that dictionary ids are assigned once, never reused, shared between a keyword attribute and value, and deterministic; that a re-asserted or retracted long value writes no new value page and a forced hash collision still finds the right value; that one transaction with two timestamps is STG-037 and a missing dictionary entry STG-036; that committed reads by entity, entity and attribute, attribute, and in full equal an in-memory model across eight checkpoints and a reopen; that short-valued reads touch no value page; that a v7 file with every value type, long strings, refs and retractions migrates to exactly the same facts; that a pre-covering-key v8 file is STG-032; and that string values over 4,068 bytes and attributes or keywords over 1,024 bytes are WAL-003. A WAL test checks that reopening next to a fully checkpointed WAL does not reuse transaction numbers (#447). ### Covered Areas diff --git a/docs/superpowers/specs/2026-10-05-v8-storage-format-design.md b/docs/superpowers/specs/2026-10-05-v8-storage-format-design.md index 7f4a60c69..9e38b2461 100644 --- a/docs/superpowers/specs/2026-10-05-v8-storage-format-design.md +++ b/docs/superpowers/specs/2026-10-05-v8-storage-format-design.md @@ -318,11 +318,15 @@ Cost is O(pages allocated + pages freed) per checkpoint, never O(free-list size) the first time it appears. Attribute and keyword values share one namespace, so the ident `:status/active` gets one id whether it is used as an attribute or a value. `next_iid` is stored in the meta page. -- **Assignment time.** Ids are assigned in memory at transact time, in WAL order. WAL - replay reassigns them in the same order, starting from the meta page's counters, so the - result is deterministic and crash-safe without logging ids. Assignment at transact - time lets the in-memory pending index use the same encoded keys as the on-disk trees, - so merging pending and committed data is a plain merge of two sorted streams. +- **Assignment time.** Ids are assigned at checkpoint time, in pending order (which is + WAL order), starting from the meta page's counters. *(Amended in delivery PR 3; the + first draft assigned them at transact time.)* A long value's ref (§6.2) exists only + once its value page is written, which happens at checkpoint, so a key that contains + it cannot be built earlier. Ids are never persisted before the commit that also + writes their DICT entries and the keys that use them, so checkpoint-time assignment + is deterministic and crash-safe without logging ids, and the transact path does no + dictionary I/O. The in-memory pending index keeps UUID- and string-keyed maps; a + streaming scan (#432) merges pending and committed facts at scan time. - **Translation boundary.** The query layer receives facts with UUIDs and strings, as today. The reader translates ids through cached dictionary lookups. The streaming engine (#432) should work on raw ids and translate only at output; this design keeps @@ -350,10 +354,10 @@ DICT entries: |---|---|---|---| | 0x01 | `uuid[16]` | `eid` | UUID → eid (transact, query constants) | | 0x02 | `eid` | `uuid[16]` | eid → UUID (results) | -| 0x03 | ident bytes (escaped) | `iid` | name → iid | +| 0x03 | ident bytes (raw; nothing follows them) | `iid` | name → iid | | 0x04 | `iid` | ident bytes | iid → name | | 0x05 | `tx_count` | `tx_id` (wall-clock ms) | `Fact.tx_id`, once per transaction | -| 0x06 | `hash64` of a long value | value ref | dedup of long values (§6.3) | +| 0x06 | `hash64` of a long value ‖ value ref | — | dedup of long values (§6.3); several values may share a hash | Tags 0x02, 0x04 and 0x05 receive increasing keys, so their inserts touch only the rightmost path. Tags 0x01 and 0x06 are random-key inserts, but they happen once per new @@ -389,7 +393,10 @@ negatives). - `eid`, `iid` and `tx_count` use the unsigned form. - `vt == VALID_TIME_FOREVER`, the common case, is the single byte `0xFF`. It sorts after every finite time. -- `tx↓` is the encoding of `u64::MAX - tx_count`, so newer transactions sort first. +- `tx↓` is the encoding of `tx_count` with every byte complemented, so newer + transactions sort first. The encoding is prefix-free, so complementing it reverses + the order exactly, and a typical `tx_count` costs 2–4 bytes. *(Amended in delivery + PR 3; the first draft encoded `u64::MAX − tx_count`, which always costs 9 bytes.)* ### 6.2 Values @@ -401,7 +408,7 @@ cross-type ordering stays Null < Boolean < Integer < Float < String < Keyword < | Null | — | | Boolean | 1 byte | | Integer | §6.1 signed | -| Float | 8 bytes, the existing order-preserving bit transform, NaN canonicalised | +| Float | 8 bytes, the existing order-preserving bit transform; NaN canonicalised to one value that sorts after +∞ | | String ≤ 64 B | bytes with 0x00 escaped as `00 FF`, terminated by `00 00` | | String > 64 B | first 32 bytes escaped, then marker `00 01`, `hash64`, value ref (`page u64` + `slot u16`) | | Keyword | `iid` (§6.1). Keywords sort by id, not by name | @@ -424,12 +431,16 @@ pages. They are never rewritten and never freed, because a retraction stores the value again rather than deleting it. They are reachable only through value refs in index keys, so verify (#373) finds them by walking the indexes. -**Dedup:** before a new long value is written, DICT tag 0x06 is looked up by `hash64`, -and each candidate's full value is compared. A retraction or re-assertion of the same +**Dedup:** before a new long value is written, DICT tag 0x06 is scanned for entries +with its `hash64` (FNV-1a 64, stable across versions), and each candidate's full value +is compared. A retraction or re-assertion of the same long value therefore costs no new value page. The 3.0.0 maximum value length is one value page's payload (4068 B). That is about today's limit, but it now applies to the -value alone rather than the whole fact. `MAX_FACT_BYTES` is replaced by -`MAX_VALUE_BYTES`, a public API change recorded in the CHANGELOG. Values spanning +value alone rather than the whole fact. `MAX_FACT_BYTES` (crate-private) is replaced by +`MAX_VALUE_BYTES`; the user-visible limit changes and is recorded in the CHANGELOG. +Attribute names and keyword values are capped at 1024 bytes (the parser's keyword +limit), so every DICT entry fits in a node. Both limits are enforced when the WAL entry +is written (WAL-003). Values spanning several pages can come later behind a feature bit. ## 7. Reads @@ -660,8 +671,11 @@ TDD per component. No new dependencies. Test assert messages follow the CodeQL r never recycled. Update `docs/ERROR_REFERENCE.md`. - WAL format version 2: the header gains `base_generation u64` at bytes 8..16, taken from the reserved bytes. A v1 WAL is accepted only next to a v7 file being migrated. -- Public API: `MAX_FACT_BYTES` → `MAX_VALUE_BYTES` (§6.3). Committed scan order changes - (§5.3). +- Size limits: string values ≤ 4068 bytes, attributes and keywords ≤ 1024 bytes, + replacing the 4052-byte fact limit (§6.3). Committed scan order changes (§5.3). +- New STG codes from delivery PR 3: dictionary entry missing (STG-036), and one + `tx_count` with two `tx_id`s (STG-037), which a checkpoint or migration refuses rather + than altering a timestamp. - Browser: `BrowserBufferBackend` dirty sets become O(change). The IndexedDB flush must write the meta page in the same IDB transaction as the data pages. Verify this, and fix it if it is not already the case. diff --git a/src/storage/btree.rs b/src/storage/btree.rs index e9d5504cc..d6d50c9ff 100644 --- a/src/storage/btree.rs +++ b/src/storage/btree.rs @@ -14,7 +14,7 @@ use crate::error::{ErrorCode, bail_coded, err_coded}; use crate::storage::cache::PageCache; use crate::storage::node::{ Entry, Internal, decode_leaf, encode_internal, encode_leaf, internal_size, leaf_entry_sizes, - leaf_first_key, leaf_get, leaf_size, shortest_separator, + leaf_first_key, leaf_get, leaf_last_key, leaf_size, shortest_separator, validate_leaf, }; use crate::storage::page::{PAGE_HEADER_SIZE, PAGE_TYPE_INTERNAL, PAGE_TYPE_LEAF, PageAllocator}; use crate::storage::{PAGE_SIZE, StorageBackend}; @@ -258,11 +258,8 @@ pub fn rebuild_btree_incremental( } batch.extend(pending.next()); } - let old = decode_leaf(&page[..])?; if batch.is_empty() { - let last = old - .last() - .map(|(k, _)| k.clone()) + let last = leaf_last_key(&page[..])? .ok_or_else(|| err_coded!(ErrorCode::Int049, "non-empty leaf without entries"))?; let id = alloc.alloc()?; alloc.write(backend, cache, id, (**page).clone())?; @@ -272,6 +269,7 @@ pub fn rebuild_btree_incremental( last, }); } else { + let old = decode_leaf(&page[..])?; emit_leaves( merge_entries(old, batch)?, &mut infos, @@ -317,7 +315,7 @@ pub fn collect_leaf_pages( Some(PAGE_TYPE_LEAF) => { // Untouched leaves are copied and resealed, so damage the CRC // cannot see must be caught here, not carried forward. - decode_leaf(&page[..])?; + validate_leaf(&page[..])?; leaves.push(page); } Some(PAGE_TYPE_INTERNAL) => { diff --git a/src/storage/meta.rs b/src/storage/meta.rs index 1da59778c..42c37e8d9 100644 --- a/src/storage/meta.rs +++ b/src/storage/meta.rs @@ -168,6 +168,16 @@ impl MetaPage { SlotState::Valid(meta) } + /// Fail with STG-032 for a pre-release v8 file: one written before covering + /// keys, whose trees have no dictionary. A committed file always has a DICT + /// tree once it has index trees. + pub fn check_layout(&self) -> Result<()> { + if self.eavt_root != 0 && self.dict_root == 0 { + bail_coded!(ErrorCode::Stg032); + } + Ok(()) + } + /// Fail with STG-034 if the file needs a feature this version lacks. pub fn check_features(&self) -> Result<()> { let unknown = self.required_features & !KNOWN_FEATURES; diff --git a/src/storage/node.rs b/src/storage/node.rs index 9a0d8c11f..d3faccd29 100644 --- a/src/storage/node.rs +++ b/src/storage/node.rs @@ -238,6 +238,63 @@ pub fn decode_leaf(page: &[u8]) -> Result> { Ok(entries) } +/// Check a leaf's structure without copying keys: every entry and the restart +/// array stay inside the page, restart offsets match, and no entry shares more +/// than its predecessor's length. Key order is not checked (decode does that). +pub fn validate_leaf(page: &[u8]) -> Result<()> { + if page.first().copied() != Some(PAGE_TYPE_LEAF) { + bail_coded!(ErrorCode::Int049, "not a leaf page"); + } + let count = node_count(page)?; + let restarts = count.div_ceil(RESTART_INTERVAL); + let end = PAGE_SIZE + .checked_sub(2 * restarts) + .filter(|&e| e >= PAGE_HEADER_SIZE) + .ok_or_else(|| invalid("restart array overflows the leaf"))?; + let mut pos = PAGE_HEADER_SIZE; + let mut prev_len = 0usize; + for i in 0..count { + let restart = i % RESTART_INTERVAL == 0; + if restart && get_u16(page, end + 2 * (i / RESTART_INTERVAL))? != pos { + bail_coded!(ErrorCode::Int049, "leaf restart offset mismatch"); + } + let shared = get_varint(page, &mut pos)?; + if shared > if restart { 0 } else { prev_len } { + bail_coded!( + ErrorCode::Int049, + "leaf entry shares more than its predecessor" + ); + } + let suffix = get_varint(page, &mut pos)?; + get_slice(page, &mut pos, suffix)?; + let value = get_varint(page, &mut pos)?; + get_slice(page, &mut pos, value)?; + if pos > end { + bail_coded!(ErrorCode::Int049, "leaf entry overlaps the restart array"); + } + prev_len = shared + suffix; + } + Ok(()) +} + +/// The last key of a leaf, decoded from its last restart point; `None` if empty. +pub fn leaf_last_key(page: &[u8]) -> Result>> { + let count = node_count(page)?; + if count == 0 { + return Ok(None); + } + let restarts = count.div_ceil(RESTART_INTERVAL); + let end = PAGE_SIZE + .checked_sub(2 * restarts) + .ok_or_else(|| invalid("restart array overflows the leaf"))?; + let mut pos = get_u16(page, end + 2 * (restarts - 1))?; + let mut key: Vec = Vec::new(); + for _ in (restarts - 1) * RESTART_INTERVAL..count { + key = read_leaf_entry(page, &mut pos, &key, end)?.0; + } + Ok(Some(key)) +} + /// The value stored under `key` in a leaf, found by binary search over the /// restart points and a scan of at most [`RESTART_INTERVAL`] entries. pub fn leaf_get(page: &[u8], key: &[u8]) -> Result>> { @@ -496,6 +553,11 @@ mod tests { leaf_first_key(&page).unwrap(), entries.first().map(|(k, _)| k.clone()) ); + assert_eq!( + leaf_last_key(&page).unwrap(), + entries.last().map(|(k, _)| k.clone()) + ); + validate_leaf(&page).unwrap(); for (k, v) in entries { assert_eq!(leaf_get(&page, k).unwrap().as_ref(), Some(v), "present key"); } @@ -548,14 +610,14 @@ mod tests { let page = encode_leaf(&entries).unwrap(); let mut bad = page.clone(); bad[2] = 200; // count far beyond the data - assert!(decode_leaf(&bad).is_err()); + assert!(decode_leaf(&bad).is_err() && validate_leaf(&bad).is_err()); let mut bad = page.clone(); bad[PAGE_HEADER_SIZE] = 9; // first entry claims a shared prefix - assert!(decode_leaf(&bad).is_err()); + assert!(decode_leaf(&bad).is_err() && validate_leaf(&bad).is_err()); let mut bad = page; let last = PAGE_SIZE - 2; bad[last] ^= 1; // restart offset - assert!(decode_leaf(&bad).is_err()); + assert!(decode_leaf(&bad).is_err() && validate_leaf(&bad).is_err()); } #[test] diff --git a/src/storage/persistent_facts.rs b/src/storage/persistent_facts.rs index 63ae3d83a..5cf4f3565 100644 --- a/src/storage/persistent_facts.rs +++ b/src/storage/persistent_facts.rs @@ -71,6 +71,7 @@ impl PersistentFactStorage { Opened::Fresh => pfs.init_empty()?, Opened::Meta(m) => { m.check_features()?; + m.check_layout()?; m } Opened::LegacyV7(h) => pfs.migrate_v7(&h)?, @@ -1107,6 +1108,23 @@ mod tests { assert_eq!(code(open_mem(&mem, None).err().unwrap()), "STG-032"); } + /// A file from a v3.0.0 development build before covering keys has valid + /// meta pages but trees without a dictionary: refused, never misread. + #[test] + fn pre_release_v8_meta_without_dictionary_is_stg_032() { + let mut mem = three_generations(); + let mut m = match MetaPage::decode(&mem.read_page(slot_page(3)).unwrap()) { + SlotState::Valid(m) => m, + _ => panic!("gen 3 valid"), + }; + m.dict_root = 0; + m.next_eid = 0; + mem.write_page(slot_page(3), &m.encode()).unwrap(); + let before = snapshot(&mem); + assert_eq!(code(open_mem(&mem, None).err().unwrap()), "STG-032"); + assert!(snapshot(&mem) == before, "file unchanged"); + } + // ── v7 migration (spec §9) ────────────────────────────────────────────── /// A v7 file: header in page 0 and v7 (0x02, 12-byte header) fact pages from 1.