Repository navigation
feat(storage): count state cache hits and misses - #659
MegaRedHand wants to merge 7 commits into
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.
Nothing reported whether a state lookup was served from the 32-entry LRU, so
slow epoch-boundary head updates could only be blamed on a checkpoint-state
miss by inference, and a has_state that fell through to RocksDB was invisible.
lean_state_cache_lookups_total{method, kind, result} counts every lookup.
`method` separates get_state, has_state and cached_state, because gossip
validation's cached_state calls (one per attestation and aggregate) would
otherwise drown out the import path's misses. `kind` separates block states
from checkpoint states, which is the miss the epoch-tick head recompute pays.
has_state now peeks the cache before pending_states, the same order read_state
uses. A state in flight is in both, so no answer changes, but with pending
first a just-imported parent never reached the cache check and its lookup went
uncounted.
🤖 Kimi Code ReviewI'll review this PR which adds metrics for state cache lookups in the storage layer. Let me analyze each file.
|
| Item | Severity | Description |
|---|---|---|
| 1 | Minor | has_state comment at line 2697-2699 slightly mischaracterizes the ordering change — it says "same order as read_state" but read_state was already cache-first; the change is making has_state match read_state, not noting they were already the same. The comment could be clearer: "Now cache-first, matching read_state's order, so every lookup is instrumented." |
| 2 | Info | Consider whether cache_get should return Option<Arc<BeaconState>> or be split into cache_get and cache_peek variants to avoid the cloned() overhead when the caller only needs existence (but has_state correctly uses direct peek for this). |
No security vulnerabilities or consensus-critical bugs found. The metrics addition is purely additive (no behavior changes except LRU ordering in has_state, which is harmless due to the "in both" property). The peek vs get semantics are correctly maintained.
The code is well-structured, properly documented, and follows Rust best practices. The metrics design with per-method labeling is appropriate for the differing call rates and operational significance.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Claude Code ReviewReview of PR 659: count state cache hits and misses The PR looks good and I found nothing blocking. It adds one I reviewed the diff only and did not build it or run tests. Points worth a look:
Docs and metric naming follow the project conventions ( Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewLooks good overall. This PR is observability-only: it adds cache-hit/miss metrics without changing fork choice, attestation processing, state transition, XMSS, or SSZ logic, and I don’t see a consensus-safety regression in the touched paths.
One minor performance/readability note:
So: brief ack from my side — the patch looks sound. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
…n-state-cache-metrics
Stacked on #658:
has_stateonly consults the state cache from that PR on.Motivation
Nothing reports whether a state lookup was served from
Store's 32-entry state LRU. Two slow paths seen on the followers can only be blamed on cache misses by inference today:checkpoint_state(E-1, R)likely misses and rebuilds the state on the chain actorhas_statethat misses the cache falls through to RocksDBChange
One counter,
lean_state_cache_lookups_total{method, kind, result}:methodget_state,has_state,cached_statecached_stateonce per attestation and aggregate; summed, it would bury the import path's misseskindblock,checkpointresulthit,missWhere each lookup is counted:
methodread_state(behindget_state, and the writer thread's lean parent read)get_stategetStore::cached_state(fork choice'scheckpoint_state, gossip validation, head state)cached_stategetStore::has_statehas_statepeekread_stateandcached_stateshare a newstate_writer::cache_gethelper, so the counting lives in one place.rebase_onto_resident's peek is not counted: it picks a base to share memory with, not the state being asked for.Behavior change:
has_statechecks the cache beforepending_statesThis is the same order
read_stateuses. A state in flight is in both, so no answer changes. Withpending_statesfirst, a just-imported parent was answered before the cache check and its lookup went uncounted.Testing
cargo test -p ethlambda-storage --profile release-fast --lib: 146 passedcargo clippy -p ethlambda-storage --all-targets -- -D warnings: cleancached_statehit (block),cached_statemiss (checkpoint),has_statehit/miss andget_statehit/miss increments exactly its own series once. Not committed because the counter lives in the global registry, which every test in the crate shares, so an exact-count assertion would be racy under parallel tests.