Skip to content

execution, db/state: StateCache follow-ups - #22159

Closed
yperbasis wants to merge 35 commits into
mainfrom
yperbasis/statecache-review-fixes
Closed

execution, db/state: StateCache follow-ups#22159
yperbasis wants to merge 35 commits into
mainfrom
yperbasis/statecache-review-fixes

Conversation

@yperbasis

@yperbasis yperbasis commented Jul 2, 2026

Copy link
Copy Markdown
Member

Important

Closed as superseded — split into four independent PRs that together reproduce this branch's full diff (union verified byte-identical): #22466 (GenericCache fences & accounting), #22467 (SD read-fills), #22468 (codeHash extractor), #22469 (warmup lifecycle). Finding 6 stays a won't-fix as documented below; the unwind-side read-fill residual is tracked in #22463.

Addresses #22120 (the #21386 StateCache review findings): findings 1, 2, 3, and 8 landed with #22154; this PR addresses the rest, with finding 6 documented as a won't-fix and recommendation A implemented using the assert variant named in the issue. The preferred drain-free getter remains tracked in #22116.

The flush-apply deletion/tombstone work previously included here has moved to #22444. This PR retains the separate jump-grow correctness fix surfaced during review.

Coverage

Item Status
Finding 4 — false ASSERT_STATE_CACHE panic during in-flight unwind fixed
Finding 5 + recommendation B — dead DetachBranchCache deleted
Finding 6 — keccak on cold code read won't fix (see below)
Finding 7 — duplicated maxStep gates; full account decode per mem-hit fixed
Finding 9 — read-fill as second unconditional-Put writer fixed
Finding 10 — pre-Start warmup gap around ProcessFrozenBlocks fixed
Finding 11 — currentSize double-subtract via unstriped removals fixed
Finding 12 — warm-path prefetch copy/keccak discarded by if-absent no-op fixed
Latent note — immortal negative cache entries fixed
Recommendation A — enforce the drain-before-epoch-bump convention assert added
Jump-grow resize windows fixed

What changed

Finding 4 — the divergence assert runs only when the mem overlay publishes no per-key maxStep bound. During an in-flight unwind MDBX can still hold a dying row inside the bound, so comparing it against a legitimate below-floor cache hit produced a false panic.

Finding 5 / recommendation BDetachBranchCache is deleted (ClearBranchCache already left with #22154). It advertised a fork-validation guard that was never wired; the actual mechanism is the epoch-bumping sd.Unwind, and detaching would discard a useful warm branch cache.

Finding 6 — won't fix. The cold-read keccak(v) keeps content-addressed code entries self-consistent when parallel execution exposes skewed cross-account codeHash reads. Finding 12 removes the recurring warm-path hash/copy cost instead.

Finding 7 — state-cache and branch-cache bounds now share servableUnderBound, with their different units explicit at each call site. codeHashForAddr uses the targeted accounts.DeserialiseV3CodeHash extractor instead of fully decoding balances and interning hashes on every mem hit.

Finding 9getLatestMetered read-fills use PutIfAbsent and PutCodeWithHashIfAbsent. A read-fill cannot carry newer information than flush-apply, so a snapshot reader can no longer overwrite a live authoritative cache entry.

Finding 10ExecModule.Start drains warmup and clears the state cache under the module semaphore before ProcessFrozenBlocks, preventing pre-start payload validation from leaving pre-catchup cache entries live.

Finding 11GenericCache.Delete and the lazy stale-drop take the key's put stripe and re-check under lock, preventing OnEvict accounting from racing the update delta and subtracting the displaced entry twice.

Finding 12 — a side-effect-free CodeCache.ContainsLive probe lets read-ahead skip the keccak and copy for an already-live address binding. Domain values deliberately keep the single conditional put because their copies are small and the common fill path is a miss.

Latent note — missing accounts and empty slots are stamped with domain progress at observation time rather than a synthetic step-zero stamp, so an unwind can invalidate them. Code read misses remain uncached. The benchmarked keys-table LastKey cost is approximately 290 ns and the complete cold-negative cache fill approximately 0.5 µs on an M2 Max.

Recommendation AWarmupStarted and WarmupDone track cache-populating warmup, and Unwind panics under ASSERT_STATE_CACHE if one remains in flight. This converts the drain-before-epoch-bump convention into a loud invariant failure.

Jump-grow resize — growth now fences put stripes while copying and publishes only a fully populated generation. Writers cannot land in an abandoned generation or expose a partial generation to conditional puts.

Testing

TDD: each behavioral fix has coverage that failed on the pre-fix code.

  • TestDomainCache_DeleteAtomicWithPut_NoSizeDrift and TestDomainCache_StaleDropAtomicWithPut_NoSizeDrift
  • TestGenericCache_PutNotLostAcrossGrow and TestGenericCache_PutIfAbsentDefersAcrossGrow
  • TestAssertStateCache_NoFalsePanicDuringInFlightUnwind
  • TestReadFill_DoesNotClobberLiveEntry
  • TestReadFill_NegativeStampedWithProgress and TestCachePopulatingGetterNegativeDropsOnUnwind
  • TestCachePopulatingGetterSkipsContentForLiveBinding
  • TestDeserialiseV3CodeHash*
  • TestCodeCache_ContainsLive and TestStateCache_UnwindAssertsWarmupInFlight

Finding 10 has no isolated test because reproduction requires a live engine server racing frozen-block processing during startup; it composes two tested primitives under the semaphore.

Verification includes the affected package suites, race coverage for the cache packages, reorg/fork engine tests, repeated clean make lint runs, and make erigon integration.

yperbasis and others added 9 commits July 2, 2026 09:40
The warmBody prefetch goroutine reads committed (parent-block) state and
Put it into the StateCache unconditionally. A laggard Put landing after
the FCU flush's cache-apply replaced the flushed value with the pre-flush
snapshot, stamped with the current epoch — permanently valid to the lazy
unwind staleness check. The next block then executed against the stale
value: wrong trie root / INVALID payload (the eest-spec-enginextests
merge-queue flake first seen after #21386 merged).

Fix in two layers: prefetch writes go through the new PutIfAbsent /
PutCodeWithHashIfAbsent (live entries kept, stale ones replaced), so
either interleaving converges to the authoritative value; and
updateForkChoice drains in-flight warmup at entry — before the unwind
epoch-bump and the flush cache-apply — subsuming the unwind-path-only
drain.
The if-absent check+insert was Get-then-Add on a per-call-locked LRU, so
a conditional writer could check (absent), lose the CPU to an
authoritative Put, then clobber it. Serialize same-key writers: striped
mutexes in GenericCache, a bind mutex around the CodeCache addr-to-code
binding. Red-tested by hammering a single key with a concurrent
Put/PutIfAbsent pair — pre-fix the stale value won within a few thousand
rounds.
…sent rationale

Route Put/PutIfAbsent and PutCodeWithHash/PutCodeWithHashIfAbsent through
shared private cores so the nil-cache and KeyCommitmentState guards live in
one place, matching the GenericCache/CodeCache shape. State the if-absent
rationale once on Cache.PutIfAbsent; the other sites keep terse pointers.
CodeCache.putCode drops zero-length puts, so the comment's 'no code' claim
was wrong (flagged by Copilot review; the wording predates this PR).
Extracts just the codeHash field from a SerialiseV3-encoded account record,
skipping the full decode (balance parse, codeHash interning) that
DeserialiseV3 pays. Bounds-checked against truncated records and faithful to
CodeHash.IsEmpty for non-canonical records that spell out the empty/zero
sentinel. Groundwork for #22120 finding 7: SharedDomains.codeHashForAddr runs
this per mem-hit on the codeHash fast path.
…p-in-flight assert

Three of the #22120 findings against the cache package itself:

Finding 11: GenericCache.Delete and the lazy stale-drop inside GetWithTxNum
removed entries outside the put stripes, so a removal interleaved between
put's data.Get and data.Add double-subtracted the displaced entry's size
(once via freelru's OnEvict, once via put's update delta) — currentSize
drifted one entry per occurrence and never healed. Both removal paths now
take the key's stripe, with a re-check under lock. Hammer tests reproduced
the drift (exactly one entry size) before the fix and run race-clean after.

Finding 12: new ContainsLive probes (Peek-based — no hit/miss counters, no
LRU recency) let conditional writers skip preparing a put that a live entry
would no-op: StateCache.put skips the value copy, and StateCache.HasLiveCode
lets the read-ahead prefetcher skip the keccak+copy for an already-bound
address. Advisory only — PutIfAbsent still decides under the key's stripe.

Enforcement (issue recommendation A, cheap variant): StateCache gains a
warmup-in-flight gauge (WarmupStarted/WarmupDone); Unwind panics under
ASSERT_STATE_CACHE when a cache-populating warmup is still in flight,
converting the drain-before-epoch-bump convention into a loud failure. The
drain-free getter remains tracked in #22116.
… domain progress

Read-ahead warmup follow-ups from #22120:

Finding 12: with if-absent semantics a live entry makes the conditional put
a no-op, but the prefetcher had already paid the keccak over the full
bytecode plus the value copy by then — the steady-state warm prefetch
allocated and discarded ~1-3 MB of hash+copy work per block. The code branch
now probes HasLiveCode before preparing the put; the accounts/storage copy
elision lives in StateCache.put (previous commit). A live binding makes the
prefetch a full no-op: it no longer populates content layers for its
superseded snapshot code either.

Latent-note fix: negative results (missing account, empty slot) carry no
step to derive an unwind bound from, and the synthetic step-0 stamp
((0+1)*stepSize-1) sat below every realistic unwind floor, making cached
negatives immortal. Correctness relied on the flush-callback overwrite
always firing. Negatives are now stamped with the domain's progress at
observation time (max committed txNum in the read snapshot), so they drop on
any unwind that could matter instead of outliving the fact they cache.

Also brackets the fire-and-forget warmBody goroutine with the new
WarmupStarted/WarmupDone gauge; the release is ordered before warmWg.Done so
a WaitForWarmup return implies the gauge is back to zero.
…old maxStep gates

The SharedDomains half of the #22120 findings:

Finding 9: the getLatestMetered read-fill was a second unconditional-Put
snapshot writer — an embedded-RPC eth_call straddling an FCU commit (or a
bounded read during an in-flight unwind) could overwrite the flush-applied
value with the pre-flush one, the same clobber shape #22146 fixes for the
warmup. Read-fills never carry newer information than a flush-apply, so both
fills (domain values and code) now use the if-absent puts.

Finding 4: the ASSERT_STATE_CACHE divergence check compared a cache hit
against a DB read bounded by the same maxStep the mem overlay published for
a key an in-flight unwind re-bound. In that window MDBX still holds the
not-yet-deleted dying row inside the bound, so the authoritative read
returns dead-fork bytes and the assert panicked on a legitimate below-floor
hit. The assert now runs only when no per-key bound is active. (The
Value==nil delete-only diff that produces this signal comes from legacy
V0-format persisted changesets; current V1 diffs are served from mem
directly — see DomainRoTx.unwind.)

Finding 7: the stateCache and branchCache maxStep gates were two copies of
the same rule differing only in a subtle divide/don't-divide unit
conversion; both now call one servableUnderBound helper carrying the shared
rationale, with the unit conversion explicit at each site. codeHashForAddr's
per-mem-hit full account decode is replaced by the targeted
DeserialiseV3CodeHash extractor.

Finding 5: ClearBranchCache and DetachBranchCache had no callers, and
DetachBranchCache's docstring advertised a fork-validation guard that was
never wired — correctness rests on the epoch-bumping sd.Unwind, which every
unwind path already funnels through. Deleted rather than wired: detaching
would cost fork validation its warm branch cache. ProbeReadLayers is kept —
it gains a caller in #22154.

Latent-note fix (SD side): negative read-fills are stamped with the domain's
progress instead of the synthetic step-0 bound, mirroring the warmup getter.
…rozenBlocks

Finding 10 of #22120: engine servers are live before ExecModule.Start, and
ValidateChain fires the read-ahead warmup before its too-far-away check, so
a payload validated in the pre-Start window warms the cache with pre-catchup
state — nil negatives included. Frozen-block processing advances state
without touching the cache (its SDs are never wired to it) and neither
clears nor epoch-bumps, so those entries stayed live through the whole
catch-up and were served cache-before-aggTx afterwards: stale value, wrong
root until restart. Start now drains any in-flight warmup and clears the
cache under the semaphore before frozen-block processing; no new warmup can
start until Start releases it.

No isolated unit test: reproducing needs a live engine server racing PFB
startup in the ms-scale pre-Start window. The change composes two
already-tested primitives (drainReadAhead, StateCache.Clear) at a point
where the semaphore excludes concurrent writers.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Follow-up fixes for the StateCache/StateCache warmup and read-fill behavior, focused on correctness under unwind/flush races and reducing unnecessary work on hot paths.

Changes:

  • Add side-effect-free “live entry” probes (ContainsLive / HasLiveCode) to avoid wasteful copies/keccak work for conditional writers and prefetchers.
  • Fix cache removal/write interleavings by striping stale-drop and delete paths under the same per-key put stripe to prevent size-accounting drift.
  • Improve correctness around warmup/unwind ordering (assert-gated warmup-in-flight), negative-entry stamping, and reduce account decode overhead via a targeted DeserialiseV3CodeHash.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
execution/types/accounts/account.go Adds DeserialiseV3CodeHash fast-path extractor for account codeHash.
execution/types/accounts/account_test.go Adds tests for DeserialiseV3CodeHash correctness and malformed inputs.
execution/execmodule/exec_module.go Drains read-ahead and clears state cache before ProcessFrozenBlocks to avoid stale pre-Start warmups.
execution/exec/blocks_read_ahead.go Stamps negative warmup results with domain progress and avoids keccak/copy when code binding is already live; adds warmup in-flight bracketing.
execution/exec/blocks_read_ahead_test.go Adds tests for negative-unwind drop and “live binding makes prefetch a no-op” behavior.
execution/cache/state_cache.go Adds warmup-in-flight gauge + assert on unwind; uses ContainsLive to skip unnecessary copies for PutIfAbsent; adds HasLiveCode.
execution/cache/generic_cache.go Stripes delete + stale-drop removals to avoid size drift; adds ContainsLive.
execution/cache/code_cache.go Adds ContainsLive for addr→code binding liveness checks.
execution/cache/cache.go Extends Cache interface with ContainsLive.
execution/cache/cache_test.go Adds concurrency/atomicity and semantics tests for Delete/stale-drop striping and ContainsLive; tests unwind warmup assert.
db/state/execctx/statecache_readfill_test.go Adds tests for assert behavior during in-flight unwind, read-fill not clobbering live entries, and negative stamping.
db/state/execctx/domain_shared.go Refactors maxStep gating into helper; switches read-fill to if-absent; stamps negatives with domain progress; uses targeted codeHash extractor; removes dead branch-cache APIs.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread execution/exec/blocks_read_ahead.go Outdated
Base automatically changed from yperbasis/readahead-statecache-stale-put to main July 2, 2026 12:05
yperbasis added 2 commits July 2, 2026 15:08
…review-fixes

# Conflicts:
#	execution/cache/cache.go
#	execution/cache/cache_test.go
#	execution/cache/state_cache.go
#	execution/exec/blocks_read_ahead.go
#	execution/exec/blocks_read_ahead_test.go
A WaitForWarmup landing between WarmupStarted and warmWg.Add saw a zero wg
counter and returned while the gauge was already raised, so a drain-then-bump
caller could trip the ASSERT_STATE_CACHE panic in StateCache.Unwind on a
correctly-drained warmup. Today's callers cannot interleave there (the module
semaphore excludes AddHeaderAndBody from every drain-then-bump path), but the
assert exists to be trustworthy when that convention is violated, so its
wiring must not manufacture false positives on its own. Raising the gauge
after Add restores the invariant from both sides: a Wait either returns
before Add (gauge still zero) or blocks until Done (gauge dropped first,
defers run LIFO).

Flagged by Copilot on #22159.
@yperbasis yperbasis changed the title execution/cache, execution, db/state: StateCache review follow-ups (#22120) execution/cache, execution, db/state: StateCache review follow-ups Jul 2, 2026
@yperbasis yperbasis changed the title execution/cache, execution, db/state: StateCache review follow-ups execution/cache, execution, db/state: StateCache follow-ups Jul 2, 2026
yperbasis added 4 commits July 8, 2026 17:08
…review-fixes

# Conflicts:
#	db/state/execctx/domain_shared.go
#	execution/cache/generic_cache.go
…e a resize

maybeGrow copied entries and then swapped generations, so a striped writer
that loaded the old generation before the swap landed its write in the
abandoned copy — the migrated (older) value resurfaced as live: a stale
serve, not the documented benign miss. Growth now publishes the new
generation first, fences the put stripes, and migrates each key under its
stripe with if-absent semantics; the grow trigger moves out of put's
striped section (the fence would self-deadlock). growLRU keeps the old
order: its content-addressed values are immutable per key, so a lost copy
there really is a benign miss.
Deleting the entry left the key absent, so a read-fill from a reader
straddling the deletion (a pre-delete snapshot) re-inserted the old value
as live — PutIfAbsent only defers to live entries — and the deleted
account or slot was served as canonical. A nil tombstone stamped with the
delete's txNum defends the key and drops on an unwind at or below the
deletion. The code layers keep the delete: they cannot represent
negatives.
…ine codeHash extraction, add negative-read benchmarks

Code negatives in the warmup getter no longer pay a DomainProgress call
and a liveness probe for a put CodeCache drops anyway. The
decodeAccountCodeHash wrapper was a pure pass-through — call
accounts.DeserialiseV3CodeHash directly. The new benchmarks bound the
negative-stamp cost: the keys-table LastKey is ~290 ns and the whole
cache-side fill ~0.5 us per cold negative (M2 Max), paid once per key
between invalidations.
@yperbasis yperbasis changed the title execution/cache, execution, db/state: StateCache follow-ups execution, db/state: StateCache follow-ups Jul 9, 2026
@yperbasis
yperbasis requested a review from Copilot July 9, 2026 08:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Comment thread execution/types/accounts/account.go Outdated
Comment thread db/state/execctx/domain_shared.go
yperbasis added 5 commits July 9, 2026 10:35
The swap-then-migrate resize left each key absent from the primary
generation until migrated, so a conditional put arriving mid-resize
found a gap and filled it with a stale value — the writer class
PutIfAbsent exists to close, reopened for the resize window. The
pre-stripe grow trigger also paid a ShardedLRU.Len (a lock per shard)
on every put, including warm updates.

The copy now runs with every put stripe held and the swap publishes a
fully-copied generation, so no operation ever sees a partial one; the
fence sweep, per-key migration and superseded-size accounting go away
with the window. The trigger returns to the insert path and the grow
runs after the stripe is released (growth takes every stripe).
… simplify put's grow trigger

The Cache.ContainsLive interface method had one caller — StateCache.put's
pre-check — whose only real value (skipping the keccak for already-bound
code) lives in HasLiveCode, which reaches CodeCache.ContainsLive
concretely; for domain values the probe traded a tens-of-bytes copy
against an extra Peek on the common miss path. putLocked now returns
whether the caller should grow, replacing the defer-ordering trick, and
warmBody builds its getter through one constructor.

TestGenericCache_PutIfAbsentDefersAcrossGrow is rebuilt to be
scheduling-proof: reaching Len >= startCap organically requires every
freelru shard full, which makes the hot key evictable and a stale insert
legitimate — a flake under CPU contention. The cache is now seeded below
capacity pressure with the hot key inserted last (the LRU victim is
always an older seed key) and the grow is forced by lowering curCap.
…deletion marker for the code cache

A code deletion was applied to the cache as a raw Delete, so a read-fill
from a reader straddling the deletion (a pre-delete snapshot) re-bound
the deleted code as a live hit — the same resurrection the account and
storage tombstones close, flagged by Copilot. An authoritative nil put
now binds a live no-code marker (versionedAddressID.deleted, stamped
with the delete's txNum): a valid negative on read, a binding
conditional fills defer to, replaced by an authoritative rebind and
dropped by an unwind at or below the deletion. The flush-apply writes
Put(CodeDomain, key, nil, txN), symmetric with the other domains.

Also tightens the DeserialiseV3CodeHash doc: it parses only up to and
including the codeHash field; later fields are not validated.
With the flush-apply writing tombstones and no-code markers, Delete lost
its last production caller — and any future call would reopen the
resurrection window the markers close, since removing an entry leaves
nothing for a straddling conditional fill to defer to. Drop it from the
Cache interface and every implementation so the wrong operation is not
expressible; the stale-drop keeps the stripe-serialized removal rationale.

The hammer tests that used Delete as a reset now race on fresh keys per
round, and the stale-drop drift check asserts the exact expected
residency instead of zero-after-delete.
@yperbasis
yperbasis requested review from awskii and taratorio July 9, 2026 11:04
yperbasis and others added 5 commits July 13, 2026 22:11
…no-code deletion marker for the code cache"

This reverts commit 23bcf56.
Keep fresh-key setup in the PutIfAbsent concurrency tests and exact residency accounting in the stale-drop test. Restore only the Delete-specific coverage needed after extracting tombstones from this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 2 comments.

Comment thread db/state/execctx/domain_shared.go
Comment thread execution/exec/blocks_read_ahead.go
A put racing Clear could load the retiring generation, land its entry
where no reader sees it, and add the entry's size after Clear zeroed
the counter — a phantom byte count that never drains. Clear now runs
the counter reset, coherence re-init and generation swap with every
put stripe held, mirroring maybeGrow's swap; lock order (resizeMu →
stripes) is unchanged, so writers either complete before the swap or
land in the fresh generation.
A capacity eviction is a size-subtracting writer the put stripes cannot
serialize: freelru picks its victim per shard (hash bits 16+), so an
insert on one stripe can evict a key whose own update — on another
stripe — sits between its Get and Add, and the update's
newSize-existing.size delta then double-subtracts the victim's size.
Replace the delta accounting (update and collision branches) with
remove-then-add, making the OnEvict callback the sole subtractor, as
putContent already does. Intentional removals (update, Delete,
dropStale) are compensated in the evictions metric, which also stops
stale drops counting in both staleEvicted and evictions.
Generation swaps stay unfenced here on purpose: the layers it backs are
content-addressed, so a lost write is a benign miss and a raced removal
resurrects correct bytes that drop on the next stale read. State that,
the counter approximation it implies, and that mutable-per-key values
belong on GenericCache's fenced swap instead.
… drain as a failed precondition

WaitForWarmup could return on context cancellation with the warmup
still running, so "WaitForWarmup returned ⟹ warmup gauge is zero"
held only for the completed-wait path; a payload validation racing
module shutdown could then bump the cache epoch un-drained (an open-tx
unwind never re-checks the context) and trip the ASSERT_STATE_CACHE
gauge assert. WaitForWarmup and drainReadAhead now report whether the
warmup fully drained — false only when the module context is cancelled
— and the epoch-bump/Clear call sites return instead of proceeding.
The DB-close caller keeps ignoring the result: it only needs a bounded
wait.
…he snapshot

AddHeaderAndBody read bra.stateCache separately for WarmupStarted, the
deferred WarmupDone, and warmBody's getter wiring, so a SetStateCache
racing the launch could split the pair (negative gauge) or tick one
cache's gauge while the workers populate another — the one whose gauge
Unwind asserts. Capture the pointer once and thread it through warmBody
so the Started/Done bracket and the puts all bind to the same cache.
…fenced copy

Grows are silent, so the once-per-lifetime writer stall of the fenced
copy (~150ms for the final 1GB-accounts step) surfaces as an
unexplained FCU/execution latency blip. One Debug line per grow with
the caps, copied count and the alloc/fenced split makes it
self-explaining.
@yperbasis

Copy link
Copy Markdown
Member Author

This PR grew too large; it is superseded by four independent (non-stacked) PRs that together reproduce its full diff — verified by merging all four onto main and diffing the union against this branch (byte-identical on every touched file):

Finding 6 remains a won't-fix as described here; the unwind-side read-fill residual is tracked in #22463. This branch is left intact for reference.

@yperbasis yperbasis closed this Jul 14, 2026
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Jul 15, 2026
…a full account decode (erigontech#22468)

Split from erigontech#22159 (the erigontech#22120 StateCache review findings — finding 7's
decode-cost half).

## What changed

`codeHashForAddr` fully decoded every account record it touched —
balance parse plus codeHash interning per mem hit — just to read one
field. `accounts.DeserialiseV3CodeHash` parses the SerialiseV3 layout
only up to and including the codeHash field:

- bounds-safe on every truncation point (returns nil on malformed input;
the full decoder indexes without length checks),
- returns nil for both no-code sentinel spellings (zero hash, empty-code
keccak), matching `CodeHash.IsEmpty`,
- returns a subslice of `enc`, valid only while `enc` is — all four call
sites in `codeHashForAddr` consume it synchronously within the tx, and
the one retained copy (`PutAddrCodeHash`) goes through a fixed
`[32]byte`.

`decodeAccountCodeHash` is deleted; its call sites switch to the
extractor.

## Testing

- `TestDeserialiseV3CodeHash` cross-validates the extractor against the
full `DeserialiseV3` decode over a nonce × balance × codeHash ×
incarnation matrix.
- `TestDeserialiseV3CodeHashMalformed` walks every truncation point of a
record (nil at any cut into the codeHash, the hash beyond it), rejects
non-32-byte codeHash fields, and pins the sentinel spellings to nil.

Verification: `execution/types/accounts` + `db/state/execctx` suites,
repeated clean `make lint`.

Touches `domain_shared.go` in hunks disjoint from erigontech#22467; the two merge
independently.
Sahil-4555 pushed a commit to Sahil-4555/erigon that referenced this pull request Jul 16, 2026
…counting exact (erigontech#22466)

Split from erigontech#22159 (the erigontech#22120 StateCache review findings): the
`GenericCache` concurrency and accounting fixes, self-contained to
`execution/cache`.

## What changed

**Jump-grow fence.** `maybeGrow` now copies and swaps the generation
with every put stripe held (allocation stays outside the fence).
Previously a striped put could land in the retiring generation — not a
benign miss: the copy may already have migrated the key's *older* value,
which then resurfaced as live; and a `PutIfAbsent` arriving in the
mid-resize gap could install a stale snapshot value as live, defeating
the if-absent semantics the read-fill paths rely on. Grow detection
moves inside the stripe and the grow itself outside it (calling it
stripe-held would self-deadlock against take-them-all); the triggering
insert and racers until the swap evict at the pre-grow cap — a transient
bounded by the grow window, noted in source. Generations carry an
explicit freelru shard count that doubles across grows only while
per-shard capacity does not shrink, so the migration copy can never
overfill a shard and evict — left to freelru, a grown generation could
pick more, smaller shards and silently drop clustered entries.

**Clear fence.** `Clear` is the second wholesale generation replacement;
it now runs its counter reset, coherence re-init and swap under the same
fence, so a racing put can neither land in the retired generation nor
add its size after the counter was zeroed. Readers stay lock-free and
are ordered instead: `GetWithTxNum` snapshots coherence before loading
the generation and `Clear` re-inits coherence only after its swap, so an
entry captured from the retiring generation is always judged by pre-init
coherence that still carries the unwind — judged against the live state,
the re-init (fresh epoch, lifted floor) revalidated dead-fork entries
for in-flight readers. A live entry judged by a pre-Clear snapshot
degrades to a safe miss via the stale-drop's re-check.

**Epoch stamp under the stripe.** `put` samples `coh.Epoch()` with the
key's stripe held, next to the generation load the fence synchronizes.
Read before the stripe, the stamp raced `Clear`'s coherence re-init: a
put that lost the stripe to `Clear` landed a pre-Clear epoch on an entry
in the post-Clear generation, and once a later unwind re-reached that
epoch value the entry aliased the live epoch and served dead-fork state
despite a txNum at or above the floor.

**Exact size accounting.** `Delete` and the lazy stale-drop run under
the key's put stripe, and `currentSize` is subtracted solely via the
`OnEvict` callback (update/collision paths do remove-then-add instead of
delta arithmetic). freelru picks eviction victims per shard — hash bits
16 and up, which the put stripes (bits 0–7) don't cover — so any
subtraction computed outside the callback races a cross-stripe capacity
eviction and double-subtracts. Capacity evictions are counted from
`freelru.Add`'s evicted return at the call sites — OnEvict also fires
for intentional Removes, and routing those through the metric races a
concurrent stats reset — which also stops stale drops counting in both
`staleEvicted` and `evictions`. The byte counter is reserved before a
remove-then-add, so a ModeNoOp admission never observes a transient dip
and over-admits past the budget.

**Observability.** One Debug line per grow with the caps, shard count,
copied and copy-evicted counts, and the alloc/fenced duration split.
Measured on the production accounts geometry (1 GB / 96 B avg), the
final 4.19M-entry step is ~470 ms total of which ~150 ms is the
writer-visible fenced copy — once per process lifetime.

**growLRU contract.** The CodeCache layers' `growLRU` keeps its unfenced
swap deliberately; its doc comment now states why that is safe only for
content-addressed layers (lost write = benign miss, raced removal =
resurrect-once dropped on the next stale read, counters approximate) and
points mutable-per-key values at the fenced `GenericCache`.

## Testing

TDD: each behavioral fix has coverage that failed on the pre-fix code.

- `TestGenericCache_PutNotLostAcrossGrow` and
`TestGenericCache_PutIfAbsentDefersAcrossGrow` (fail in the first rounds
when the fence is removed)
- `TestGenericCache_GrowMigrationLossless` (clustered keys
deterministically evicted by a resharding migration pre-fix)
- `TestGenericCache_ClearRacingPut_EpochAlias` (deterministic
stripe-parking choreography; fails every run pre-fix)
- `TestGenericCache_ClearRacingGet_DeadEntryStaysDead` (reader gated on
the fence reaching its stripe; served dead-fork state within tens of
rounds pre-fix)
- `TestDomainCache_ClearAtomicWithPut_NoSizeDrift`
- `TestDomainCache_DeleteAtomicWithPut_NoSizeDrift` and
`TestDomainCache_StaleDropAtomicWithPut_NoSizeDrift`
- `TestGenericCache_CapacityEvictionAtomicWithPut_NoSizeDrift` (cap-1
cache forces same-shard cross-stripe eviction; reproduces the drift in
~0.2 s pre-fix)
- `TestGenericCache_StatsResetAtomicWithDelete_NoPhantomEvictions`
(intentional removals leak into the metric within milliseconds pre-fix)
- `TestGenericCache_ModeNoOpAdmissionAtomicWithUpdate` (over-admission
past a full budget pre-fix)

Test caches now close on cleanup, returning their envelope reservations
— the process-global `cachebudget.Global` otherwise accumulates leaked
reservations across the package run, which would starve the grow tests'
`Reserve` calls.

Verification: `go test ./execution/cache/...`, `-race` on the whole
concurrency family, repeated clean `make lint`.

Note: erigontech#22159's warmup-lifecycle split also appends tests to
`cache_test.go`; whichever lands second rebases trivially.
pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Jul 16, 2026
…and unwinds (erigontech#22467)

Split from erigontech#22159 (the erigontech#22120 StateCache review findings): the
`SharedDomains` read-path changes — findings 4, 5, 7 (bound unification)
and 9 — plus the immortal-negative fix applied at both fill sites (SD
read-fill and warmBody read-ahead), and a mem-batch contract fix
(`kv.NoStepBound`) so the per-key unwind bound survives step 0.

## What changed

**Finding 9 — read-fills defer to authority.** `getLatestMetered`
populates the cache with if-absent semantics (`PutIfAbsent`,
`PutCodeWithHashIfAbsent`): a read-fill never carries newer information
than a flush-apply, so a snapshot reader can no longer overwrite a live
authoritative entry (e.g. an embedded-RPC read straddling an FCU
commit).

**Immortal negatives.** Missing accounts and empty slots are stamped
with the domain's progress at observation time rather than a synthetic
step-zero bound, so an unwind can invalidate them. The benchmarked
keys-table `LastKey` cost is ~290 ns and the complete cold-negative fill
~0.5 µs (M2 Max); both are paid only on reads that already traverse the
file-accessor stack. Applied at both fill sites into the process-global
cache: the SD read-fill and warmBody's read-ahead prefetcher
(`cachePopulatingGetter`, which wraps the raw temporal tx and therefore
doesn't inherit the SD fix). Observable side effect: a cached
empty-value hit returns a progress-derived step rather than a deletion
step; no consumer reads the step of an empty value (the write path,
commitment, and RPC readers all discard it), and the hit path documents
this.

**Finding 4 — no false `ASSERT_STATE_CACHE` panic during in-flight
unwinds.** The divergence assert runs only when the mem overlay
publishes no per-key `maxStep` bound: during an in-flight unwind MDBX
still holds the dying row inside the bound, so the "authoritative"
comparison read can return dead-fork bytes and blame the cache for a
legitimate below-floor hit. The bound now survives step 0: a plain mem
miss returns `kv.NoStepBound` instead of 0, so a delete-shape bound at
step 0 is no longer conflated with "no bound" — a young chain's whole
state lives in step 0.

**Finding 7 (bounds) — one gate, explicit units.** The state-cache and
branch-cache maxStep gates share `servableUnderBound`; the StateCache
divides its txNum stamp by the step size, the BranchCache uses on-disk
step indices directly — the unit mismatch that previously defeated the
gate once.

**Finding 5 — dead `DetachBranchCache` deleted.** It advertised a
fork-validation guard that was never wired; the actual isolation
mechanism is the epoch-bumping `sd.Unwind`, and detaching would discard
a useful warm branch cache.

## Testing

TDD: each behavioral fix has coverage that failed on the pre-fix code.

- `TestAssertStateCache_NoFalsePanicDuringInFlightUnwind` (plus a step-0
variant pinning the `kv.NoStepBound` signal)
- `TestReadFill_DoesNotClobberLiveEntry` (the fall-through read serves
the maxStep-bounded row without replacing the live entry)
- `TestReadFill_NegativeStampedWithProgress`
- `TestCachePopulatingGetterNegativeDroppedByUnwind` (the warmBody fill
site)
- `BenchmarkDomainProgress` / `BenchmarkGetLatestColdNegative` quantify
the negative-stamp cost

The read-fill regressions run in the short suite (no `testing.Short`
guards) — at ~20-30 ms each they are not long-running, and they carry
the PR's core behavioral coverage.

Verification: full `db/state`, `db/state/execctx`, and `execution/exec`
suites, repeated clean `make lint`.

## Notes

- The known residual — an in-flight-unwind fill of a dying row when no
live entry blocks it — is tracked in erigontech#22463; the one-line `maxStep`
fill-skip proposed there composes with this PR.
- erigontech#22444's linearized fill admission rewrites the same fill call sites
(`PutIfFresh`); merge order needs coordinating — whichever lands second
carries a rebase that is mechanical except for preserving the negative
progress-stamp through the `PutIfAbsent` → `PutIfFresh` switch. Details
in erigontech#22444's "Interplay with erigontech#22467" section.
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.

2 participants