8000 perf: decide net-assert on v8 index keys before building facts (#379) by adityamukho · Pull Request #459 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
10000
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Internal

- **Committed reads hide superseded history on index keys** (#379). A query's committed facts are now reduced to the ones that survive net-assert (newest assertion per valid-time window, newer than every retraction) while walking the index keys, before any fact is built. Only surviving entries are decoded and translated through the dictionary. A transaction that retracted a triple hides everything older in it, and a long run of hidden entries is skipped with one `seek`. `:as-of N` point and attribute queries now take the same index path instead of reading every fact in the file. On a checkpointed file with 2,000 superseded values, a point query takes 1.03 ms instead of 4.61 ms, an attribute scan 1.24 ms instead of 5.87 ms, and an `:as-of` point query 23 µs instead of 5.6 ms (at depth 1). Results are unchanged: the executor still applies net-assert to committed and pending facts together, and a pending fact is always newer than a committed one.
- **B+tree reads no longer follow leaf sibling links** (v8 storage format, PR 1 of 5; #374). Every index scan uses a cursor that walks down from the root and keeps the path, with a forward `seek` that climbs only as far as needed. This prepares for copy-on-write index pages, which cannot keep sibling links. The file format and query results are unchanged. Concurrent 100k-fact scans take 25–37 % less time and checkpointing after one fact at 10k facts 9 % less; single point lookups and attribute scans are 2–6 % slower (about 0.4 µs per lookup). A corrupted child pointer now fails with STG-013 (wrong page type) or INT-049 (cycle) instead of following a bad link.

### Tests
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ cargo run < demos/demo_negation.txt
- `btree.rs`: On-disk B+tree over byte keys (`build_btree`, `cow_insert`, `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`)
- `reader.rs`: `OnDiskReader` — covering reads of committed facts (`CommittedReader`); `live_facts` decides net-assert on index keys (#379)
- `verify.rs`: integrity walk for `Minigraf::verify` (tree order, index digests, DICT, page accounting) and the source choice for `rebuild_indexes` (#373)
- `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)
Expand Down Expand Up @@ -194,7 +194,7 @@ Auto-migrates v7 → v8 on open (spec §9, with a backup meta page). v1–v6 are

## Test Coverage

**1248 tests** (1239 passing, 9 ignored; unit + integration + doc).
**1252 tests** (1243 passing, 9 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ let r2 = pq.execute(&[("tx", BindValue::TxCount(2)), ("entity", BindValue::Entit

```bash
cargo run # interactive Datalog REPL
cargo test # run 1248 tests
cargo test # run 1252 tests
cargo run < demos/demo_recursive.txt # recursive rules demo
```

Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Scope — format and storage integrity:

The format is frozen for the golden-file corpus (#391).
- Index `verify` and public `rebuild_indexes()` (#373; done on `v3`)
- Net-assert on v8 keys before resolving facts, for O(live) point queries (#379)
- Net-assert on v8 keys before resolving facts, for O(live) point queries (#379; done on `v3`)
- `btree_page` fuzz target that reaches node decoding (#375)

Scope — release-gate testing and documentation:
Expand Down
5 changes: 5 additions & 0 deletions benches/minigraf_bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1673,6 +1673,11 @@ fn bench_point_query_chain_depth(c: &mut Criterion) {
"(query [:find ?v :where [:e/hot :other ?v]])",
),
("attr_scan", "(query [:find ?v :where [?e :hash ?v]])"),
// tx 2002 asserted h0: an :as-of point query on committed history (#379).
(
"churned_attr_as_of",
"(query [:find ?v :as-of 2002 :valid-at :any-valid-time :where [:e/hot :hash ?v]])",
),
];
for &(name, q) in QUERIES {
let mut group = c.benchmark_group(format!("point_query_chain_depth/{name}"));
Expand Down
20 changes: 20 additions & 0 deletions docs/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ One entity's `:hash` is retracted and reasserted `depth` times (exactly one live

The churned attribute still scales with its own history on v2.x; see #379 for the v3.0.0 fix.

### Net-Assert on Index Keys (#379, v3.0.0)

**Date**: 2026-10-06 · **Command**: `cargo bench --bench minigraf_bench -- point_query_chain_depth --warm-up-time 1 --measurement-time 3` · same fixture as above, on format v8. "Before" is `v3` at `74310ee`; "after" decides net-assert on index keys and translates only surviving entries. The `:as-of` row is new: `[:find ?v :as-of 2002 :valid-at :any-valid-time :where [:e/hot :hash ?v]]`, which used to scan every fact in the file.

| Query | Depth | Before | After |
|---|---:|---:|---:|
| `[:e/hot :hash ?v]` (churned attribute) | 1 | 19.9 µs | 21.1 µs |
| | 500 | 1.10 ms | 262 µs |
| | 2000 | 4.61 ms | 1.03 ms |
| `[:e/hot :other ?v]` (sibling attribute) | 1 | 21.6 µs | 22.6 µs |
| | 2000 | 28.8 µs | 30.1 µs |
| `[?e :hash ?v]` (attribute scan) | 1 | 22.7 µs | 23.5 µs |
| | 500 | 1.20 ms | 294 µs |
| | 2000 | 5.87 ms | 1.24 ms |
| `:as-of 2002` on the churned attribute | 1 | 5.59 ms | 23.4 µs |
| | 500 | 6.30 ms | 216 µs |
| | 2000 | 8.55 ms | 845 µs |

Depth-1 differences are within run-to-run noise: two back-to-back A/B runs measured 19.0–19.3 µs before and 19.8–19.9 µs after for the churned attribute, and 22.6 µs before and 21.2–21.5 µs after for the sibling attribute. In this fixture every retraction and re-assertion uses a new value, so each history entry is its own two-entry triple. The remaining cost is the walk over those keys. A triple with a long history of its own is skipped with one seek.

### Checkpoint After One Dirty Fact (#315)

**Date**: 2026-09-26 · **Command**: `cargo bench --bench minigraf_bench -- checkpoint/after_1_fact` · same host as above; file on tmpfs, so fsync cost is excluded.
Expand Down
4 changes: 2 additions & 2 deletions docs/TEST_COVERAGE.md

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions docs/superpowers/specs/2026-10-06-live-key-scans-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Net-assert on index keys (O(live) committed reads) — Design

**Issue:** #379 (follow-up to #323), tracker #383
**Milestone:** v3.0.0, branch `v3`, file format v8 (no format change)
**Builds on:** `2026-10-05-v8-storage-format-design.md` §5.3, §7

## 1. Problem

Every non-`:as-of` query fetches facts, then applies `net_asserted_facts` (keep an
assertion only if it is the newest in its `(e, a, v, vf, vt)` window and newer than every
retraction of `(e, a, v)`). On v8, a committed read decodes every index entry of the
range and translates it into a `Fact` through the dictionary (UUID, attribute name,
timestamp, maybe a long-value page), including every superseded and retracted record.
The cost is O(history), and the history is thrown away a moment later.

`:as-of N` queries are worse: they never take the selective path and always scan and
translate every fact in the file.

## 2. Idea

v8 keys are ordered `e a v tx↓ vf vt op` (EAVT; AEVT is `a e v …`). The history of one
triple is contiguous and newest first. Net-assert can therefore be decided on the key
bytes while walking, and only surviving entries are decoded and translated:

- Walk the triple's entries in `tx` descending order, one transaction group at a time.
- If a group holds a retraction, its assertions and every older entry of the triple are
dead. They are passed over one by one; after 8 in a row the walk calls
`seek(triple ‖ 0xFF)` (`0xFF` sorts after every `tx↓` byte). Short histories, the
common case, pay no seek; long ones pay O(log) page visits.
- Otherwise each assertion survives if its `(vf, vt)` bytes have not been seen in this
triple; the first one seen is the newest of its window.
- `:as-of N`: entries newer than `N` are passed over the same way; the seek target is
`triple ‖ tx↓(N)`, the triple's newest entry at or before `N`.

## 3. Why prefiltering committed facts is exact

The executor still applies `net_asserted_facts` to committed and pending facts together.
Prefiltering is exact because every pending fact has a larger `tx_count` than every
committed one, and net-assert only ever lets a newer record hide an older one:

- A committed assertion hidden by a committed record stays hidden in the union.
- A dropped committed retraction can hide only committed assertions older than it, and
those are already gone. It cannot hide a pending assertion, which is newer.
- `:as-of N` applies the same argument to the records with `tx_count ≤ N`.

A randomized test checks `net(prefilter(committed) ∪ pending) = net(committed ∪ pending)`
for random histories, with and without `:as-of`.

## 4. Changes

- `keys::triple_len(index, key)`: bytes of the leading `(e, a, v)` (or index-order)
triple, without decoding the value.
- `CommittedReader::live_facts(scan, as_of)` with `scan` one of all, entity,
entity + attribute, attribute. The default implementation (history, then `as_of`
filter, then `net_asserted_facts`) is the reference; `OnDiskReader` overrides it with
the key walk.
- `FactStorage::get_live_facts(scan, as_of)`: pending facts (filtered by `as_of`) plus
committed live facts.
- Executor `filter_facts_for_query`: when there is no transaction overlay, the
`None` and `:as-of N` (counter) cases use live reads, selective when patterns bind
entities or attributes and a full live scan otherwise. `:as-of` timestamps, which
have no key form, and the transaction overlay keep their current path.

## 5. Testing

- `triple_len` agrees with a full decode for every value type and index.
- Randomized histories (asserts, retracts, re-asserts, valid-time windows, several
values per attribute, as-of points): `live_facts` from disk equals the reference for
every scan kind, before and after reopen.
- Query results with and without the prefilter are identical on the existing suites;
new point-query tests for retract/re-assert and `:as-of` on committed data.
- A counting backend shows that a point query on an entity with long history reads
fewer leaves than a history read.

## 6. Philosophy

Aligned: no format change, no API change, no dependency; less work per query.
76 changes: 76 additions & 0 deletions src/graph/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,78 @@ fn pending_fact(d: &FactData, pos: usize) -> Result<Fact> {

/// Production helpers on FactStorage: index-driven entity/attribute lookups used by the query executor.
impl FactStorage {
/// Facts for a query's net-assert step: the pending facts of `scan` with
/// `tx_count <= as_of` (all when `None`), plus the committed facts of `scan`
/// that survive net-assert at `as_of` (#379).
///
/// `net_asserted_facts` over the result equals `net_asserted_facts` over
/// every record of `scan` up to `as_of` (see
/// [`CommittedReader::live_facts`](crate::storage::CommittedReader::live_facts)),
/// but committed history is not translated into facts.
pub(crate) fn get_live_facts(
&self,
scan: crate::storage::Scan<'_>,
as_of: Option<u64>,
) -> Result<Vec<Fact>> {
use crate::storage::Scan;
use crate::storage::index::{AevtKey, EavtKey};
let d = self.data.read().unwrap_or_else(|e| e.into_inner());
let mut facts = Vec::new();
let mut push = |pos: usize| -> Result<()> {
let f = pending_fact(&d, pos)?;
if as_of.is_none_or(|n| f.tx_count <= n) {
facts.push(f);
}
Ok(())
};
match scan {
Scan::All => {
for pos in 0..d.facts.len() {
push(pos)?;
}
}
Scan::Entity(entity) => {
for (key, &pos) in d
.pending_indexes
.eavt
.range(EavtKey::entity_start(*entity)..)
{
if key.entity != *entity {
break;
}
push(pos)?;
}
}
Scan::EntityAttribute(entity, attribute) => {
let start = EavtKey::entity_attribute_start(*entity, attribute);
for (key, &pos) in d.pending_indexes.eavt.range(start..) {
if key.entity != *entity || key.attribute != attribute {
break;
}
push(pos)?;
}
}
Scan::Attribute(attribute) => {
for (key, &pos) in d
.pending_indexes
.aevt
.range(AevtKey::attribute_start(attribute)..)
{
if key.attribute != attribute {
break;
}
push(pos)?;
}
}
}
if let Some(reader) = &d.committed {
facts.extend(reader.live_facts(scan, as_of)?);
}
Ok(facts)
}

/// History read (every record); tests compare it with live reads and models.
#[cfg(test)]
/// Get all facts for a specific entity (index-driven).
pub(crate) fn get_facts_by_entity(&self, entity_id: &EntityId) -> Result<Vec<Fact>> {
use crate::storage::index::EavtKey;
Expand All @@ -525,6 +597,8 @@ impl FactStorage {
Ok(facts)
}

/// History read (every record); tests compare it with live reads and models.
#[cfg(test)]
/// Get every stored record for one `(entity, attribute)` pair (index-driven, #323).
///
/// Reads only that pair's EAVT range, so other attributes of the same entity
Expand All @@ -551,6 +625,8 @@ impl FactStorage {
Ok(facts)
}

/// History read (every record); tests compare it with live reads and models.
#[cfg(test)]
/// Get all facts for a specific attribute (index-driven).
pub(crate) fn get_facts_by_attribute(&self, attribute: &Attribute) -> Result<Vec<Fact>> {
use crate::storage::index::AevtKey;
Expand Down
47 changes: 28 additions & 19 deletions src/query/datalog/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,10 @@ impl DatalogExecutor {
/// `execute_query_with_rules` call.
///
/// Step 1 uses selective index-backed fetches when query patterns bind concrete entities
/// or attributes (up to 4 distinct lookups); falls back to `get_all_facts()` otherwise.
/// Step 2 (caching `net_asserted_facts()`) remains a future optimisation opportunity.
/// or attributes (up to 4 distinct lookups), and a full scan otherwise. Without a
/// transaction overlay, and for `:as-of` counters, committed history is already
/// reduced to its net-asserted records on index keys (#379); step 2 still runs over
/// the union with the pending facts, which gives the same result.
fn filter_facts_for_query(&self, query: &DatalogQuery) -> Result<Arc<[Fact]>> {
let now = self.read_now();

Expand All @@ -318,20 +320,10 @@ impl DatalogExecutor {
crate::graph::storage::filter_facts_as_of(facts.iter().cloned().collect(), as_of)
}
(Some(facts), None) => facts.iter().cloned().collect(),
// A counter is a key bound, so committed history is cut on index keys.
(None, Some(AsOf::Counter(n))) => self.live_source_facts(query, Some(*n))?,
(None, Some(as_of)) => self.storage.get_facts_as_of(as_of)?,
(None, None) => {
// Selective fetch is only safe when no rule invocations are present —
// rules require the full fact base to evaluate correctly.
if !query.uses_rules() {
let patterns = collect_all_patterns(&query.where_clauses);
match self.selective_fact_fetch(&patterns, 4)? {
Some(facts) => facts,
None => self.storage.get_all_facts()?,
}
} else {
self.storage.get_all_facts()?
}
}
(None, None) => self.live_source_facts(query, None)?,
};

let tx_filtered = source_facts;
Expand Down Expand Up @@ -360,6 +352,20 @@ impl DatalogExecutor {
Ok(Arc::from(valid_filtered))
}

/// Step 1 without a transaction overlay: pending facts up to `as_of` plus the
/// committed facts that survive net-assert at `as_of` (#379), selectively when the
/// patterns allow it. Rules need the whole fact base, so they always get a full scan.
fn live_source_facts(&self, query: &DatalogQuery, as_of: Option<u64>) -> Result<Vec<Fact>> {
if !query.uses_rules() {
let patterns = collect_all_patterns(&query.where_clauses);
if let Some(facts) = self.selective_fact_fetch(&patterns, 4, as_of)? {
return Ok(facts);
}
}
self.storage
.get_live_facts(crate::storage::Scan::All, as_of)
}

/// Attempt a selective index-backed fact fetch for the given patterns.
///
/// Patterns with a bound entity literal (UUID or keyword → deterministic UUID) are
Expand All @@ -376,12 +382,15 @@ impl DatalogExecutor {
/// A lookup error (a damaged page) is returned, never turned into a full scan.
///
/// Results are not deduplicated: the sole caller feeds them to `net_asserted_facts`,
/// which is idempotent under duplicated records.
/// which is idempotent under duplicated records. Each lookup returns the pending facts
/// up to `as_of` and the committed facts live at `as_of` (`FactStorage::get_live_facts`).
fn selective_fact_fetch(
&self,
patterns: &[Pattern],
threshold: usize,
as_of: Option<u64>,
) -> Result<Option<Vec<Fact>>> {
use crate::storage::Scan;
use std::collections::{BTreeMap, BTreeSet};

// Per bound entity: Some(attrs) = only these attributes are referenced;
Expand Down Expand Up @@ -441,16 +450,16 @@ impl DatalogExecutor {
for attr in attrs {
all_facts.extend(
self.storage
.get_facts_by_entity_attribute_indexed(uid, attr)?,
.get_live_facts(Scan::EntityAttribute(uid, attr), as_of)?,
);
}
}
_ => all_facts.extend(self.storage.get_facts_by_entity(uid)?),
_ => all_facts.extend(self.storage.get_live_facts(Scan::Entity(uid), as_of)?),
}
}

for attr in &attributes {
all_facts.extend(self.storage.get_facts_by_attribute(attr)?);
all_facts.extend(self.storage.get_live_facts(Scan::Attribute(attr), as_of)?);
}

Ok(Some(all_facts))
Expand Down
2 changes: 0 additions & 2 deletions src/storage/btree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -558,8 +558,6 @@ impl<'a> LeafCursor<'a> {

/// 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(());
Expand Down
Loading
Loading
0