Skip to content

perf(storage): share one rebuild between concurrent get_state misses on a root - #788

Open
MegaRedHand wants to merge 7 commits into
mainfrom
perf/share-state-rebuilds
Open

MegaRedHand wants to merge 7 commits into
mainfrom
perf/share-state-rebuilds

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

The Hoodi beacon follower on ethlambda-3 (nightly b1fc143) has short resident-memory spikes, roughly one a day: +1 to +4 GiB that are gone within 30 s, against a ~3.3 GiB baseline (peak RSS since start: 9.09 GiB). All eight spikes between 11-03 and 11-10 show the same pattern in one 30 s scrape window:

Spike (UTC) RSS sampled get_state misses columns queued "parent not ready" column-check run time
11-03 11:31 4.18 GiB 11 8 12.2 s
11-04 23:24 4.40 GiB 10 8 12.8 s
11-05 03:27 4.11 GiB 11 8 12.0 s
11-05 14:15 7.19 GiB 9 8 9.4 s
11-06 17:55 4.35 GiB 8 8 8.1 s
11-08 03:56 4.44 GiB 8 8 6.7 s
11-10 11:24 4.23 GiB 8 8 7.8 s
11-10 15:58 4.25 GiB 7 7 6.0 s

Late blocks whose columns did not queue had 0–1 misses and no spike.

How one late block turns into eight state rebuilds:

  1. A block arrives minutes late (on Hoodi, usually a fork block), and its parent's post-state has already left the 32-entry state cache.
  2. Gossip validation of its 8 custody columns finds no cached parent state, so each sidecar is verdicted Queue(ParentNotReady).
  3. verdict.rs hands each queued sidecar to column_checks::check_and_forward(server, vec![*sidecar]) on its own. That makes one group per sidecar, and up to COLUMN_CHECK_PERMITS (16) groups run at once.
  4. Each group's chain_precheck_with calls store.get_state(parent_root). Every call misses, and read_state rebuilds the state independently each time.

A beacon rebuild reads a 100+ MB snapshot, folds a delta into a second full-size buffer and decodes a fresh registry. That's about a second of CPU and several hundred MB at peak, paid 8 times at once for the same state.

The fix is in read_state rather than in the column path, so it covers every caller that can miss on the same root concurrently: p2p's column checks, the chain actor, the RPC and the writer thread.

What Changed

  • crates/storage/src/state_writer.rs
    • New StateRebuilds: one OnceLock per root under way (Mutex<HashMap<H256, Arc<OnceLock<…>>>>).
      • The first miss runs the rebuild inside get_or_init. Concurrent misses on that root block in get_or_init and take the same Arc.
      • The entry is removed once the rebuild settles, so the map never pins a state. A pointer check keeps a late caller from removing a newer rebuild's entry.
    • read_state takes the registry. Under the lead it re-checks the cache and the write buffer before reading, so a miss that arrives just after a rebuild finished finds that rebuild's state instead of reading it again.
    • The backend half of read_state moves into rebuild_state.
    • StateWriter holds the registry too, so the writer thread's lean parent read shares rebuilds with readers.
  • A rebuild failure is now an Err instead of a panic (state_writer.rs, state_codec.rs, beacon_state_delta.rs, store.rs, error.rs).
    • A failed read view, BlockHeaders slot read, snapshot read or diff read is Error::Storage.
    • A value that does not decode is the new Error::CorruptState { root, reason }. That covers a beacon snapshot, a delta that does not apply to its base (beacon_state_delta::try_decode), and a lean snapshot or diff. root is the state being read, so a corrupt snapshot is reported against every state read through it.
    • get_state returns every such error wrapped in Error::StateRebuild, for the caller that ran the rebuild as well as the ones that waited on it.
    • Still panics, as before: a diff frame shorter than its 48-byte header (unframe, never written by a store), and a BlockHeaders row that does not decode, on both chains (the slot lookup decodes it before any state row is read).
  • crates/blockchain/state_transition/src/beacon/gossip/column.rs: chain_checks used to park a sidecar on any non-Ok(Some) parent read. Now Ok(None) parks it as before, and Err logs at error and drops the sidecar as Ignore(Internal): a failed read or a corrupt row is not a parent still to come, so parking would wait for an import that need never happen. IgnoreReason::Internal's doc now covers a failed store read.
  • crates/storage/src/store.rs: a state_rebuilds field, wired in from_parts and passed by get_state.
  • crates/storage/src/metrics.rs: new lean_state_rebuilds_total{role="led"|"joined"}. led counts a state actually read back from storage, and joined a miss that waited on another caller's rebuild and took its state. A lookup that finds no state for its root (one header read) or fails counts as neither, so a column parked on an unimported parent does not look like a rebuild.
  • docs/metrics.md, docs/data_storage.md: the new metric, the shared-rebuild behavior and the read errors.

Correctness / Behavior Guarantees

  • Same results. A waiter gets the leader's Arc, which is what a cache hit right after the rebuild would have returned. Ok(None) (no state for the root) is shared the same way.
  • Errors are shared; panics are not.
    • A rebuild's Err goes into the cell behind an Arc (Error is not Clone). Every caller returns it as Error::StateRebuild, which displays as the error it wraps. So a failing root is read once, not once per caller.
    • The flip side: one failed read fails every caller waiting on it, so a late block's 8 custody columns are all dropped, not one.
    • The first caller out removes the cell. A miss arriving in the moment before that takes the settled result, error included; any later miss reads storage again.
    • A panic leaves the cell empty, and OnceLock (not poisoned by a panic) lets a blocked caller run its own rebuild, so no waiter is left blocked.
  • Callers now see Err where they used to see a panic. Unchanged in effect: fork choice and the state writer .expect() the result and still panic; the RPC answers 500. Now quiet instead of a panic:
    • beacon_envelope.rs' envelope_signature_holds reads an Err as an invalid signature. An envelope held for its columns is then dropped with an "invalid signature" warning, and re-fetched if children wait on it.
    • Resume: verify_anchor_states forwards the error with ?, and main.rs returns any error other than AnchorStateLost, so a corrupt finalized state now stops startup with that error rather than a panic. It does not fall back to checkpoint sync.
    • Skipped as if the state were missing, silently or with a "missing" log: beacon_columns.rs (the parked column stays parked), the head-event emitter, epoch precompute, operation validation (StateUnavailable), operation-pool pruning, the RPC's proposal.rs:165 and gloas_proposal.rs, lean reaggregation.
  • No new deadlock.
    • The map lock is never held across a rebuild. Only callers of the same root wait, inside get_or_init.
    • A rebuild only reads the backend and never waits on the writer thread. So the writer can safely be a waiter for a lean parent read.
  • Unchanged:
    • Lookup counting. lean_state_cache_lookups_total still counts each miss, so a late block shows 8 get_state misses with 1 led and 7 joined rebuilds.
    • p2p's per-sidecar grouping. It now costs one rebuild plus seven short waits instead of eight rebuilds.
    • Waiting callers still block their thread for the length of the rebuild, as they did before, but without doing the work themselves.

Tests Added / Run

  • New unit tests in state_writer.rs:
    • concurrent_get_state_misses_on_one_root_read_storage_once: 8 concurrent read_state misses on a stored lean state, through a backend that holds each read view until every other caller is waiting. One read view, and every caller gets the same Arc.
    • a_lead_takes_a_state_settled_since_its_miss_without_reading: the leader's re-check finds a state cached, then one in the write buffer, since its miss, and reads nothing.
    • a_caller_leaving_an_old_rebuild_keeps_a_newer_one_registered: a late caller of an earlier rebuild leaving does not remove the current rebuild's entry, so a new miss joins it instead of starting a second.
    • concurrent_shares_of_one_root_run_one_rebuild: the same as the first, on StateRebuilds::share alone.
    • a_leader_that_panics_hands_the_rebuild_to_a_waiter
    • a_leader_error_is_handed_to_its_waiters_and_not_kept: 4 callers, one rebuild, every caller gets the leader's error, and a later miss reads again.
  • store.rs: a_corrupt_beacon_state_reads_back_as_an_error_and_is_not_cached truncates a stored snapshot. Both the snapshot and the delta on it come back as CorruptState, and neither is cached.
  • column.rs: chain_checks_drop_a_sidecar_whose_parent_state_does_not_read_back.
  • Mutation checks, each restored afterwards:
    • read_state calling the rebuild directly instead of through share: the first test fails.
    • The leader's cache and write-buffer re-check removed: the re-check test fails.
    • The entry removal made unconditional (no Arc::ptr_eq guard): the late-caller test fails.
    • With Err parked like Ok(None) again, the column test fails with AwaitParent.
  • Not tested: the metric counts, which are a process-global counter shared with every other test.
  • Ran: make fmt, make lint, cargo test --profile release-fast -p ethlambda-storage -p ethlambda-state-transition -p ethlambda-p2p (217, 632 and 362 passed; p2p as of the previous commit, which this one does not touch), and the ethlambda-blockchain and ethlambda-rpc lib tests (292 and 335 passed, same).

Related Issues / PRs

  • None

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test — storage, state-transition and p2p crates locally; the full test-consensus/test-node halves left to CI

…on a root

A beacon state rebuild reads a 100+ MB snapshot, folds a delta into a
second full-size buffer and decodes a fresh registry: about a second of
CPU and several hundred MB at peak. Concurrent misses on one root each
paid that in full. A late block's custody columns are chain-checked one
sidecar per blocking task, so a parent state that had left the 32-entry
cache was rebuilt eight times at once. On the Hoodi follower that showed
up as +1 to +4 GiB resident-memory spikes.

read_state now routes a miss through StateRebuilds, a per-root registry
shared by the Store and the writer thread. The first miss leads: it
re-checks the cache and the write buffer, rebuilds, caches the result,
then deregisters the root and wakes the waiters, who take the same Arc.
A leader that errors or panics hands its waiters no result; they retry,
one of them as the new leader.

New counter lean_state_rebuilds_total{role="led"|"joined"} tells real
storage reads apart from shared ones.
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No actionable findings. The per-root rebuild coordination, cache recheck, and unwind cleanup look sound. Consensus rules and SSZ decoding behavior are unchanged.

Tests could not run because rustup attempted to write to the read-only /home/runner/.rustup/tmp directory.


Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: PR 788, share concurrent state rebuilds per root

This is a solid single-flight implementation.

What it gets right:

  • Locks are never held across rebuild().
  • The drop guard handles unwinding correctly, and it uses PoisonError::into_inner so a second panic during unwind can't abort.
  • Lead::drop deregisters the root before publishing the outcome. With the reverse order, a newcomer could grab a settled Abandoned flight from the map and spin.
  • The re-check under the lead (state_writer.rs ~L318–327) closes the window between a leader releasing and a late caller arriving.
  • The tests cover the success, panic and error paths, and synchronize deterministically through strong_count.

A few points worth considering:

1. Sharing Built(None) can hand a joiner a stale answer (medium)

state_writer.rs, share() and Lead: a block state is immutable per root, so sharing Some(state) is always correct. None is different: it is time-dependent. Consider this sequence:

  • Caller A leads for R and starts its backend read before R is committed. It will end with Ok(None).
  • The writer commits R and removes it from pending.
  • Caller B now misses the cache and misses pending, joins A, and receives None.

At the moment B was called, R was already in storage. Before this PR, B would have done its own read and found it.

Whether B can actually hit this depends on whether the writer caches the state before it leaves pending. If it does, B's cache check catches it. That invariant isn't stated anywhere, though.

Safer option: publish only Some as Built, and treat an Ok(None) leader result like Abandoned so waiters retry. A None is usually cheap (missing header or broken diff chain), so the retry costs little.

2. Deterministic failures serialize across all waiters (low)

Say the failure is a corrupt diff chain, an Err, or an .expect() panic inside rebuild_state. Every waiter then retries as leader in turn. With eight custody-column tasks, that means eight back-to-back failed rebuilds, and the last caller waits through all of them.

This is a deliberate trade-off and the doc says so. Still, consider sharing the error with waiters that joined this flight while still letting later callers retry. That would require Error: Clone, or wrapping the error in an Arc. At minimum, note the latency amplification in the StateRebuilds doc.

3. Metric accounting is slightly off (low)

share(), waiter branch: Joined is incremented for every Built outcome. That includes two cases where nothing was read from storage:

  • The leader's re-check hit the cache or pending, so no Led was counted.
  • The outcome was Built(None).

As a result, joined can rise with no matching led, which contradicts the guidance in metrics.md ("A burst of joins next to a single lead"). Possible fixes:

  • Carry a flag in Built saying whether the leader actually rebuilt.
  • Or document that joined counts every wait, whatever the leader's path was.

4. Re-entrancy hazard is undocumented (low)

If rebuild_state ever calls read_state or get_state for the same root, it will deadlock. That could happen, for example, if the diff-chain reconstruction were refactored to go through read_state for a base. Each thread would wait on its own flight.

Today rebuild_state reads the backend directly, so this is fine. Add a one-line note on rebuild_state or StateRebuilds::share saying the closure must not re-enter read_state for the same root.

5. Nits

  • share(): the waiter's flight.outcome.lock().unwrap() is inconsistent with the poison tolerance in Drop. It's unreachable in practice, but using unwrap_or_else(PoisonError::into_inner) would make the two consistent.
  • rebuild_state (~L388): the comment "Cached before the lead is released" is load-bearing for the re-check. A debug_assert, or a test where a caller arrives after the release and must not trigger a second backend read, would guard that invariant against refactors.
  • metrics.md: "On every get_state miss that reaches storage" should also mention the writer's parent read. The lookups row already mentions it.

Overall the PR looks good. Item 1 is the only one I'd want addressed or explicitly reasoned about before merge.


Automated review by Claude · claude-opus-5-5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Gemini Code Review

This PR looks great. The singleflight coordination in StateRebuilds is clean and well-tested:

  • Lock ordering between StateRebuilds, Rebuild::outcome, StateCache, and PendingStates is strictly disjoint, avoiding any deadlock risk (including when shared with the background StateWriter).
  • Lead's Drop guard properly handles unwinding and errors via RebuildOutcome::Abandoned and PoisonError::into_inner, ensuring waiters are always woken to retry rather than stranded or poisoned.
  • Re-checking cache and pending after acquiring the lead closes the race window between the initial miss and Lead registration.

LGTM!


Automated review by Gemini · gemini-4-argon · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Grok Code Review

I'll read the rebuild coalescing path and its call sites to check races, lock ordering, and whether failures can strand waiters.


Automated review by Grok (xAI) · grok-4.7 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 DeepSeek Code Review

⚠️ Review failed: DeepSeek API request failed: curl exit code 56


Automated review by DeepSeek

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

No blocking correctness, security, or consensus issues found. The single-flight implementation handles the important races correctly: waiters cannot miss the wakeup, results are cached before deregistration, late arrivals re-check the cache/pending states, and Lead::drop settles the flight during unwinding without risking a double-panic abort. The success, error, and panic tests provide good coverage.

One minor observability issue:

  • lean_state_rebuilds_total{role="led"} counts attempts, not necessarily states “actually read and decoded.” The counter is incremented before rebuild_state in crates/storage/src/state_writer.rs:329-332, so failed rebuilds and rebuilds returning Ok(None) are included. Writer-thread parent reads also increment it, whereas docs/metrics.md:338 describes only get_state misses. Consider changing the documentation at docs/metrics.md:367-374 to “storage rebuild attempts led” and explicitly including writer parent reads. Moving the increment after a successful decode would instead lose failed-attempt visibility, so adjusting the wording is preferable.

Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt

OnceLock::get_or_init already blocks concurrent callers until one
initializer finishes and hands them all its value, and a panicking
initializer leaves the cell for a blocked caller to fill. That replaces
the Condvar, the outcome enum, the drop guard and the retry loop.

The rebuild path has no Err return today (every failure is Ok(None) or
an .expect() panic), so the cell stores Err(()) for an error instead of
sharing a non-Clone Error, and a caller that finds it reads the state
itself.
A state rebuild that could not read or decode what it found used to panic
through an `.expect()`. It now returns `Error::Storage` for a failed read and
`Error::CorruptState` for a snapshot, delta, lean diff or header that does not
decode, so a caller can give up on one request instead of unwinding.

`StateRebuilds` hands that error to every caller waiting on the rebuild, as
`Error::StateRebuild`, so a failing root is read once rather than once per
caller. Neither a state nor an error outlives the rebuild: the next miss
reads storage again.

The fulu column check used to treat an `Err` like a missing parent and park
the sidecar. It now logs the error and drops the sidecar as
`Ignore(Internal)`, since parking would wait on an import that has already
happened.
…rough read_state

`role="led"` was counted before the rebuild ran, so a lookup for a root with
no state (one header read) and a failed read both counted as a 100+ MB
rebuild. A parked column's parent lookups then produced the same "1 led + N
joined" shape as a real late-block rebuild. Now `led` counts only a state read
back from storage, and `joined` only a waiter that took one.

The lean header read at the end of `reconstruct_state` mapped a decode
failure to `CorruptState`, but the walk's slot lookup decodes that same row
first and panics, so the mapping could never run. It is an `expect` again,
and the docs no longer list a lean header among the rows that return an
error. The docs also said only waiting callers get `Error::StateRebuild`;
every caller does.

New tests: eight concurrent `read_state` misses behind a gated backend open
one read view; the leader's cache and write-buffer re-check reads nothing;
and a caller leaving an old rebuild does not remove a newer rebuild's entry.
Each fails when the code it covers is removed.
MegaRedHand added a commit that referenced this pull request Nov 11, 2026
…on a root (#788)

A late block whose parent state had left the 32-entry state cache had each
of its custody columns checked on its own blocking task, and every one of
them rebuilt that parent state from storage in parallel: a 100+ MB snapshot
read, a delta fold and a registry decode each, about a second of CPU and
several hundred MB at peak. On Hoodi that showed up as +1 to +4 GiB RSS
spikes lasting under 30 s.

- `StateRebuilds` keeps one `OnceLock` per root under way. The first miss
  runs the rebuild; every other miss on that root waits in `get_or_init` and
  takes the same `Arc`, or the same error. The first caller out removes the
  entry (guarded by `Arc::ptr_eq`, so a late caller cannot remove a newer
  rebuild's), so the map never pins a state. A panicking rebuild leaves the
  cell empty and one waiter rebuilds for the rest.
- The leader re-checks the cache and the write buffer before reading, so a
  miss that arrives just after a rebuild settled does not read it again.
- A rebuild that cannot read or decode what it finds returns
  `Error::Storage` or the new `Error::CorruptState` instead of panicking,
  wrapped in `Error::StateRebuild` for every caller. A diff frame shorter
  than its header and a `BlockHeaders` row that does not decode still panic.
- The fulu column chain check parks a sidecar whose parent has no state, as
  before, and now logs and drops one whose parent state read fails
  (`Ignore(Internal)`).
- New `lean_state_rebuilds_total{role="led"|"joined"}`: states actually read
  back from storage, and misses that took one from a rebuild under way.

This branch has not been deployed

No deployments
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