Skip to content

[db] Frame snapshot storage streams into per-leaf groups - #2464

Draft
maxkozlovsky wants to merge 1 commit into
max/snapshot-stream-versioningfrom
max/snapshot-page-groups
Draft

[db] Frame snapshot storage streams into per-leaf groups#2464
maxkozlovsky wants to merge 1 commit into
max/snapshot-stream-versioningfrom
max/snapshot-page-groups

Conversation

@maxkozlovsky

@maxkozlovsky maxkozlovsky commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2463 — review that first. This PR's base is max/snapshot-stream-versioning, so its diff shows only the grouping change.

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 upsert is 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's reserved byte on the source's page-key shift:

storage := monad_snapshot_stream_header group*
group   := [account_offset: uint64][payload_len: uint32][payload]

payload is the concatenated encode_storage_db entries of one source leaf, so the per-slot account-offset prefix collapses to one per group. The load-side predicate is one line:

group_key_shift >= storage_page_t::PAGE_KEY_SHIFT

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

PageGroupedStorageStream asserts the invariant the loader depends on — one page key per group, ascending slot keys, each page in exactly one group. PageToPageRestoreIndependentOfFlushThreshold covers 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. HeaderlessSnapshotRestores and DumpFromSecondaryPageDb gain 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:

  • Framed page→page. Same state root as the unframed path, peak RSS 9.93 → 7.84 GB. The saving is the whole-shard accumulator going away; the threshold never engages, the largest shard being under 1 GiB.
  • Pre-framing snapshot. Today's headerless layout restores into a page target at the root main produces, and into a slot target reproducing exactly the state_root in the snapshot's own block header.
  • New snapshot, older code. Handed to a main binary 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

@maxkozlovsky
maxkozlovsky marked this pull request as draft July 28, 2026 19:36
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-stream-versioning branch from 260a8c8 to d21a73f Compare July 28, 2026 20:09
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-page-groups branch from e18617e to 95ab774 Compare July 28, 2026 20:09
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-stream-versioning branch from d21a73f to 7da7e92 Compare July 28, 2026 22:15
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-page-groups branch from 95ab774 to 898fa9d Compare July 28, 2026 22:16
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-stream-versioning branch from 7da7e92 to d2867f4 Compare July 31, 2026 18:49
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-page-groups branch from 898fa9d to eba389e Compare July 31, 2026 18:50
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-stream-versioning branch from d2867f4 to 2b56a9c Compare July 31, 2026 21:06
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-page-groups branch from eba389e to 08b21ee Compare July 31, 2026 21:06
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
maxkozlovsky force-pushed the max/snapshot-stream-versioning branch from 2b56a9c to c9b11fa Compare July 31, 2026 22:17
@maxkozlovsky
maxkozlovsky force-pushed the max/snapshot-page-groups branch from 08b21ee to 85ca1e5 Compare July 31, 2026 22:17
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