8000 fix!: file format v8 — keep same-transaction multi-valued facts (#371, #287) by adityamukho · Pull Request #377 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

fix!: file format v8 — keep same-transaction multi-valued facts (#371, #287) - #377

Merged
adityamukho merged 21 commits into
mainfrom
fix/371-index-value-collision
Sep 25, 2026
Merged

adityamukho merged 21 commits into
mainfrom
fix/371-index-value-collision

Conversation

@adityamukho
@adityamukho adityamukho commented Sep 25, 2026 •
Copy link
Copy Markdown
Collaborator

Summary

When one transaction wrote two values of the same attribute for one entity, reads returned only one of them, and EAVT and AEVT paths could return different ones. A batched retract could leave a retracted value visible. This PR fixes all three layers that caused it, moves the file format to v8, and drops support for formats v1–v6.

Closes #371
Closes #287

Root causes

  1. Pending index overwrite (before checkpoint): EavtKey/AevtKey had no value bytes, so two values in one transaction collided and the in-memory BTreeMap kept only the last.
  2. Query-time dedup: selective_fact_fetch deduplicated on (entity, attribute, tx_count, asserted), which kept one of the colliding facts and dropped one half of a batched retract. The final review also found it dropped facts that differ only in valid time.
  3. Unstable order on disk: after a checkpoint both colliding entries were on disk, but sort_unstable_by ordered them differently in EAVT and AEVT, so "first seen" differed between paths.

Changes

  • Index keys (format v8): EAVT/AEVT gain value_bytes + asserted; AVET/VAET gain asserted. Fields are appended, so range-scan order is unchanged. All key construction goes through per-key constructors. Lookups use take_while instead of "zzzz"/u64::MAX sentinels.
  • v7 → v8 migration: a v7 file (written by v2.x) is always re-indexed from its fact pages on open. The header is written last, after a sync, so a crash mid-rebuild leaves a v7 header and the next open rebuilds again. The rebuild never lowers tx_counter/last_checkpointed_tx_count, even when the last transactions were empty. The upgrade is one-way.
  • Query dedup: the selective_fact_fetch key is now the full EAVT identity (entity, attribute, tx_count, asserted, value, valid_from, valid_to).
  • v1–v6 dropped: they fail with new STG-028 ("open once with Minigraf v2.x first"). btree.rs and the v1→v2, one-per-page and v5→v6 migrations are removed. STG-003/STG-004 are deprecated (no longer emitted; still registered and documented, never reused), and the STG-005/STG-006 message text changed.
  • Fixtures: new tests/fixtures/v7_multivalue.graph (written by the v2.0.0 v7 writer). compat.graph is now the frozen v7 fixture; its tests assert v7 before opening and v8 after. generate_compat_fixture no longer overwrites it.
  • Fuzz: fact_page / btree_page targets use v8 headers (btree_page still doesn't reach node decoding; see fuzz: btree_page target never reaches B+tree node decoding #375).

Tests

  • New tests/multi_value_test.rs (10 tests) covers:
    • every path (entity-bound, attribute-bound, full scan) before checkpoint, after WAL replay (real crash child) and after checkpoint + reopen, across 8 graph shapes
    • batched retracts, including one that fails on the pre-fix dedup key
    • valid-time stints in one transaction
    • v7 fixture migration and second open
    • near-max and exact-boundary value sizes through EAVT/AEVT/AVET
  • Unit tests for key collisions in all four indexes, lookup boundaries, forced v7 rebuild, and no tx_counter rewind.
  • Legacy-version tests are now rejection tests: STG-028 for v1–v6, with the file left unmodified. STG-006 for newer-than-v8 files, also left unmodified.
  • cargo test: 1149 passed, 8 ignored, 0 failed. cargo fmt --check and cargo clippy --all-features -- -D warnings are clean. fuzz crate cargo check is clean.

Performance / size (subset, --measurement-time 5, main vs this branch)

Benchmark main branch Δ
insert/single_fact/1k 3.557 µs 3.784 µs +6.4%
insert/single_fact/10k 3.807 µs 3.945 µs +3.6%
query/point_entity/1k 6.109 µs 6.352 µs +4.0%
query/point_attribute/1k 2296.5 µs 2445.7 µs +6.5%
open/checkpointed/1k 86.38 µs 98.28 µs +13.8%
checkpoint/1k 29.04 µs 31.90 µs +9.9%

The file for 10,000 facts, checkpointed, grows from 2,641,920 to 2,965,504 bytes (+12.2%), because index keys now carry value bytes. This is a short, subset run; treat it as indicative.

Breaking changes (v3.0.0 milestone)

  • v2.x cannot open a v8 file (STG-006). The first open with this version upgrades a v7 file in place, including through the bindings.
  • Formats v1–v6 are no longer readable (STG-028).
  • The error-code changes above.

Follow-ups

🤖 Generated with Claude Code

https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy

adityamukho and others added 20 commits September 21, 2026 20:23
… for v6/v7, fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
…287)

Generated by the v7 writer at 84bf375 (v2.0.0 format). Generator source is
recorded in tests/multi_value_test.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
Formats older than v7 now fail to open with STG-028, which tells the user
to open the file once with v2.x. Removes the v1→v2, one-per-page and
v5→v6 migrations and the legacy v5 B+tree module. STG-003/STG-004 are
retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
…371, #287)

EavtKey/AevtKey gain value_bytes and asserted; AvetKey/VaetKey gain
asserted. Fields are appended so range-scan order is unchanged. All key
construction goes through per-key constructors. A v7 file is always
re-indexed from its fact pages on open and rewritten with a v8 header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
… barrier (#371, #287)

Round-1 review findings on c74f89d:

- Empty transactions (`(transact [])`, `(retract [])`) allocate a
  tx_count without producing facts, so restoring the tx_counter from
  `max(fact.tx_count)` alone could rewind it below what the v7 file's
  header already recorded. Floor it at the old header's
  `last_checkpointed_tx_count` so migration never lets a new
  transaction reuse a tx_count that `:as-of N` already served facts
  for.
- The rebuild path wrote the header before syncing the freshly built
  index pages. Add the same `backend.sync()` barrier `save()` uses,
  after the B+tree builds and before the header write, so the header
  (the crash-safe commit point) is never durable ahead of the data it
  points to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
#371)

selective_fact_fetch deduplicated on (entity, attribute, tx_count,
asserted), which collapsed two values written in one transaction and left
one half of a batched retract un-cancelled. The key now includes the
encoded value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
… dedup key (#371)

batched_retract_of_both_values_hides_both_on_every_path passes even
against the pre-fix (entity, attribute, tx_count, asserted) dedup key:
retracting exactly the asserted value set is symmetric under that key,
since the smallest-encoded-byte value always wins the collision on both
the assert side and the retract side and correctly cancels itself,
regardless of statement order or whether the retracts are batched or
split across separate transactions (verified empirically, not just
argued).

Add two tests where the retracted value set includes a value that was
never asserted (:k/A, which sorts before :k/a): under the old key this
non-asserted value wins the retract-batch collision and silently drops
the real retraction records for :k/a and :k/b, leaving :k/a visible.
Confirmed RED against a reverted copy of executor.rs and GREEN against
the fix from the prior commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PuKbXn6NaHBMDVkScdBNQZ
…287)

`selective_fact_fetch`'s dedup key (entity, attribute, tx_count, asserted,
value_bytes) had no valid-time component, so two facts differing only in
their per-fact valid-time window — same tx_count (one transact call), same
value bytes, same asserted flag — collapsed into a single arbitrary
survivor in both the entity-bound and attribute-bound loops. A `:valid-at`
query landing inside the dropped window's range returned zero rows instead
of one, and `:any-valid-time` returned one stint instead of two.

Add fact.valid_from/valid_to to the key in both loops and update the doc
comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
…287)

Both targets hardcoded header version 7 with an all-zero page_count, so
`Minigraf::open()` always either failed STG-007 immediately (page_count 0)
or took the version<8 forced-rebuild path, which rebuilds indexes from
fact pages and never reads the fuzzed page at all.

- fact_page.rs: version 8, page_count 2, fact_page_count 1, eavt_root_page
  left at 0 so `load()` still rebuilds from fact pages — opening alone now
  decodes the fuzzed page as a packed fact page (postcard `Fact`).
- btree_page.rs: version 8, page_count 3, eavt_root_page pointing at the
  fuzzed page so `load()` wires it up as the EAVT B+tree root instead of
  rebuilding. `open()` only wires the reader lazily and never reads the
  root page itself, so the target now also runs a minimal entity-bound
  query to force an EAVT range scan, which decodes the fuzzed page as a
  v8 B+tree leaf/internal node (with value bytes + asserted flag).

fuzz/Cargo.lock and fuzz/target/ remain untracked/uncommitted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
…ure (#371, #287)

The example's doc header said "do not regenerate" the v7 compat.graph
fixture, but the code still wrote straight to tests/fixtures/compat.graph
and renamed over it — running the example silently replaced the frozen v7
file with a v8 one, invalidating the v7->v8 migration coverage.

Write to target/generate_compat_fixture/compat_generated.graph instead,
and fix the doc header to describe the new behavior. Also assert in
fixture_readable_by_native (cross_platform_compat_test.rs) that the
embedded fixture bytes are version 7 before opening, so a future
regeneration-in-place is caught by the test instead of silently changing
what's being covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
)

unsupported_version_fails_loudly checked that opening a v99 file errors
with STG-006, but not that Minigraf::open() left the file alone on that
error path (spec Testing 5). Compare the file bytes before and after the
rejected open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
#287)

near_max_size_values_checkpoint_and_read_back leaves ~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 so
tests can't compute it directly). Add a case that locates the exact edge
at runtime via a binary search over transact attempts, then
checkpoints/reads back two values of that exact length through EAVT, AEVT
and AVET, and confirms one byte past the boundary is rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
The field doc still listed 0x00/0x01 as if they were live decode options.
Only 0x02 (packed, FACT_PAGE_FORMAT_PACKED) is ever written or read;
v1-v6 files (the only ones that could carry 0x00/0x01) are rejected
outright by STG-028, not migrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
…ty (#371, #287)

The 7-tuple dedup key added for the valid_from/valid_to fix trips
clippy::type_complexity under the CI gate (cargo clippy --all-features
-- -D warnings). Factor it into a local type alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
@adityamukho adityamukho added this to the v3.0.0 milestone Sep 25, 2026
@codecov
codecov Bot commented Sep 25, 2026
Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.84536% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/storage/index.rs 92.18% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

…g them (#287)

Error codes are a stable public contract and are never removed or
reused. STG-003 and STG-004 can no longer be emitted (pre-v7 headers are
rejected with STG-028 before any size check), so they stay in ErrorCode,
REGISTRY and ERROR_REFERENCE.md, marked deprecated in v3.0.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
@adityamukho
adityamukho merged commit b8a9762 into main Sep 25, 2026
16 checks passed
@adityamu
8BD0
kho
adityamukho deleted the fix/371-index-value-collision branch September 25, 2026 04:50
adityamukho added a commit that referenced this pull request Sep 25, 2026
Main must stay releasable as v2.x so v2.1–v2.3 (and #370's unreleased
fix) can ship without a forced one-way format migration. The v8 work
(#371, #287) is preserved unchanged on the long-lived `v3` branch
(at b8a9762), which will merge main regularly and becomes main when
v3.0.0 is cut.

This reverts commit b8a9762.


Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

0