[db] Frame snapshot storage streams into per-leaf groups - #2464
Draft
maxkozlovsky wants to merge 1 commit into
Draft
[db] Frame snapshot storage streams into per-leaf groups#2464maxkozlovsky wants to merge 1 commit into
maxkozlovsky wants to merge 1 commit into
Conversation
maxkozlovsky
marked this pull request as draft
July 28, 2026 19:36
maxkozlovsky
force-pushed
the
max/snapshot-stream-versioning
branch
from
July 28, 2026 20:09
260a8c8 to
d21a73f
Compare
maxkozlovsky
force-pushed
the
max/snapshot-page-groups
branch
from
July 28, 2026 20:09
e18617e to
95ab774
Compare
maxkozlovsky
force-pushed
the
max/snapshot-stream-versioning
branch
from
July 28, 2026 22:15
d21a73f to
7da7e92
Compare
maxkozlovsky
force-pushed
the
max/snapshot-page-groups
branch
from
July 28, 2026 22:16
95ab774 to
898fa9d
Compare
maxkozlovsky
force-pushed
the
max/snapshot-stream-versioning
branch
from
July 31, 2026 18:49
7da7e92 to
d2867f4
Compare
maxkozlovsky
force-pushed
the
max/snapshot-page-groups
branch
from
July 31, 2026 18:50
898fa9d to
eba389e
Compare
maxkozlovsky
force-pushed
the
max/snapshot-stream-versioning
branch
from
July 31, 2026 21:06
d2867f4 to
2b56a9c
Compare
maxkozlovsky
force-pushed
the
max/snapshot-page-groups
branch
from
July 31, 2026 21:06
eba389e to
08b21ee
Compare
A page-encoded target had to buffer a whole shard before writing any of it: the slots of one page arrive spread over an out-of-order stream, and upsert is set-not-merge, so a page written twice loses the slots of the earlier write. The dump already emitted each source leaf's slots contiguously, but nothing in the artifact said so, and the loader could not rely on it. Frame the storage stream as groups, one per source leaf, and spend the stream header's reserved byte on the source's page-key shift. When a group covers a whole target page, the loader assembles the page as the group ends and may flush at any group boundary, so peak memory follows the flush threshold rather than the shard size. That threshold moves from 10 GiB to 1 GiB and gains a setter: with mid-shard flushing gated away it had become unreachable within a shard. Because a storage record grows a length, the format version goes to 2. Both older layouts still load: a version 1 stream, dumped once headers existed but before grouping did, and a stream with no header at all each hold one slot per record, which the loader reads as groups of a single slot and takes the whole-shard path for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maxkozlovsky
force-pushed
the
max/snapshot-stream-versioning
branch
from
July 31, 2026 22:17
2b56a9c to
c9b11fa
Compare
maxkozlovsky
force-pushed
the
max/snapshot-page-groups
branch
from
July 31, 2026 22:17
08b21ee to
85ca1e5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Restoring into a page-encoded target buffers a whole shard before writing any of it. A page is 128 raw-adjacent slots in one trie leaf, the dump is out of order, and
upsertis set-not-merge — writing a page twice drops the earlier slots instead of merging them. With no way to know a page is complete, the loader defers everything to one flush per shard, so peak memory scales with the largest shard. Sharding splits on the account-hash prefix, so a whale contract is atomic to one shard however the divider is tuned: at block 90045827 the largest shard is 740 MB against a 43 MB mean.What this does
Page contiguity already existed in the stream — a page-encoded source expands one page leaf into slots inside a single
down()on a single-threaded traverse, so each page's slots were already one contiguous run. What was missing was not bytes but a promise that a run is closed. So frame the storage stream as groups, one per source leaf, and spend the header'sreservedbyte on the source's page-key shift:payloadis the concatenatedencode_storage_dbentries of one source leaf, so the per-slot account-offset prefix collapses to one per group. The load-side predicate is one line:Every target page then lies entirely inside one closed group, so the loader assembles a page when its group ends and may flush at any group boundary. Peak memory follows the flush threshold rather than the shard size, and the single-account floor disappears.
A slot-encoded source reports shift 0, which no target page fits inside, so slot-source × page-target falls back to the whole-shard accumulator with no separate branch. The threshold moves 10 GiB → 1 GiB and gains a setter; a typical shard is far below it, so the common path still does one upsert per shard.
Cost
Framing is not free on sparse storage: where a group holds one slot a 12-byte group header replaces an 8-byte per-slot prefix, and most mainnet pages hold exactly one slot. At block 90045827 the storage stream grows 2.8% (8.63 → 8.87 GiB) and the whole snapshot 2.3% (10.42 → 10.67 GiB).
Compatibility
Storage streams written before this keep loading: they report shift 0 and are read as single-slot groups, taking the previous whole-shard path.
Testing
PageGroupedStorageStreamasserts the invariant the loader depends on — one page key per group, ascending slot keys, each page in exactly one group.PageToPageRestoreIndependentOfFlushThresholdcovers page→page, previously untested altogether, restoring both at the default threshold and flushing after every group; agreeing on the source root is what makes the threshold a pure memory knob.HeaderlessSnapshotRestoresandDumpFromSecondaryPageDbgain an unframed and a one-byte-threshold slot target, giving all six source × target × framing combinations.End to end against an official mainnet snapshot, block 90045827, 256 shards:
mainproduces, and into a slot target reproducing exactly thestate_rootin the snapshot's own block header.mainbinary it aborts in about a second, leaving the target database untouched.Follow-ups, not in scope
The threshold is not wired to a CLI flag, so the bound it enables is exercised by the unit test rather than from the command line, and an unframed or slot-source stream into a page target still buffers a shard. An in-order dump would compose well, making each flush epoch contiguous in key space so a small threshold stops costing scattered re-merklization.
🤖 Generated with Claude Code