Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
12d67f6
execution: read-ahead warmup must not clobber fresher StateCache entries
yperbasis Jul 2, 2026
e7cbf18
execution/cache: make PutIfAbsent atomic w.r.t. concurrent Put
yperbasis Jul 2, 2026
d60a1f8
execution/cache: dedup StateCache put wrappers, trim repeated PutIfAb…
yperbasis Jul 2, 2026
2de03de
execution/exec: fix negative-caching comment — empty code is not cached
yperbasis Jul 2, 2026
f1a99b5
execution/types/accounts: add targeted DeserialiseV3CodeHash extractor
yperbasis Jul 2, 2026
5121282
execution/cache: stripe Delete/stale-drop, liveness pre-checks, warmu…
yperbasis Jul 2, 2026
ec75f7a
execution/exec: skip live-binding prefetch work, stamp negatives with…
yperbasis Jul 2, 2026
5a143b8
db/state/execctx: if-absent read-fill, in-flight-unwind assert fix, f…
yperbasis Jul 2, 2026
2fe8e68
execution/execmodule: drain and clear the state cache before ProcessF…
yperbasis Jul 2, 2026
98f4849
Merge remote-tracking branch 'origin/main' into yperbasis/statecache-…
yperbasis Jul 2, 2026
4f99e38
execution/exec: raise the warmup gauge only after warmWg.Add
yperbasis Jul 2, 2026
6622377
Merge remote-tracking branch 'origin/main' into yperbasis/statecache-…
yperbasis Jul 8, 2026
464dde1
execution/cache: swap-then-migrate jump-grow so striped writes surviv…
yperbasis Jul 9, 2026
561b29d
db/state/execctx: tombstone deleted keys in the flush cache-apply
yperbasis Jul 9, 2026
a8e81b5
db/state/execctx, execution/exec: trim code-negative warmup work, inl…
yperbasis Jul 9, 2026
044ea9d
execution/cache: freeze writers for the jump-grow copy
yperbasis Jul 9, 2026
1c85ee4
execution/cache, execution/exec: drop the generic ContainsLive probe,…
yperbasis Jul 9, 2026
23bcf56
execution/cache, db/state/execctx, execution/types/accounts: no-code …
yperbasis Jul 9, 2026
9072533
execution/cache: remove Delete — deletions are authoritative nil puts
yperbasis Jul 9, 2026
5324e0b
execution/cache: gofmt
yperbasis Jul 9, 2026
0b2eafc
execution/cache, db/state/execctx, execution/exec, execution/types/ac…
yperbasis Jul 9, 2026
84b146e
execution/exec: skip caching negatives when the warmup getter has no …
yperbasis Jul 9, 2026
8dafd07
Merge remote-tracking branch 'origin/main' into yperbasis/statecache-…
yperbasis Jul 10, 2026
972f75d
Revert "execution/cache: remove Delete — deletions are authoritative …
yperbasis Jul 13, 2026
5317c02
Revert "execution/cache, db/state/execctx, execution/types/accounts: …
yperbasis Jul 13, 2026
69baf33
Revert "db/state/execctx: tombstone deleted keys in the flush cache-a…
yperbasis Jul 13, 2026
f3984be
execution/cache: preserve non-tombstone test cleanups
yperbasis Jul 13, 2026
bdb650c
Merge branch 'main' into yperbasis/statecache-review-fixes
yperbasis Jul 13, 2026
975ecbb
execution/cache: fence GenericCache.Clear with the put stripes
yperbasis Jul 14, 2026
f3f4e57
execution/cache: account currentSize solely via OnEvict
yperbasis Jul 14, 2026
bebb2de
execution/cache: document growLRU's unfenced-swap contract
yperbasis Jul 14, 2026
29654fd
execution/exec, execution/execmodule: treat an interrupted read-ahead…
yperbasis Jul 14, 2026
96e6572
execution/exec: bind a warmup's gauge and puts to one launch-time cac…
yperbasis Jul 14, 2026
76f3799
execution/cache: note the transient pre-grow-cap eviction at the grow…
yperbasis Jul 14, 2026
d95229c
execution/cache: log jump-grow durations split by unfenced alloc and …
yperbasis Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 37 additions & 67 deletions db/state/execctx/domain_shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -1082,18 +1082,6 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun
return nil
}

// DetachBranchCache makes this SharedDomains ignore the aggregator-scope
// BranchCache: commitment branch reads go straight to sd.mem/overlay/MDBX and
// no read populates the shared cache. Used for fork-validation SDs, which read
// transient fork state — sharing the canonical BranchCache let them read stale
// committed branches (wrong trie root → INVALID payload) and pollute the cache
// with fork-transient branches. Only sd.branchCache (the read/populate path,
// domain_shared GetLatest) is consulted, so nil-ing it fully detaches; the
// canonical SDs keep their warm cache.
func (sd *SharedDomains) DetachBranchCache() {
sd.branchCache = nil
}

// TemporalDomain satisfaction. Collects no read metrics — see
// temporalGetter.GetLatest for why there is no process-wide accumulator.
func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) {
Expand All @@ -1109,6 +1097,16 @@ func (sd *SharedDomains) GetLatestContext(ctx context.Context, domain kv.Domain,
return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx))
}

// servableUnderBound gates a cached entry against an in-flight unwind's
// per-key maxStep: a hit above the bound would diverge from the bounded read
// the cache-disabled path takes (the epoch floor usually drops such entries
// already; the gate keeps the two paths identical regardless). Callers convert
// their unit first — the StateCache stamps txNums (divide by step size), the
// BranchCache stores step indices (no divide).
func servableUnderBound(cStep, maxStep kv.Step) bool {
return cStep <= maxStep
}

// getLatestMetered is the read implementation. wm is the caller's lock-free
// per-task/per-worker metrics accumulator (nil disables metrics for the call).
// No global metrics lock is taken on this hot path — accumulators are combined
Expand Down Expand Up @@ -1171,16 +1169,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k
// that haven't been flushed to DB yet. Early return keeps correctness AND performance.
if sd.stateCache != nil {
v, cTxNum, ok := sd.stateCache.GetWithTxNum(domain, k)
// The cache stamps txNums — divide to get the step the entry reflects.
cStep := kv.Step(cTxNum / sd.StepSize())
// Respect maxStep, mirroring the BranchCache gate below. sd.mem /
// sd.parent.mem lowered maxStep above when an in-flight unwind re-bound
// this key to an earlier step (the per-key unwindChangeset signal). A
// cached entry from a higher step would diverge from the (maxStep-bounded)
// DB read the cache-disabled path takes, so treat it as a miss and fall
// through; the Put below refreshes it. For direct domains the (txNum,epoch)
// floor in Get usually already drops such entries — this keeps the two
// read paths identical regardless.
if ok && cStep > maxStep {
if ok && !servableUnderBound(cStep, maxStep) {
ok = false
}
if dbg.KVReadLevelledMetrics {
Expand All @@ -1191,7 +1182,11 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k
}
}
if ok {
if dbg.AssertStateCache {
// The divergence assert is skipped while the mem overlay bounds this
// key (in-flight unwind): MDBX still holds the not-yet-deleted dying
// rows inside the bound, so the "authoritative" read can return
// dead-fork bytes and blame the cache for a legitimate hit.
if dbg.AssertStateCache && maxStep == kv.Step(math.MaxUint64) {
// Fetch authoritative value from the backing tx and panic on any divergence.
// sd.mem and sd.parent.mem were already checked above and missed, so the
// backing tx is the single source of truth for this key at this point.
Expand Down Expand Up @@ -1225,18 +1220,11 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k
// with a newer MDBX state.
if domain == kv.CommitmentDomain && sd.branchCache != nil {
if cv, cStepU64, ok := sd.branchCache.Get(k); ok {
// Respect maxStep. sd.mem / sd.parent.mem lowered maxStep above when an
// unwound key reports its restored step via unwindChangeset (the per-key
// in-mem unwind signal). The cache's global epoch/floor is coarser than
// that per-key signal, so a cached entry below the floor can still belong
// to a step the unwind re-bound away. Serving it then diverges from the
// (maxStep-bounded) DB read the cache-disabled path takes. Fall through to
// the bounded read in that case; the Put below refreshes the entry.
// Get returns the on-disk step index directly — do NOT divide by
// StepSize (that double-division collapsed cStep to ~0, defeating the
// gate).
cStep := kv.Step(cStepU64)
if cStep <= maxStep {
if servableUnderBound(cStep, maxStep) {
return cv, cStep, nil
}
}
Expand All @@ -1257,26 +1245,30 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k
return nil, 0, fmt.Errorf("storage %x read error: %w", k, err)
}

// Populate state cache on successful read. Stamp with an upper bound on the
// value's write txNum (the read only gives us the file/step it came from):
// the last txNum of that step. An unwind below this bound can't leave the
// value stale, so frozen-step reads stay warm across unwinds while
// recent-step reads are dropped.
// Populate the cache with if-absent semantics: a read-fill never carries
// newer information than a flush-apply, so it must not overwrite one
// (e.g. an embedded-RPC read straddling an FCU commit). Stamp with the
// last txNum of the step the value came from — an upper bound on its
// write txNum — so an unwind below it can't leave the entry stale. A
// negative carries no step; stamp it with the domain's progress at
// observation time so any unwind drops it.
if sd.stateCache != nil {
readTxNum := (uint64(step)+1)*sd.StepSize() - 1
if domain == kv.CodeDomain {
if len(v) > 0 {
// This SD getter is the single place that populates the code cache
// on a read. Key the content-addressed entry by the code's OWN hash,
// keccak(v) — NEVER a separately-read account codeHash, which under
// parallel exec can be a skewed/cross-account value and would poison
// the shared codeHash->code map for every account sharing that hash.
// keccak(v) makes every cached entry self-consistent, so a skewed
// account read can never produce a bad entry.
sd.stateCache.PutCodeWithHash(k, v, crypto.Keccak256(v), readTxNum)
// parallel exec can be a skewed or cross-account value and would
// poison the shared codeHash→code map for every account sharing
// that hash.
sd.stateCache.PutCodeWithHashIfAbsent(k, v, crypto.Keccak256(v), readTxNum)
Comment thread
yperbasis marked this conversation as resolved.
}
} else {
sd.stateCache.Put(domain, k, v, readTxNum)
if len(v) == 0 && sd.stateCache.GetCache(domain) != nil {
readTxNum = tx.Debug().DomainProgress(domain)
}
sd.stateCache.PutIfAbsent(domain, k, v, readTxNum)
Comment thread
yperbasis marked this conversation as resolved.
}
}
// Only cache a branch when the read's txN is known: a txN=0 entry would
Expand Down Expand Up @@ -1415,11 +1407,11 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui
// on flush. Route mem-first; the LRU is a committed-state layer that may only
// answer once mem has missed.
if v, _, ok := sd.mem.GetLatest(kv.AccountsDomain, addr); ok {
return decodeAccountCodeHash(v)
return accounts.DeserialiseV3CodeHash(v)
}
if sd.parent != nil {
if v, _, ok := sd.parent.mem.GetLatest(kv.AccountsDomain, addr); ok {
return decodeAccountCodeHash(v)
return accounts.DeserialiseV3CodeHash(v)
}
}

Expand All @@ -1440,14 +1432,14 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui
resolve := func() []byte {
if sd.stateCache != nil {
if v, ok := sd.stateCache.Get(kv.AccountsDomain, addr); ok {
return decodeAccountCodeHash(v)
return accounts.DeserialiseV3CodeHash(v)
}
}
v, _, err := tx.GetLatest(kv.AccountsDomain, addr)
if err != nil || len(v) == 0 {
return nil
}
return decodeAccountCodeHash(v)
return accounts.DeserialiseV3CodeHash(v)
}

h := resolve()
Expand All @@ -1465,28 +1457,6 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui
return h
}

// decodeAccountCodeHash extracts the codeHash from an account's encoded
// (DecodeForStorage) bytes. Returns nil on decode error or when the account
// has no code (empty codeHash).
func decodeAccountCodeHash(enc []byte) []byte {
if len(enc) == 0 {
return nil
}
var acc accounts.Account
// AccountsDomain values are SerialiseV3-encoded, so they must be decoded
// with DeserialiseV3. DecodeForStorage is the legacy MDBX bitmask format
// with an incompatible binary layout; applied to V3 bytes it silently
// misparses and leaves CodeHash empty.
if err := accounts.DeserialiseV3(&acc, enc); err != nil {
return nil
}
if acc.CodeHash.IsEmpty() {
return nil
}
h := acc.CodeHash.Value()
return h[:]
}

func (sd *SharedDomains) Metrics() *kvmetrics.DomainMetrics {
return &sd.metrics
}
Expand Down
99 changes: 99 additions & 0 deletions db/state/execctx/statecache_readfill_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2026 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package execctx_test

import (
"encoding/binary"
"testing"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/state/execctx"
)

// benchSeedDb commits one account so the domain tables are non-empty for
// cold-negative probes.
func benchSeedDb(b *testing.B) kv.TemporalRwDB {
b.Helper()
const stepSize = uint64(16)
ctx := b.Context()
db := newTestDb(b, stepSize)

rwTx, err := db.BeginTemporalRw(ctx)
require.NoError(b, err)
defer rwTx.Rollback()
sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New())
require.NoError(b, err)
defer sd.Close()
written := make([]byte, 20)
written[0] = 0x01
sd.SetTxNum(100)
require.NoError(b, sd.DomainPut(kv.AccountsDomain, rwTx, written, encAccount(7), 100, nil))
require.NoError(b, sd.Commit(ctx, rwTx))
return db
}

// BenchmarkDomainProgress isolates the negative-stamp source: one
// files.EndTxNum read plus an MDBX LastKey on the domain's keys table.
func BenchmarkDomainProgress(b *testing.B) {
db := benchSeedDb(b)
roTx, err := db.BeginTemporalRo(b.Context())
require.NoError(b, err)
defer roTx.Rollback()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = roTx.Debug().DomainProgress(kv.AccountsDomain)
}
}

// benchColdNegativeReads drives the full cold-negative SD read: the whole
// miss stack, plus — when a cache is wired — the progress stamp and the
// if-absent fill.
func benchColdNegativeReads(b *testing.B, withCache bool) {
db := benchSeedDb(b)
ctx := b.Context()
roTx, err := db.BeginTemporalRo(ctx)
require.NoError(b, err)
defer roTx.Rollback()
sd, err := execctx.NewSharedDomains(ctx, roTx, log.New())
require.NoError(b, err)
defer sd.Close()
if withCache {
sd.SetStateCacheForTest(newSmallStateCache())
}

key := make([]byte, 20)
key[0] = 0x02
b.ResetTimer()
for i := 0; i < b.N; i++ {
binary.BigEndian.PutUint64(key[12:], uint64(i)+1)
v, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key)
if err != nil {
b.Fatal(err)
}
if len(v) != 0 {
b.Fatalf("expected a negative, got %x", v)
}
}
}

func BenchmarkGetLatestColdNegative(b *testing.B) { benchColdNegativeReads(b, true) }

// The baseline the stamp+fill cost adds to.
func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { benchColdNegativeReads(b, false) }
Loading
Loading