8000 perf: checkpoint copies untouched index leaves instead of rebuilding every index (#315) by adityamukho · Pull Request #402 · project-minigraf/minigraf · GitHub
[go: up one dir, main page]

Skip to content

perf: checkpoint copies untouched index leaves instead of rebuilding every index (#315) - #402

Merged
adityamukho merged 10 commits into
mainfrom
perf/315-incremental-checkpoint
Sep 26, 2026
Merged

adityamukho merged 10 commits into
mainfrom
perf/315-incremental-checkpoint

Conversation

@adityamukho
Copy link
Copy Markdown
Collaborator

Summary

Every checkpoint() used to stream all four covering indexes off disk, decode every entry, re-serialise everything and bulk-build four new B+trees. It then re-read the whole file for the checksum. So checkpointing after one new fact cost almost as much as after thousands, and the cost grew with graph size. Profiling showed the time was CPU spent decoding and re-encoding entries, not I/O.

  • Incremental rebuild. save() snapshots each old tree's raw leaf pages, then calls rebuild_btree_incremental (src/storage/btree_v6.rs):
    • Leaves that no new entry lands in are copied verbatim; only the 8-byte next_leaf pointer is patched.
    • Leaves that receive new entries are decoded, merged and re-split. Internal levels are rebuilt through a helper extracted from build_btree.
  • Balanced splits. A touched leaf stays one page while it fits. When it overflows, it splits into size-balanced pages. The bulk-build "fill to 75%, then start a new leaf" rule left a near-empty leaf on every single-fact checkpoint that hit a full leaf: 43 leaves grew to 596 over 600 checkpoints.
  • Checksum. PersistentFactStorage caches the CRC32 state over the fact pages, which are never rewritten. Each save now hashes only the new fact pages and the index region. The stored index_checksum value is unchanged: a CRC over pages 1..page_count.
  • Corruption guard. Untouched leaves are no longer decoded, so collect_leaf_pages bounds-checks each leaf's slot directory and caps the leaf chain at the file's page count. A damaged index now fails the checkpoint instead of being copied forward under a fresh checksum.
  • Docs. The checkpoint() and wal_checkpoint_threshold docs now say that a checkpoint is not a durability boundary (the WAL is already crash-durable) and that its cost grows with graph size. The outdated "same-process double-open" rationale in do_checkpoint is trimmed, as the issue's aside asked.

The file format is unchanged: v7 node and header layouts are the same, and files written by this branch open on v2.0.1 without the index-rebuild path.

Mitigates #315. Checkpoint cost still grows with graph size, because index pages are copied on every checkpoint; the new fact pages always land on the start of the old index region. Checkpoints proportional to the change alone need copy-on-write pages, which is the same change #374 needs on the v3 branch.

Measurements

cargo bench --bench minigraf_bench -- checkpoint/after_1_fact (new group): an already-checkpointed file receives one new fact, then checkpoint() runs. tmpfs, same host.

Facts before (v2.0.1) after
10k 23.9 ms 4.01 ms
100k 246 ms 26.3 ms

Separate harness, 50k facts (13 MB): 80 ms → 10.1 ms. Cost after 3000 consecutive single-fact checkpoints on 10k facts: 3.1 ms. Before the balanced-split fix it was 28.7 ms, from fragmentation.

Type of change

  • Bug fix (non-breaking) — performance
  • New feature (non-breaking)
  • Breaking change (requires migration or version bump)
  • Documentation / tests only

Checklist

  • cargo test passes (1182 passed, 0 failed, 8 ignored)
  • cargo clippy -- -D warnings is clean
  • cargo fmt --check passes
  • New code has tests (unit and/or integration)
  • Error paths are tested, not just happy paths: a cyclic leaf chain, a damaged slot directory, a stale checksum-prefix cache, and a rebuild on open
  • No unwrap() / expect() in library code paths
  • CHANGELOG.md updated under Unreleased
  • I have read PHILOSOPHY.md and this change aligns with it

Tests

  • btree_v6 (10 new):
    • Randomized comparison of the incremental rebuild against a full merge: streamed entries, an unbroken leaf chain, and range_scan on random bounds.
    • Edge cases: no new entries (leaves byte-identical except next_leaf), keys outside the old range, thousands of entries routed into one leaf, an empty old tree, and a new tree written over the old tree's pages.
    • 30 repeated rounds on an incrementally built tree.
    • A fragmentation bound for single-fact checkpoints.
    • Rejection of a cyclic chain and of a damaged slot directory.
  • persistent_facts (4 new):
    • After 25 checkpoints, all four indexes equal a from-scratch derivation from the fact pages, and the stored checksum equals a full pass.
    • Reopening after incremental saves takes no rebuild.
    • A stale prefix cache falls back to a full pass.
    • After a rebuild on open the cache is empty and the next save is still exact.

Notes for reviewer

🤖 Generated with Claude Code

https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy

adityamukho and others added 9 commits September 26, 2026 10:27
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BedF9ZmqVZLkvDwn91fuKy
…act pages (#315)

save() snapshots each index's raw leaf pages and rebuilds via
rebuild_btree_incremental: leaves no pending entry touches are copied
verbatim, touched leaves split balanced so repeated checkpoints do not
fragment the tree. The CRC32 state over unchanged fact pages is cached
so each save hashes only new fact pages and the index region. File
format and stored checksum are unchanged.

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

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

Untouched leaves are now copied verbatim and sealed under a fresh file
checksum, so collect_leaf_pages bounds-checks each leaf's slot directory
and caps the chain at the file's page count instead of looping.

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

Codecov Report

❌ Patch coverage is 88.94737% with 21 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/storage/btree_v6.rs 90.15% 13 Missing ⚠️
src/storage/persistent_facts.rs 86.20% 8 Missing ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0