Repository navigation
perf(storage): borrow values on read, make has_state skip snapshot reads, keep states in blob files - #658
Conversation
…hot read On a Hoodi beacon follower the `guards` import phase was 0-1 ms on most slots but median 180 ms (p90 332 ms, max 536 ms) on the block after each epoch's first block. `has_state(parent_root)` read both `States` and `StateDiffs` through `get`, and the epoch-crossing block's state is stored as a full snapshot only (no diff), so RocksDB copied the whole ~150 MB state into a Vec just to test that it exists. On networks with smaller states the same call still costs ~30 ms per block because `States` has no bloom filter, so even a miss is expensive. Three changes, same truth value as before: - Consult the state cache (non-promoting `peek`) after `pending_states`. A cached BlockState is only ever inserted by `insert_state` (also in `pending_states` until committed) or by `read_state` after a backend read, and no path deletes persisted states, so a cache hit implies the state exists. - Check `StateDiffs` before `States`: every non-anchor root has a diff. - Add `StorageReadView::contains`, implemented with `get_pinned_cf` on RocksDB and `contains_key` in memory, so no value is materialized. Tests use a counting backend to show the cached path touches neither table, the diff path skips `States`, and no value read happens.
`get` hands back an owned `Vec`, so every lookup copies the whole value before the caller decodes it, and a beacon state snapshot is 100+ MB. RocksDB can lend its own buffer (`get_pinned_cf`), but a generic closure parameter would make `StorageReadView` unusable as a trait object, and every caller holds it as `Box<dyn StorageReadView>`. `read` takes the callback as `&mut dyn FnMut(&[u8])`, which keeps the trait dyn-compatible. Both backends implement only `read`; `get` and `contains` become default methods on top of it, so a new backend has one lookup to get right instead of three that must agree.
Every decode site fetched an owned `Vec` with `get` and dropped it right after decoding, so each read paid a value-sized allocation and copy first. For a beacon state snapshot that is 100+ MB per cold read. `StorageReadViewExt::read_with` decodes inside `read`'s borrow, and the decode sites use it. Beacon state reconstruction folds its deltas while the snapshot is still borrowed and hands the result over as a `Cow`: a snapshot root decodes straight from RocksDB's buffer, and the writer's parent-bytes path still takes ownership without a second copy. Existence checks written as `get(..).is_some()` become `contains`. `get` stays where the bytes outlive the read: the beacon walk's delta records, the pending-column take, and the stored config, which must not be decoded before the version and preset checks pass.
A beacon snapshot is 100+ MB and lives inline in its SST, so it is its own data block. Every compaction touching that file rewrites it, and with no bloom filter a lookup that misses `States` still reads the data block around the key. On a Plataberget follower that put ~30 ms of `has_state` misses on every block import, one snapshot-sized read per L0 file. `States` and `StateDiffs` now store values of 4 KiB and up (one default data block) in blob files, LZ4-compressed since blob files otherwise default to none while SST blocks get Snappy. Their SSTs shrink to keys and blob references. No blob GC, since nothing deletes or overwrites a state, and no blob cache, since a snapshot-sized entry would evict the whole block cache while the store already caches decoded states. RocksDB applies this to an existing directory as it goes, so a directory written without blob files opens unchanged and no `DB_VERSION` bump is needed; inline values move out as compaction rewrites them. The table-size estimate now counts live blob bytes, which `estimate-live-data-size` leaves out.
🤖 Claude Code ReviewReview: RocksDB blob files for
|
🤖 Kimi Code ReviewI'll review this PR which introduces zero-copy reads and RocksDB blob file optimization for large state values. SummaryThis is a well-architected performance PR that:
Issues Found1. Panic Safety in
|
| Severity | Count | Items |
|---|---|---|
| Must Fix | 1 | Verify rocksdb crate has lz4 feature enabled (Item 3) |
| Should Fix | 1 | Defensive handling for read_fn multi-call in read_with (Item 1) |
| Nice to Have | 2 | Documentation clarity on blob threshold; fold_beacon_state_deltas temp allocation audit |
| Informational | 4 | Remaining items |
The PR is well-architected, correctly implements zero-copy reads, and the RocksDB blob optimization is appropriate for consensus state storage. The main action item is confirming the lz4 feature flag.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewLooks good overall. The PR’s direction is solid:
One thing I would change:
Aside from that, I don’t see a security or memory-safety issue in the diff. The tests added around cache non-promotion and diff-first existence checks are especially helpful ( Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
…63-64-633-636-638-gloas-live Conflicts were import lists only (`ActiveBalanceCache` here, `StorageReadViewExt` there). The fork-choice test's counting backend, which exists only on this branch, now counts block-table lookups at `read`, since block decodes no longer go through `get`.
…63-64-633-636-638-gloas-live
…36-638-gloas-live Brings gloas validator duties (produceBlockV4, envelope publication, PTC duties and payload attestations, gloas attestation data and aggregates, VC gloas support) onto the deployment branch, keeping every behavior of #626, #633, #636, #638, #646, #647-#652, #656, #658-#660 and the sync-committee and liveness endpoints. Conflict resolutions keep both sides: the attestation pool stays in Store (tmp) while the payload attestation pool is threaded through P2P and the RPC handles (feature); the aggregate endpoints keep tmp's liveness recording and attesting indices and add the feature's fork-header check and gloas pooling; the VC tests and fake execution client serve both fulu blobs and gloas V6. Semantic fixes: a. POST /eth/v2/beacon/blocks (gloas) calls publish_beacon_block(block, Vec::new()): gloas columns travel with the envelope. RecordingNetwork implements publish_beacon_block(block, sidecars) and both new methods. b. produceBlockV4 appends client versions to the graffiti exactly like produceBlockV3 (graffiti::execution_client_version run alongside the payload build, with_client_versions, Extension<OwnVersion>) and logs it. c. Attestation data, aggregate_attestation keep require_execution_client and require_validated for gloas slots; payload_attestation_data now applies the same two rules (503 without an execution client, or when the voted block's payload is unvalidated). d. Proposer duties v1 and v2 serve gloas epochs from a fulu or gloas state's proposer_lookahead; nothing refuses gloas any more; v2 keeps its dependent root. e. The VC's per-validator ProposerSettings apply to gloas proposals (graffiti in the BlockRequest, fee recipient compared with the bid's); a test pins the graffiti. VC tests updated to the ProposerSettings constructors. f. gloas production reads the attestation pool from Store and calls the stf with the ActiveBalanceCache the perf work added; fulu production and pack_operations are untouched (gloas blocks carry no pooled operations). g. Chain events are emitted by the chain actor only, so nothing on the RPC publish paths needed to move; gloas imports reach it unchanged. h. Cargo.lock unchanged; cargo check --locked passes.
…3-64-633-636-638-gloas-live The branch is cut from beacon-chain-integration 17af7c3, so it also brings bci's squash of #658. tmp already holds that content through its own #658 merges, so the storage conflicts take tmp's side and the merge changes nothing there. Beyond the conflict markers: - gloas's is_valid_indexed_attestation gets the same _with_domain split, and the gloas arm of gossip::aggregate::stateful_checks uses it, so a gloas aggregate for a pre-gloas block verifies too. - pool.rs keeps tmp's test scaffolding (fixture_at, attestation_for). Its signing helpers sign under the schedule, and the fork-boundary tests run fulu to gloas, the transition the devnet failure was seen on. - lib.rs keeps tmp's beacon_store_with_config, which has the same signature as the branch's.
Motivation
On the Hoodi follower, the import guard's
has_state(parent)check costs 0-1 ms on most blocks but p50 180 ms, p90 332 ms, max 536 ms on the block after each epoch's first block (48h of import timings, 10-03 to 10-05):guardsp50guardsp90An epoch-crossing block's state is stored as a full snapshot only, and
has_statedidget(States)on it: RocksDB read and copied the whole ~150 MB state to answer a yes/no question, although that state was still in the store's state cache. On Plataberget the same function costs ~30 ms on every block, sinceStateshas no bloom filter and a miss still reads a snapshot-sized data block per L0 file.Changes
has_stateis an existence check. It consults the state cache (peek, so the LRU order is untouched), thenStateDiffsbeforeStates, throughcontains, and never copies a value. A cachedBlockStatealways means the state is pending or persisted, and nothing deletes a persisted state, so the answer is unchanged.StorageReadView::readis the one required lookup. It lends the value to a&mut dyn FnMut(&[u8])callback, which keeps the trait usable asdyn StorageReadView.getandcontainsare default methods on top of it, so both backends implementreadonly. RocksDB usesget_pinned_cf.StorageReadViewExt::read_withdecodes inside the borrow, and every get-then-decode site uses it. Beacon state reconstruction folds its deltas while the snapshot is still borrowed and hands the result over as aCow: a snapshot root decodes straight from RocksDB's buffer, and the writer's parent-bytes path takes ownership without a second copy.get(..).is_some()checks becamecontains.getstays where the bytes outlive the read (the beacon walk's delta records, the pending-column take) and for the stored config, which must not be decoded before the version and preset checks.StatesandStateDiffsuse RocksDB blob files.min_blob_sizeCompatibility
No
DB_VERSIONbump. RocksDB applies the blob options to an existing directory as it goes: new writes land in blob files, and inline values move out as compaction rewrites their SSTs.a_directory_written_without_blob_files_still_readsopens a directory written with plain options and reads both old inline and new blob values.estimate_table_bytesnow addsrocksdb.live-blob-file-size, whichestimate-live-data-sizeleaves out, so the table-size metric keeps counting state bytes.Testing
has_stateon cached, diff, snapshot-only and absent roots, through a counting backend that proves the cached path reads neither table;readon both backends (value, absent key, callback error); a cold-cache beacon reconstruction across a whole snapshot interval; blob placement and the pre-blob directory.cargo test --workspace --profile release-fast --lib --bins: 0 failures. Clippy-D warningsclean.Not covered
States: blob files make misses cheap here, but other large-value tables still have none.guards: worth re-measuring on a follower after deploy.