diff --git a/cmd/evm/staterunner.go b/cmd/evm/staterunner.go
index 8df3afd1dc6..21e5b5409c9 100644
--- a/cmd/evm/staterunner.go
+++ b/cmd/evm/staterunner.go
@@ -35,6 +35,7 @@ import (
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/datadir"
"github.com/erigontech/erigon/db/kv/temporal/temporaltest"
+ "github.com/erigontech/erigon/db/state/execctx"
"github.com/erigontech/erigon/execution/tests/testutil"
"github.com/erigontech/erigon/execution/tracing/tracers/logger"
"github.com/erigontech/erigon/execution/vm"
@@ -224,7 +225,18 @@ func runStateTest(ctx *cli.Context, cfg vm.Config, fname string) ([]testResult,
}
defer tx.Rollback()
- statedb, root, err := test.Run(nil, tx, st, cfg, dirs)
+ // Per-subtest SD scope: caller-owned, never Flushed. Closing
+ // without Flush discards every write made during the subtest
+ // (pre-state + tx execution + commitment), so per-subtest state
+ // never enters the long-lived branch cache.
+ sd, err := execctx.NewSharedDomains(context.Background(), tx, log.New())
+ if err != nil {
+ result.Pass, result.Error = false, err.Error()
+ return
+ }
+ defer sd.Close()
+
+ statedb, root, err := test.Run(nil, sd, tx, st, cfg, dirs)
if err != nil {
result.Pass, result.Error = false, err.Error()
}
@@ -238,8 +250,26 @@ func runStateTest(ctx *cli.Context, cfg vm.Config, fname string) ([]testResult,
}
}
if bench {
+ // Run the benchmark on a fresh tx + SD so the timed work
+ // starts from the same pre-state every iteration; the
+ // preceding test.Run mutated sd in place (pre-state +
+ // tx execution + commitment), which would otherwise
+ // skew the gas/throughput numbers.
+ benchTx, err := db.BeginTemporalRw(context.Background())
+ if err != nil {
+ result.Pass, result.Error = false, err.Error()
+ return
+ }
+ defer benchTx.Rollback()
+ benchSD, err := execctx.NewSharedDomains(context.Background(), benchTx, log.New())
+ if err != nil {
+ result.Pass, result.Error = false, err.Error()
+ return
+ }
+ defer benchSD.Close()
+
_, stats, _ := timedExec(true, func() ([]byte, uint64, error) {
- _, _, gasUsed, _ := test.RunNoVerify(nil, tx, st, cfg, dirs)
+ _, _, gasUsed, _ := test.RunNoVerify(nil, benchSD, benchTx, st, cfg, dirs)
return nil, gasUsed, nil
})
result.Stats = &stats
diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go
index 6e1abc85475..1b52d4a67ff 100644
--- a/common/dbg/experiments.go
+++ b/common/dbg/experiments.go
@@ -120,8 +120,20 @@ var (
BorValidateHeaderTime = EnvBool("BOR_VALIDATE_HEADER_TIME", true)
TraceDeletion = EnvBool("TRACE_DELETION", false)
- RpcDropResponse = EnvBool("RPC_DROP_RESPONSE", false)
- TipTrieWarmupers = EnvInt("TIP_TRIE_WARMUPERS", runtime.NumCPU()*8) //io-bound (not cpu-bound). it's ok to have `io-threads > cpus`
+ RpcDropResponse = EnvBool("RPC_DROP_RESPONSE", false)
+ // The original default was NumCPU()*8 with the rationale "io-bound, more
+ // workers drive more concurrent I/O." Our measurements (cold-cgroup 6c
+ // vs unconstrained 12c on the SSTORE-bloat bench) show read_bytes
+ // nearly identical (1.28 GB vs 1.24 GB) regardless of worker count,
+ // and 76 % of TEST-block CPU sits in the warmer's recsplit/xorfilter/
+ // seg-decompress path. The hot path is CPU-bound, not I/O-bound; the
+ // "drives I/O" rationale isn't borne out. Oversubscribing past
+ // available cores adds scheduler/context-switch overhead and queue
+ // noise without throughput gain. The constraint is queue depth on the
+ // warmer's work channel (sized at numWorkers*64), not worker count.
+ // GOMAXPROCS respects cgroup CPU caps under Go 1.25+, so this scales
+ // correctly inside a constrained envelope.
+ TipTrieWarmupers = EnvInt("TIP_TRIE_WARMUPERS", runtime.GOMAXPROCS(0))
PerfProfiles = EnvBool("PERF_PROFILES", false)
)
diff --git a/common/maphash/maphash.go b/common/maphash/maphash.go
index 595e644f321..45dfc97a00a 100644
--- a/common/maphash/maphash.go
+++ b/common/maphash/maphash.go
@@ -61,6 +61,24 @@ func (m *Map[V]) Clear() {
m.m.Clear()
}
+// Range iterates over every (hash, value) pair. Iteration order is
+// unspecified. Return false from fn to stop early. Concurrent
+// modification during Range is permitted by the underlying xsync.Map.
+//
+// The original byte-key is not recoverable — Set hashes-and-discards.
+// Pair with DeleteByHash to evict entries discovered via Range.
+func (m *Map[V]) Range(fn func(hash uint64, v V) bool) {
+ m.m.Range(func(key uint64, value V) bool {
+ return fn(key, value)
+ })
+}
+
+// DeleteByHash removes the entry under the pre-computed hash. Use
+// alongside Range when the byte-key is unknown.
+func (m *Map[V]) DeleteByHash(hash uint64) {
+ m.m.Delete(hash)
+}
+
// NonConcurrentMap is a non-thread-safe map that uses maphash to hash []byte keys.
// Use this when you don't need concurrent access for better performance.
type NonConcurrentMap[V any] struct {
@@ -163,3 +181,30 @@ func (l *LRU[V]) SetByHash(hash uint64, value V) {
func (l *LRU[V]) ContainsByHash(hash uint64) bool {
return l.cache.Contains(hash)
}
+
+// DeleteByHash removes the entry under the pre-computed hash. Use
+// alongside Range when the byte-key is unknown.
+func (l *LRU[V]) DeleteByHash(hash uint64) {
+ l.cache.Remove(hash)
+}
+
+// Range iterates over every (hash, value) pair without affecting LRU
+// recency (uses Peek under the hood). Iteration order is unspecified.
+// Return false from fn to stop early.
+//
+// The original byte-key is not recoverable — Set hashes-and-discards.
+// Use the hash itself as the identity in callers that need cross-cache
+// comparisons; same byte-key always maps to the same hash, so equality
+// of (hash, value) sets is equivalent to equality of (key, value) sets
+// modulo collision (vanishingly unlikely at typical working-set sizes).
+func (l *LRU[V]) Range(fn func(hash uint64, v V) bool) {
+ for _, h := range l.cache.Keys() {
+ v, ok := l.cache.Peek(h)
+ if !ok {
+ continue
+ }
+ if !fn(h, v) {
+ return
+ }
+ }
+}
diff --git a/db/datastruct/existence/existence_filter.go b/db/datastruct/existence/existence_filter.go
index 36d5241bfac..32230e73d6c 100644
--- a/db/datastruct/existence/existence_filter.go
+++ b/db/datastruct/existence/existence_filter.go
@@ -22,6 +22,7 @@ import (
"hash"
"os"
"path/filepath"
+ "sync/atomic"
bloomfilter "github.com/holiman/bloomfilter/v2"
@@ -79,15 +80,40 @@ func (b *Filter) AddHash(hash uint64) error {
b.filter.AddHash(hash)
return nil
}
+
+// Process-cumulative counters of ContainsHash outcomes.
+// True = "probably present" (filter does not short-circuit, lookup proceeds).
+// False = "definitely not present" (filter short-circuits the lookup).
+// Hit-rate = true / (true + false). Used to size the value of bypassing
+// the filter for callers known to lookup present keys (e.g. warmer).
+var (
+ containsTrueCount atomic.Uint64
+ containsFalseCount atomic.Uint64
+)
+
+// ContainsHashStats returns (true, false) cumulative counts since process
+// start. To get a per-block delta, snapshot before/after.
+func ContainsHashStats() (containsTrue, containsFalse uint64) {
+ return containsTrueCount.Load(), containsFalseCount.Load()
+}
+
func (b *Filter) ContainsHash(hashedKey uint64) bool {
if b.empty {
+ containsTrueCount.Add(1)
return true
}
+ var ok bool
if b.useFuse {
- return b.fuseReader.ContainsHash(hashedKey)
+ ok = b.fuseReader.ContainsHash(hashedKey)
+ } else {
+ ok = b.filter.ContainsHash(hashedKey)
}
-
- return b.filter.ContainsHash(hashedKey)
+ if ok {
+ containsTrueCount.Add(1)
+ } else {
+ containsFalseCount.Add(1)
+ }
+ return ok
}
func (b *Filter) Contains(v hash.Hash64) bool {
if b.empty {
diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go
index e2836f786f3..7aee2feaee0 100644
--- a/db/kv/kv_interface.go
+++ b/db/kv/kv_interface.go
@@ -530,6 +530,7 @@ type TemporalMemBatch interface {
HasPrefixInRAM(domain Domain, prefix []byte) bool
SizeEstimate() uint64
Flush(ctx context.Context, tx RwTx) error
+ FlushWithCallback(ctx context.Context, tx RwTx, cb func(domain Domain, k []byte, v []byte, txNum uint64)) error
Close()
PutForkable(id ForkableId, num Num, v []byte) error
DiscardWrites(domain Domain)
diff --git a/db/kv/tables.go b/db/kv/tables.go
index c36f63ae048..b6c1fb9e95f 100644
--- a/db/kv/tables.go
+++ b/db/kv/tables.go
@@ -61,8 +61,6 @@ const (
HeaderTD = "HeadersTotalDifficulty" // block_num_u64 + hash -> td (RLP)
BlockBody = "BlockBody" // block_num_u64 + hash -> block body
- // BlockAccessList stores RLP-encoded block access lists, keyed by block_num_u64 + hash.
- BlockAccessList = "BlockAccessList"
// Naming:
// TxNum - Ethereum canonical transaction number - same across all nodes.
@@ -315,7 +313,6 @@ var ChaindataTables = []string{
HeaderNumber,
BadHeaderNumber,
BlockBody,
- BlockAccessList,
TxLookup,
ConfigTable,
DatabaseInfo,
diff --git a/db/rawdb/accessors_chain.go b/db/rawdb/accessors_chain.go
index e0f6ec090a1..45ec7e675c0 100644
--- a/db/rawdb/accessors_chain.go
+++ b/db/rawdb/accessors_chain.go
@@ -595,23 +595,6 @@ func ReadBody(db kv.Getter, hash common.Hash, number uint64) (*types.Body, uint6
return body, bodyForStorage.BaseTxnID.First(), bodyForStorage.TxCount - 2 // 1 system txn in the beginning of block, and 1 at the end
}
-// ReadBlockAccessListBytes reads the RLP-encoded block access list sidecar for a block.
-func ReadBlockAccessListBytes(db kv.Getter, hash common.Hash, number uint64) ([]byte, error) {
- data, err := db.GetOne(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash))
- if err != nil {
- return nil, err
- }
- return data, nil
-}
-
-// WriteBlockAccessListBytes stores the RLP-encoded block access list sidecar for a block.
-func WriteBlockAccessListBytes(db kv.Putter, hash common.Hash, number uint64, data []byte) error {
- if err := db.Put(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash), data); err != nil {
- return fmt.Errorf("failed to store block access list: %w", err)
- }
- return nil
-}
-
func HasSenders(db kv.Getter, hash common.Hash, number uint64) (bool, error) {
return db.Has(kv.Senders, dbutils.BlockBodyKey(number, hash))
}
@@ -698,9 +681,6 @@ func DeleteBody(db kv.Putter, hash common.Hash, number uint64) {
if err := db.Delete(kv.BlockBody, dbutils.BlockBodyKey(number, hash)); err != nil {
log.Crit("Failed to delete block body", "err", err)
}
- if err := db.Delete(kv.BlockAccessList, dbutils.BlockBodyKey(number, hash)); err != nil {
- log.Crit("Failed to delete block access list", "err", err)
- }
}
func AppendCanonicalTxNums(tx kv.RwTx, from uint64) (err error) {
@@ -898,9 +878,6 @@ func PruneBlocks(tx kv.RwTx, blockTo uint64, blocksDeleteLimit int) (deleted int
if err = tx.Delete(kv.BlockBody, kCopy); err != nil {
return deleted, err
}
- if err = tx.Delete(kv.BlockAccessList, kCopy); err != nil {
- return deleted, err
- }
if err = tx.Delete(kv.Headers, kCopy); err != nil {
return deleted, err
}
@@ -949,9 +926,6 @@ func TruncateBlocks(ctx context.Context, tx kv.RwTx, blockFrom uint64) error {
if err := tx.Delete(kv.BlockBody, kCopy); err != nil {
return err
}
- if err := tx.Delete(kv.BlockAccessList, kCopy); err != nil {
- return err
- }
if err := tx.Delete(kv.Headers, kCopy); err != nil {
return err
}
diff --git a/db/rawdb/accessors_chain_test.go b/db/rawdb/accessors_chain_test.go
index 203a0441f3f..85fdaa1ef05 100644
--- a/db/rawdb/accessors_chain_test.go
+++ b/db/rawdb/accessors_chain_test.go
@@ -40,7 +40,6 @@ import (
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/types"
- "github.com/erigontech/erigon/execution/types/accounts"
)
func newTestLegacyTx(nonce uint64, to common.Address, value uint256.Int, gasLimit uint64, gasPrice uint256.Int) *types.LegacyTx {
@@ -1127,59 +1126,6 @@ func TestBlockWithdrawalsStorage(t *testing.T) {
require.Nil(entry)
}
-func TestBlockAccessListStorage(t *testing.T) {
- t.Parallel()
- _, tx := memdb.NewTestTx(t)
- defer tx.Rollback()
-
- block := types.NewBlockWithHeader(&types.Header{
- Number: *uint256.NewInt(1),
- Extra: []byte("test block"),
- UncleHash: empty.UncleHash,
- TxHash: empty.RootHash,
- ReceiptHash: empty.RootHash,
- })
-
- data, err := rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64())
- require.NoError(t, err)
- require.Empty(t, data)
-
- nonEmpty := types.BlockAccessList{
- {
- Address: accounts.InternAddress(common.HexToAddress("0x00000000000000000000000000000000000000aa")),
- },
- }
- nonEmptyBytes, err := types.EncodeBlockAccessListBytes(nonEmpty)
- require.NoError(t, err)
- require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, block.Hash(), block.NumberU64(), nonEmptyBytes))
-
- data, err = rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64())
- require.NoError(t, err)
- require.Equal(t, nonEmptyBytes, data)
-
- decoded, err := types.DecodeBlockAccessListBytes(data)
- require.NoError(t, err)
- require.NoError(t, decoded.Validate())
- require.Equal(t, nonEmpty.Hash(), decoded.Hash())
-
- emptyBytes, err := types.EncodeBlockAccessListBytes(nil)
- require.NoError(t, err)
- require.NoError(t, rawdb.WriteBlockAccessListBytes(tx, block.Hash(), block.NumberU64(), emptyBytes))
-
- data, err = rawdb.ReadBlockAccessListBytes(tx, block.Hash(), block.NumberU64())
- require.NoError(t, err)
- require.Equal(t, emptyBytes, data)
-
- decoded, err = types.DecodeBlockAccessListBytes(data)
- require.NoError(t, err)
- // EIP-7928: Decoding 0xc0 (empty RLP list) should return an initialized empty slice,
- // rather than nil, to distinguish a valid empty BAL from a missing/pruned one.
- require.NotNil(t, decoded)
- require.Empty(t, decoded)
- require.NoError(t, decoded.Validate())
- require.Equal(t, empty.BlockAccessListHash, decoded.Hash())
-}
-
// Tests pre-shanghai body to make sure withdrawals doesn't panic
func TestPreShanghaiBodyNoPanicOnWithdrawals(t *testing.T) {
t.Parallel()
diff --git a/db/state/aggregator.go b/db/state/aggregator.go
index 33def812e3d..db05060e4a0 100644
--- a/db/state/aggregator.go
+++ b/db/state/aggregator.go
@@ -342,6 +342,14 @@ func (a *Aggregator) ConfigureDomains() error {
}
a.configured = true
+ // Attach the long-lived commitment branch cache to the commitment
+ // domain. Lifetime = aggregator lifetime; shared by every
+ // SharedDomains derived from this aggregator. Idempotent across
+ // repeated ConfigureDomains calls (early return above).
+ if cd := a.d[kv.CommitmentDomain]; cd != nil && cd.branchCache == nil {
+ cd.branchCache = commitment.NewBranchCache(commitment.DefaultBranchCacheTailCapacity)
+ }
+
if a.disableFsync {
for _, d := range a.d {
if d != nil {
@@ -2353,6 +2361,18 @@ func (a *Aggregator) BeginFilesRo() *AggregatorRoTx {
return ac
}
+// BranchCache returns the long-lived commitment branch cache attached to
+// this aggregator's commitment domain. Implements
+// commitment.BranchCacheProvider so the SharedDomains construction path
+// can fetch the cache via a tx.AggTx() type assertion without forcing
+// db/state/execctx to import db/state (which would create a cycle).
+func (at *AggregatorRoTx) BranchCache() *commitment.BranchCache {
+ if at.d[kv.CommitmentDomain] == nil {
+ return nil
+ }
+ return at.d[kv.CommitmentDomain].d.branchCache
+}
+
func (at *AggregatorRoTx) Dirs() datadir.Dirs { return at.a.dirs }
func (at *AggregatorRoTx) standaloneIIs() []*InvertedIndexRoTx { return at.iis[:at.iisCount] }
@@ -2401,31 +2421,50 @@ func (at *AggregatorRoTx) MeteredGetLatest(domain kv.Domain, k []byte, tx kv.Tx,
return at.getLatest(domain, k, tx, maxStep, metrics, start)
}
+// MeteredGetLatestWithTxN returns the high-water txN alongside (value,
+// step) for tagging BranchCache entries so UnwindTo can evict by
+// watermark. Non-CommitmentDomain reads return txN=0.
+func (at *AggregatorRoTx) MeteredGetLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *changeset.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error) {
+ return at.getLatestWithTxN(domain, k, tx, maxStep, metrics, start)
+}
+
func (at *AggregatorRoTx) getLatest(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *changeset.DomainMetrics, start time.Time) (v []byte, step kv.Step, ok bool, err error) {
+ v, step, _, ok, err = at.getLatestWithTxN(domain, k, tx, maxStep, metrics, start)
+ return v, step, ok, err
+}
+
+func (at *AggregatorRoTx) getLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *changeset.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error) {
if domain != kv.CommitmentDomain {
- return at.d[domain].getLatest(k, tx, maxStep, metrics, start)
+ v, step, ok, err = at.d[domain].getLatest(k, tx, maxStep, metrics, start)
+ return v, step, 0, ok, err
}
v, step, ok, err = at.d[domain].getLatestFromDb(k, tx)
if err != nil {
- return nil, kv.Step(0), false, err
+ return nil, kv.Step(0), 0, false, err
}
if ok && step <= maxStep {
if metrics != nil && dbg.KVReadLevelledMetrics {
metrics.UpdateDbReads(domain, start)
}
- return v, step, true, nil
+ // DB-sourced: tag with the step's high-water; the exact write
+ // txN isn't recoverable from the step-keyed record.
+ return v, step, lastTxNumOfStep(step, at.StepSize()), true, nil
}
v, found, fileStartTxNum, fileEndTxNum, err := at.d[domain].getLatestFromFiles(k, 0)
if !found {
- return nil, kv.Step(0), false, err
+ return nil, kv.Step(0), 0, false, err
}
if metrics != nil && dbg.KVReadLevelledMetrics {
- metrics.UpdateFileReads(domain, start)
+ // UpdateFileReadsUnique tracks total + distinct prefixes; the
+ // ratio is the read amplification factor (hot prefixes re-read).
+ metrics.UpdateFileReadsUnique(domain, k, start)
}
v, err = at.replaceShortenedKeysInBranch(k, commitment.BranchData(v), fileStartTxNum, fileEndTxNum)
- return v, kv.Step(fileEndTxNum / at.StepSize()), found, err
+ // File-sourced: tag with fileEndTxNum; snapshots are immutable and
+ // unwind can't cross them, so this is always <= any legal watermark.
+ return v, kv.Step(fileEndTxNum / at.StepSize()), fileEndTxNum, found, err
}
func (at *AggregatorRoTx) DebugGetLatestFromDB(domain kv.Domain, key []byte, tx kv.Tx) ([]byte, kv.Step, bool, error) {
diff --git a/db/state/changeset/state_changeset.go b/db/state/changeset/state_changeset.go
index 057eba818db..48ae1dc0260 100644
--- a/db/state/changeset/state_changeset.go
+++ b/db/state/changeset/state_changeset.go
@@ -17,6 +17,7 @@
package changeset
import (
+ "context"
"encoding/binary"
"fmt"
"math"
@@ -464,61 +465,201 @@ type DomainIOMetrics struct {
DbReadDuration time.Duration
FileReadCount int64
FileReadDuration time.Duration
+ // UniqueFileReadCount tracks distinct prefixes that ever fell through
+ // to a file read (process-cumulative). The ratio FileReadCount /
+ // UniqueFileReadCount is the read amplification factor: how many
+ // times each unique prefix was re-read from the file layer (cache
+ // misses on the same prefix). Updated by UpdateFileReadsUnique;
+ // gated by dbg.KVReadLevelledMetrics same as FileReadCount.
+ UniqueFileReadCount int64
+ // UniqueLenBuckets is the byte-length distribution of distinct
+ // prefixes seen by UpdateFileReadsUnique. The compact-encoded
+ // prefix length is 1 + ⌈depth/2⌉ bytes (HP encoding), so byte
+ // length maps to trie depth as follows:
+ // 0 : 1 byte (depth 0-1, root)
+ // 1 : 2-4 bytes (depth 2-7, top trunk)
+ // 2 : 5-8 bytes (depth 8-15)
+ // 3 : 9-16 bytes (depth 16-31)
+ // 4 : 17-32 bytes (depth 32-63, near account leaf)
+ // 5 : 33 bytes (depth 64, storage subtree root)
+ // 6 : 34-36 bytes (depth 65-70, storage subtree top)
+ // 7 : 37-44 bytes (depth 71-86, mid storage)
+ // 8 : 45-64 bytes (depth 87-127, leaf-parents and deep)
+ // 9 : 65+ bytes (out of range)
+ // Used to localise where the per-block file reads concentrate —
+ // e.g., are the 25K commitment reads on bloat workload
+ // storage-subtree-trunk (where per-contract pinning helps) or
+ // leaf-parents (where it doesn't)?
+ UniqueLenBuckets [10]int64
+
+ // StateCache hit/miss tracks the SharedDomains.stateCache layer
+ // specifically (the per-execution Account/Storage/Code cache), distinct
+ // from CacheReadCount which counts sd.mem and sd.parent.mem hits.
+ // Hit means stateCache.Get returned ok (we skipped MDBX+files).
+ // Miss means stateCache.Get returned !ok and we fell through to aggTx.
+ StateCacheHitCount int64
+ StateCacheMissCount int64
}
+// DomainMetrics is used in two roles. As the shared aggregate, it is read for a
+// snapshot under RLock and written only by Merge under Lock — so a separate
+// goroutine always sees a consistent snapshot. As a per-worker instance (one
+// per goroutine, via NewDomainMetrics), its Update* run lock-free because the
+// instance is single-owner; the worker's counts are folded into the aggregate
+// via Merge when the worker's task finishes. The embedded mutex therefore
+// guards aggregation/snapshot only — never the per-read Update* path.
type DomainMetrics struct {
sync.RWMutex
DomainIOMetrics
Domains map[kv.Domain]*DomainIOMetrics
}
+// NewDomainMetrics returns a fresh instance (used for the aggregate and for
+// per-worker instances).
+func NewDomainMetrics() *DomainMetrics {
+ return &DomainMetrics{Domains: map[kv.Domain]*DomainIOMetrics{}}
+}
+
+// Reset zeroes a per-worker instance after its counts have been merged into the
+// aggregate, so it can be reused for the next task. Lock-free: called by the
+// single owning worker. The Domains map is cleared but kept allocated.
+func (dm *DomainMetrics) Reset() {
+ if dm == nil {
+ return
+ }
+ dm.DomainIOMetrics = DomainIOMetrics{}
+ for k := range dm.Domains {
+ delete(dm.Domains, k)
+ }
+}
+
+// domainEntry returns the per-domain counters, creating them on first use.
+// Lock-free: only called on a single-owner per-worker instance (the per-domain
+// roll-up into the aggregate happens in Merge, under the aggregate's lock).
+func (dm *DomainMetrics) domainEntry(domain kv.Domain) *DomainIOMetrics {
+ d, ok := dm.Domains[domain]
+ if !ok {
+ d = &DomainIOMetrics{}
+ dm.Domains[domain] = d
+ }
+ return d
+}
+
+// Update* record one read into this (single-owner) instance, lock-free. Do NOT
+// call them on the shared aggregate from multiple goroutines — use a per-worker
+// instance and Merge it in.
func (dm *DomainMetrics) UpdateCacheReads(domain kv.Domain, start time.Time) {
- dm.Lock()
- defer dm.Unlock()
- dm.CacheReadCount++
- readDuration := time.Since(start)
- dm.CacheReadDuration += readDuration
- if d, ok := dm.Domains[domain]; ok {
- d.CacheReadCount++
- d.CacheReadDuration += readDuration
- } else {
- dm.Domains[domain] = &DomainIOMetrics{
- CacheReadCount: 1,
- CacheReadDuration: readDuration,
- }
+ if dm == nil {
+ return
}
+ d := time.Since(start)
+ dm.CacheReadCount++
+ dm.CacheReadDuration += d
+ de := dm.domainEntry(domain)
+ de.CacheReadCount++
+ de.CacheReadDuration += d
}
func (dm *DomainMetrics) UpdateDbReads(domain kv.Domain, start time.Time) {
- dm.Lock()
- defer dm.Unlock()
+ if dm == nil {
+ return
+ }
+ d := time.Since(start)
dm.DbReadCount++
- readDuration := time.Since(start)
- dm.DbReadDuration += readDuration
- if d, ok := dm.Domains[domain]; ok {
- d.DbReadCount++
- d.DbReadDuration += readDuration
- } else {
- dm.Domains[domain] = &DomainIOMetrics{
- DbReadCount: 1,
- DbReadDuration: readDuration,
- }
+ dm.DbReadDuration += d
+ de := dm.domainEntry(domain)
+ de.DbReadCount++
+ de.DbReadDuration += d
+}
+
+func (dm *DomainMetrics) UpdateStateCacheHit(domain kv.Domain) {
+ if dm == nil {
+ return
+ }
+ dm.StateCacheHitCount++
+ dm.domainEntry(domain).StateCacheHitCount++
+}
+
+func (dm *DomainMetrics) UpdateStateCacheMiss(domain kv.Domain) {
+ if dm == nil {
+ return
}
+ dm.StateCacheMissCount++
+ dm.domainEntry(domain).StateCacheMissCount++
}
func (dm *DomainMetrics) UpdateFileReads(domain kv.Domain, start time.Time) {
+ if dm == nil {
+ return
+ }
+ d := time.Since(start)
+ dm.FileReadCount++
+ dm.FileReadDuration += d
+ de := dm.domainEntry(domain)
+ de.FileReadCount++
+ de.FileReadDuration += d
+}
+
+// UpdateFileReadsUnique records a file read. The distinct-prefix (read
+// amplification) tracking it used to do was removed: it had no consumer and
+// cost a string alloc + map insert on every file read. Thin alias so call sites
+// are unchanged.
+func (dm *DomainMetrics) UpdateFileReadsUnique(domain kv.Domain, key []byte, start time.Time) {
+ dm.UpdateFileReads(domain, start)
+}
+
+// Merge folds a finished per-worker instance into this aggregate under the
+// aggregate's lock — the join. This and the RLock'd snapshot are the only
+// protected operations.
+func (dm *DomainMetrics) Merge(src *DomainMetrics) {
+ if src == nil {
+ return
+ }
dm.Lock()
defer dm.Unlock()
- dm.FileReadCount++
- readDuration := time.Since(start)
- dm.FileReadDuration += readDuration
- if d, ok := dm.Domains[domain]; ok {
- d.FileReadCount++
- d.FileReadDuration += readDuration
- } else {
- dm.Domains[domain] = &DomainIOMetrics{
- FileReadCount: 1,
- FileReadDuration: readDuration,
- }
+ addIOMetrics(&dm.DomainIOMetrics, &src.DomainIOMetrics)
+ for d, sd := range src.Domains {
+ addIOMetrics(dm.domainEntry(d), sd)
+ }
+}
+
+func addIOMetrics(dst, src *DomainIOMetrics) {
+ dst.CacheReadCount += src.CacheReadCount
+ dst.CacheReadDuration += src.CacheReadDuration
+ dst.CacheGetCount += src.CacheGetCount
+ dst.CachePutCount += src.CachePutCount
+ dst.CacheGetSize += src.CacheGetSize
+ dst.CacheGetKeySize += src.CacheGetKeySize
+ dst.CacheGetValueSize += src.CacheGetValueSize
+ dst.CachePutSize += src.CachePutSize
+ dst.CachePutKeySize += src.CachePutKeySize
+ dst.CachePutValueSize += src.CachePutValueSize
+ dst.DbReadCount += src.DbReadCount
+ dst.DbReadDuration += src.DbReadDuration
+ dst.FileReadCount += src.FileReadCount
+ dst.FileReadDuration += src.FileReadDuration
+ dst.StateCacheHitCount += src.StateCacheHitCount
+ dst.StateCacheMissCount += src.StateCacheMissCount
+}
+
+// ctxKey is this package's typed-int context-key namespace (the node/app model).
+type ctxKey int
+
+const ckMetrics ctxKey = iota
+
+// ContextWithMetrics attaches a per-worker metrics instance to ctx; the read
+// path extracts it via MetricsFromContext so a concurrent worker accumulates
+// into its own instance and never touches shared state.
+func ContextWithMetrics(ctx context.Context, m *DomainMetrics) context.Context {
+ return context.WithValue(ctx, ckMetrics, m)
+}
+
+// MetricsFromContext returns the per-worker instance, or nil (reads then collect
+// no metrics — a valid no-op).
+func MetricsFromContext(ctx context.Context) *DomainMetrics {
+ if ctx == nil {
+ return nil
}
+ m, _ := ctx.Value(ckMetrics).(*DomainMetrics)
+ return m
}
diff --git a/db/state/commitment_preload_test.go b/db/state/commitment_preload_test.go
new file mode 100644
index 00000000000..c7eb557f4a7
--- /dev/null
+++ b/db/state/commitment_preload_test.go
@@ -0,0 +1,147 @@
+// 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.
+
+package state
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
+ "github.com/erigontech/erigon/db/state/statecfg"
+ "github.com/erigontech/erigon/execution/commitment"
+ "github.com/erigontech/erigon/execution/commitment/nibbles"
+)
+
+// TestCommitmentPreloadPhase1Cursor mirrors triggerTrunkPreload's phase-1
+// dbPrefetch in db/state/execctx/domain_shared.go: a DupSort cursor over
+// TblCommitmentVals walked via Seek + NextNoDup, decoding v[8:] as the value
+// (dups = invertedStep||value, latest-first). Asserts that for a populated
+// CommitmentDomain DB, the walk over the contract's two parity ranges
+// (commitment.ContractTrunkKeyRanges) picks up the LATEST value per key and
+// excludes keys outside the ranges (foreign contract).
+//
+// The preload-side unit test (commitment.TestPreloadParallel_DbHitsShadowFiles)
+// covers the algorithm with a fake dbBranches map; this covers the cursor
+// mechanics against the real domain writer encoding.
+func TestCommitmentPreloadPhase1Cursor(t *testing.T) {
+ if testing.Short() {
+ t.Skip()
+ }
+ t.Parallel()
+ logger := log.New()
+ db, d := testDbAndDomainOfStep(t, statecfg.Schema.CommitmentDomain, 16, logger)
+
+ ctx := t.Context()
+ tx, err := db.BeginRw(ctx)
+ require.NoError(t, err)
+ defer tx.Rollback()
+
+ domainRoTx := d.beginForTests()
+ defer domainRoTx.Close()
+ writer := domainRoTx.NewWriter()
+ defer writer.Close()
+
+ // Pick two contracts so we can also assert foreign-contract keys are
+ // excluded from the target contract's parity ranges.
+ contractA := make([]byte, 32)
+ for i := range contractA {
+ contractA[i] = byte(0x10 + i)
+ }
+ contractB := make([]byte, 32)
+ for i := range contractB {
+ contractB[i] = byte(0x80 - i)
+ }
+ nibA := commitment.ContractNibbles(contractA)
+ nibB := commitment.ContractNibbles(contractB)
+
+ // keyAt builds a real commitment-domain key: HexToCompact(contractNibbles
+ // ++ slotPath). The path's parity falls out of len(slotPath).
+ keyAt := func(contractNibbles, slotPath []byte) []byte {
+ full := append(append([]byte{}, contractNibbles...), slotPath...)
+ k := nibbles.HexToCompact(full)
+ return common.Copy(k)
+ }
+
+ // A small set spanning even (depths 64, 66, 68) and odd (65, 67, 69) parity
+ // for contractA, plus a foreign key under contractB that must NOT show up.
+ slotPaths := [][]byte{
+ {}, // depth 64 (even, == subtree root)
+ {0x3}, // depth 65 (odd)
+ {0x4, 0x5}, // depth 66 (even)
+ {0x6, 0x7, 0x8}, // depth 67 (odd)
+ {0x9, 0xa, 0xb, 0xc}, // depth 68 (even)
+ {0xd, 0xe, 0xf, 0x0, 0x1}, // depth 69 (odd)
+ }
+
+ // Write each key at multiple txNums so it has multiple steps in MDBX (the
+ // DupSort dups). prev tracks the previous value (PutWithPrev contract).
+ expected := map[string][]byte{}
+ prev := map[string][]byte{}
+ writeAt := func(key []byte, txNum uint64, marker byte) {
+ v := make([]byte, 8)
+ v[0] = marker
+ v[7] = byte(txNum)
+ err := writer.PutWithPrev(key, v, txNum, prev[string(key)])
+ require.NoError(t, err)
+ prev[string(key)] = common.Copy(v)
+ expected[string(key)] = common.Copy(v)
+ }
+
+ // 3 successive writes per key at txNums 1, 5, 9 (latest = txNum 9 — the
+ // value we expect the cursor to return).
+ for i, sp := range slotPaths {
+ k := keyAt(nibA, sp)
+ for ti, tn := range []uint64{1, 5, 9} {
+ writeAt(k, tn, byte(0xA0+i*4+ti)) // distinctive marker per (key, step)
+ }
+ }
+ // Foreign contract key — must NOT appear in contractA's ranges.
+ foreign := keyAt(nibB, []byte{0x4, 0x5})
+ writeAt(foreign, 7, 0xFF)
+
+ require.NoError(t, writer.Flush(ctx, tx))
+
+ // Walk the cursor exactly like dbPrefetch does.
+ evenFrom, evenTo, oddFrom, oddTo := commitment.ContractTrunkKeyRanges(nibA)
+ c, err := tx.CursorDupSort(kv.TblCommitmentVals)
+ require.NoError(t, err)
+ defer c.Close()
+
+ got := map[string][]byte{}
+ scanRange := func(from, to []byte) {
+ k, v, err := c.Seek(from)
+ for ; k != nil; k, v, err = c.NextNoDup() {
+ require.NoError(t, err)
+ if bytes.Compare(k, to) >= 0 {
+ break
+ }
+ require.GreaterOrEqual(t, len(v), 8, "dup value must be invStep(8B)||value")
+ got[string(common.Copy(k))] = common.Copy(v[8:])
+ }
+ }
+ scanRange(evenFrom, evenTo)
+ scanRange(oddFrom, oddTo)
+
+ // Every contractA branch must be present with its LATEST value (txNum=9).
+ for _, sp := range slotPaths {
+ k := keyAt(nibA, sp)
+ want, ok := expected[string(k)]
+ require.True(t, ok)
+ g, present := got[string(k)]
+ require.Truef(t, present, "key %x (depth %d) missing from cursor walk", k, 64+len(sp))
+ require.Equalf(t, want, g, "key %x: cursor returned the wrong value (not the latest step)", k)
+ }
+ // Foreign-contract key must be absent.
+ _, leaked := got[string(foreign)]
+ require.Falsef(t, leaked, "foreign key %x leaked into contractA's parity ranges", foreign)
+}
diff --git a/db/state/domain.go b/db/state/domain.go
index 347e322836b..6050a314e89 100644
--- a/db/state/domain.go
+++ b/db/state/domain.go
@@ -51,6 +51,7 @@ import (
"github.com/erigontech/erigon/db/state/statecfg"
"github.com/erigontech/erigon/db/version"
"github.com/erigontech/erigon/diagnostics/metrics"
+ "github.com/erigontech/erigon/execution/commitment"
)
var (
@@ -88,6 +89,13 @@ type Domain struct {
checker *DependencyIntegrityChecker
+ // branchCache is the long-lived commitment-trie branch cache pinned to
+ // the aggregator. Set in ConfigureDomains for the commitment domain
+ // only; nil for every other domain. Lifetime = aggregator lifetime,
+ // independent of any single tx, batch, or SharedDomains. See
+ // commitment.BranchCacheProvider for the SDs' access path.
+ branchCache *commitment.BranchCache
+
// _testBuildAccessorHook - test-only: called with the recsplit before the build loop in buildHashMapAccessor
_testBuildAccessorHook func(rs *recsplit.RecSplit)
}
@@ -135,6 +143,13 @@ func (d *Domain) SetChecker(checker *DependencyIntegrityChecker) {
d.checker = checker
}
+// BranchCache returns the long-lived commitment-trie branch cache attached
+// to this domain. Non-nil only on the commitment domain. Lifetime is the
+// owning Aggregator's lifetime.
+func (d *Domain) BranchCache() *commitment.BranchCache {
+ return d.branchCache
+}
+
func (d *Domain) kvNewFilePath(fromStep, toStep kv.Step) string {
return filepath.Join(d.dirs.SnapDomain, fmt.Sprintf("%s-%s.%d-%d.kv", d.FileVersion.DataKV.String(), d.FilenameBase, fromStep, toStep))
}
@@ -1622,7 +1637,7 @@ func (dt *DomainRoTx) getLatest(key []byte, roTx kv.Tx, maxStep kv.Step, metrics
v, foundInFile, _, endTxNum, err := dt.getLatestFromFiles(key, 0)
if metrics != nil && dbg.KVReadLevelledMetrics {
- metrics.UpdateFileReads(dt.name, start)
+ metrics.UpdateFileReadsUnique(dt.name, key, start)
}
if err != nil {
diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go
index 89ad7a1d0d8..df64ec75444 100644
--- a/db/state/execctx/domain_shared.go
+++ b/db/state/execctx/domain_shared.go
@@ -19,10 +19,13 @@ package execctx
import (
"bytes"
"context"
+ "encoding/hex"
"errors"
"fmt"
"math"
+ "os"
"runtime"
+ "strings"
"sync"
"sync/atomic"
"time"
@@ -36,12 +39,12 @@ import (
"github.com/erigontech/erigon/db/kv/order"
"github.com/erigontech/erigon/db/kv/rawdbv3"
"github.com/erigontech/erigon/db/state/changeset"
+ "github.com/erigontech/erigon/db/state/statecfg"
"github.com/erigontech/erigon/diagnostics/metrics"
"github.com/erigontech/erigon/execution/cache"
"github.com/erigontech/erigon/execution/commitment"
"github.com/erigontech/erigon/execution/commitment/commitmentdb"
-
- "github.com/erigontech/erigon/db/state/statecfg"
+ "github.com/erigontech/erigon/execution/types/accounts"
)
var (
@@ -159,6 +162,24 @@ type SharedDomains struct {
// (now tx-granular) at sd.Flush time, and delete this Mutex + the
// SetChangesetAccumulator/GetChangesetAccumulator API entirely.
changesetMu sync.Mutex
+
+ // branchCache is the aggregator-scope commitment-branch cache. It sits
+ // behind sd.mem and sd.parent.mem in the read chain (consulted only after
+ // both miss, before the aggTx files/MDBX read), so writers' in-flight
+ // bytes always mask the cache and cross-SD pollution is impossible.
+ // May be nil for test setups whose AggTx doesn't implement
+ // commitment.BranchCacheProvider.
+ branchCache *commitment.BranchCache
+
+ // adaptivePinController owns the policy that decides which
+ // contracts get pinned based on observed miss pressure. nil when
+ // branchCache is nil or when the adaptive layer is disabled. The
+ // controller's miss callback is wired onto branchCache via Bind
+ // during EnableParaTrieDB; OnBlockComplete fires from sd.Flush.
+ // Uses sd.txNum as the "block tick" for cooldown semantics — Flush
+ // is per-batch so the controller's tick maps to batches not blocks,
+ // but cooldown thresholds are tunable to compensate.
+ adaptivePinController *commitment.AdaptivePinController
}
// PickTrieVariant returns the commitment trie variant selected by the
@@ -190,7 +211,30 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger,
}
sd.mem = tx.Debug().NewMemBatch(&sd.metrics)
- sd.sdCtx = commitmentdb.NewSharedDomainsCommitmentContext(sd, commitment.ModeDirect, tx.Debug().Dirs().Tmp, trieCfg)
+ // Fetch the aggregator-scope branch cache (lives on the commitment
+ // Domain, shared across all SharedDomains derived from this
+ // aggregator). The duck-typed BranchCacheProvider lookup avoids
+ // importing db/state directly — db/state already imports execctx, so
+ // the reverse import would create a cycle.
+ var branchCache *commitment.BranchCache
+ if p, ok := tx.AggTx().(commitment.BranchCacheProvider); ok {
+ branchCache = p.BranchCache()
+ }
+ sd.branchCache = branchCache
+ sd.sdCtx = commitmentdb.NewSharedDomainsCommitmentContext(sd, commitment.ModeDirect, tx.Debug().Dirs().Tmp, trieCfg, branchCache)
+
+ // Construct the adaptive pin controller alongside the cache. The
+ // controller observes per-contract miss pressure and decides
+ // promotions/extensions/demotions; without binding (deferred to
+ // EnableParaTrieDB so the db is available for the reader path)
+ // the controller is inert.
+ if branchCache != nil && !dbg.EnvBool("DISABLE_ADAPTIVE_PIN", false) {
+ sd.adaptivePinController = commitment.NewAdaptivePinController(
+ branchCache,
+ commitment.DefaultAdaptivePinControllerConfig(),
+ logger,
+ )
+ }
_, blockNum, err := sd.SeekCommitment(ctx, tx)
if err != nil {
@@ -208,6 +252,16 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger,
}
}
+ // PIN_CONTRACT_TRUNKS preload runs from EnableParaTrieDB (called by
+ // the staged sync exec stage) NOT here in NewSharedDomains — at SD
+ // construction time we'd block the engine HTTP handler for ~3-4s
+ // while preload reads, causing the bench's first NewPayload to be
+ // dropped. EnableParaTrieDB runs in the staged sync init goroutine,
+ // not the request handler, AND has access to the kv.TemporalRoDB
+ // needed to spawn an own-tx preload goroutine that doesn't share
+ // cursors with the main pipeline (the cause of the earlier
+ // cgo-pointer panic).
+
return sd, nil
}
@@ -388,10 +442,38 @@ func (sd *SharedDomains) domainPutNoLock(domain kv.Domain, roTx kv.TemporalTx, k
type temporalGetter struct {
sd *SharedDomains
tx kv.TemporalTx
+ // m is an optional per-worker metrics instance to record reads into. nil
+ // (the AsGetter default) collects nothing — there is no process-wide
+ // accumulator, since AsGetter is used by many concurrent goroutines (RPC,
+ // engine) where a shared one would be raced/unbounded. Exec workers pass
+ // their own instance via AsGetterMetered and merge it at task end.
+ m *changeset.DomainMetrics
}
func (gt *temporalGetter) GetLatest(name kv.Domain, k []byte) (v []byte, step kv.Step, err error) {
- return gt.sd.GetLatest(name, gt.tx, k)
+ return gt.sd.getLatestMetered(name, gt.tx, k, gt.m)
+}
+
+// GetLatestContext is the context-aware read: it records into the per-worker,
+// lock-free accumulator carried by ctx (a nil ctx-value collects no metrics).
+// Concurrent workers (trie-warmup goroutines) pass their own accumulator via
+// ctx, so they neither share metrics state with the main goroutine nor take any
+// lock. Optional method — callers type-assert for it (mirrors the existing
+// AggregatorRoTx.MeteredGetLatest pattern). The plan is to migrate GetLatest
+// callers onto this ctx-carrying form in a follow-up, then drop the split.
+func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, k []byte) (v []byte, step kv.Step, err error) {
+ return gt.sd.getLatestMetered(name, gt.tx, k, changeset.MetricsFromContext(ctx))
+}
+
+// GetCodeSize returns the length of the code at addr without loading the
+// bytes. Returns (size, true, nil) on size-cache hit, (size, true, nil)
+// after a full-bytes load+populate, or (0, false, nil) when the account
+// has no code. Errors propagate normally.
+//
+// Callers (ReaderV3.ReadAccountCodeSize, etc.) type-assert on this method
+// so the existing kv.TemporalGetter interface is unchanged.
+func (gt *temporalGetter) GetCodeSize(addr []byte) (int, bool, error) {
+ return gt.sd.GetCodeSize(gt.tx, addr)
}
func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) {
@@ -416,7 +498,28 @@ func (up *unmarkedPutter) Put(num kv.Num, v []byte) error {
}
func (sd *SharedDomains) AsGetter(tx kv.TemporalTx) kv.TemporalGetter {
- return &temporalGetter{sd, tx}
+ return &temporalGetter{sd: sd, tx: tx}
+}
+
+// AsGetterNoMetrics is an explicit-intent alias of AsGetter (collects no
+// metrics), for concurrent callers (RPC/engine) where that is deliberate.
+func (sd *SharedDomains) AsGetterNoMetrics(tx kv.TemporalTx) kv.TemporalGetter {
+ return &temporalGetter{sd: sd, tx: tx}
+}
+
+// AsGetterMetered returns a getter that records reads into the caller's own
+// per-worker metrics instance m. m must be single-owner (one goroutine); the
+// caller merges it into the shared metrics via MergeMetrics at task end (a lock
+// per task, not per read) and Resets it. Used by parallel-exec workers.
+func (sd *SharedDomains) AsGetterMetered(tx kv.TemporalTx, m *changeset.DomainMetrics) kv.TemporalGetter {
+ return &temporalGetter{sd: sd, tx: tx, m: m}
+}
+
+// MergeMetrics folds a worker's lock-free accumulator into the shared
+// DomainMetrics under a single lock. Called once when a worker finishes (not
+// per read), so the global metrics write-lock stays off the hot path.
+func (sd *SharedDomains) MergeMetrics(wm *changeset.DomainMetrics) {
+ sd.metrics.Merge(wm)
}
// LockChangesetAccumulator and UnlockChangesetAccumulator bracket a
@@ -507,11 +610,40 @@ func (sd *SharedDomains) SavePastChangesetAccumulator(blockHash common.Hash, blo
}
func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumber uint64) ([kv.DomainLen][]kv.DomainEntryDiff, bool, error) {
- return sd.mem.GetDiffset(tx, blockHash, blockNumber)
+ d, ok, err := sd.mem.GetDiffset(tx, blockHash, blockNumber)
+ if ok || err != nil {
+ return d, ok, err
+ }
+ // Resolve through the parent chain: a fork-validation SD is freshly
+ // constructed with an empty mem batch, so the diffsets of the canonical
+ // blocks it must unwind live in the canonical generation's
+ // pastChangesAccumulator, reachable only via the parent link. Without
+ // this an unwind silently runs with no unwind set.
+ if sd.parent != nil {
+ return sd.parent.GetDiffset(tx, blockHash, blockNumber)
+ }
+ return d, ok, err
}
func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][]kv.DomainEntryDiff) {
sd.mem.Unwind(txNumUnwindTo, changeset)
+ // Tx-aware unwind of the commitment BranchCache: every cached branch
+ // whose bytes belong to the rolled-back window (txNum above the unwind
+ // point, current epoch) is now stale vs the post-unwind canonical state.
+ // The changeset-gated Invalidate below only covers keys the unwound
+ // blocks explicitly wrote — it misses entries seeded by the read-pop and
+ // the trunk preload, which is what left stale committed branches that a
+ // fork-validation then read as a wrong trie root. Unwind(txNum) drops all
+ // of them (lazily, by epoch+floor) while keeping at/below-floor entries
+ // — including frozen preloads — warm.
+ if sd.branchCache != nil {
+ sd.branchCache.Unwind(txNumUnwindTo)
+ if changeset != nil {
+ for _, diff := range changeset[kv.CommitmentDomain] {
+ sd.branchCache.Invalidate([]byte(diff.Key))
+ }
+ }
+ }
}
func (sd *SharedDomains) Trace() bool {
@@ -576,8 +708,11 @@ func (sd *SharedDomains) Logger() log.Logger { return sd.logger }
// SetStateCache sets the state cache for faster lookups.
func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) {
if !dbg.UseStateCache || stateCache == nil {
+ sd.logger.Info("[state-cache] SetStateCache skipped",
+ "useStateCache", dbg.UseStateCache, "stateCacheNil", stateCache == nil)
return
}
+ sd.logger.Info("[state-cache] SetStateCache enabled on SD")
sd.stateCache = stateCache
}
@@ -586,6 +721,31 @@ func (sd *SharedDomains) GetStateCache() *cache.StateCache {
return sd.stateCache
}
+// ProbeReadLayers samples each independent state layer (this SD's mem,
+// the parent SD's mem, and direct MDBX via tx.GetLatest) and returns
+// the bytes from each. Read-only, intended for divergence-detection
+// diagnostics — pinpoints which layer holds bytes that disagree with
+// the BranchCache. Bytes are copied so the caller can hold them past
+// tx lifetime.
+func (sd *SharedDomains) ProbeReadLayers(domain kv.Domain, tx kv.TemporalTx, key []byte) (mem, parentMem, mdbx []byte, memOk, parentOk bool) {
+ if v, _, ok := sd.mem.GetLatest(domain, key); ok {
+ memOk = true
+ mem = append([]byte(nil), v...)
+ }
+ if sd.parent != nil {
+ if v, _, ok := sd.parent.mem.GetLatest(domain, key); ok {
+ parentOk = true
+ parentMem = append([]byte(nil), v...)
+ }
+ }
+ if tx != nil {
+ if v, _, err := tx.GetLatest(domain, key); err == nil {
+ mdbx = append([]byte(nil), v...)
+ }
+ }
+ return
+}
+
func (sd *SharedDomains) ClearRam(resetCommitment bool) {
// When the commitment calculator goroutine owns the Updates buffer,
// skip ClearRam on the commitment context to avoid concurrent btree access.
@@ -674,11 +834,176 @@ func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error {
return err
}
}
+ // Refresh the caches with the bytes about to land in MDBX, atomically
+ // with the flush. FlushWithCallback holds the latestState write lock
+ // for the full window so a concurrent DomainPut cannot interleave
+ // between the cache update and the MDBX write — readers see either
+ // the local sd.mem value (during the lock) or the cached/MDBX value
+ // (after release). The caches are refreshed ONLY here, on flush —
+ // never per-write — so they mirror committed, fork-agnostic state:
+ // CommitmentDomain → BranchCache, Accounts/Storage/Code → StateCache.
+ if sd.branchCache != nil || sd.stateCache != nil {
+ if err := sd.mem.FlushWithCallback(ctx, tx, func(domain kv.Domain, k []byte, v []byte, txNum uint64) {
+ switch domain {
+ case kv.CommitmentDomain:
+ if sd.branchCache != nil {
+ if len(v) == 0 {
+ sd.branchCache.Invalidate(k)
+ } else {
+ sd.branchCache.Put(k, v, txNum, "sd.Flush")
+ }
+ }
+ case kv.AccountsDomain:
+ if sd.stateCache != nil {
+ if len(v) == 0 {
+ sd.stateCache.Delete(kv.AccountsDomain, k)
+ sd.stateCache.Delete(kv.CodeDomain, k)
+ sd.stateCache.DeleteAddrCodeHash(k)
+ } else {
+ sd.stateCache.Put(kv.AccountsDomain, k, v, txNum)
+ sd.stateCache.DeleteAddrCodeHash(k)
+ }
+ }
+ case kv.StorageDomain:
+ if sd.stateCache != nil {
+ if len(v) == 0 {
+ sd.stateCache.Delete(kv.StorageDomain, k)
+ } else {
+ sd.stateCache.Put(kv.StorageDomain, k, v, txNum)
+ }
+ }
+ case kv.CodeDomain:
+ if sd.stateCache != nil {
+ if len(v) == 0 {
+ sd.stateCache.Delete(kv.CodeDomain, k)
+ } else {
+ sd.stateCache.Put(kv.CodeDomain, k, v, txNum)
+ }
+ }
+ }
+ }); err != nil {
+ return err
+ }
+ if sd.branchCache != nil {
+ // Adaptive controller hook: now that the cache reflects this
+ // batch's writes, decide promotions / extensions / demotions
+ // for the contracts whose miss pressure crossed thresholds.
+ // The reader uses the in-flight Flush tx so pre-cache reads
+ // see the just-committed bytes (the tx's own writes are
+ // visible to itself).
+ if sd.adaptivePinController != nil {
+ if ttx, ok := tx.(kv.TemporalTx); ok {
+ reader := func(prefix []byte) ([]byte, uint64, bool, error) {
+ v, step, err := ttx.GetLatest(kv.CommitmentDomain, prefix)
+ if err != nil {
+ return nil, 0, false, err
+ }
+ return v, uint64(step), len(v) > 0, nil
+ }
+ // Install per-call parallel-mode plumbing so the
+ // controller's promote/extend uses the wave-BFS file-only
+ // resolver. The factory captures ttx; cleared after the
+ // call so a future OnBlockComplete can't reach a stale tx.
+ factory := func() (commitment.BatchBranchResolver, func(), error) {
+ resolve := func(keys [][]byte) ([][]byte, error) {
+ d := ttx.Debug()
+ vals := make([][]byte, len(keys))
+ for i, k := range keys {
+ v, found, _, _, err := d.GetLatestFromFiles(kv.CommitmentDomain, k, 0)
+ if err != nil {
+ return nil, err
+ }
+ if found {
+ vc := make([]byte, len(v))
+ copy(vc, v)
+ vals[i] = vc
+ }
+ }
+ return vals, nil
+ }
+ return resolve, nil, nil
+ }
+ provider := func(contractHash []byte) map[string][]byte {
+ m := map[string][]byte{}
+ c, cerr := ttx.CursorDupSort(kv.TblCommitmentVals)
+ if cerr != nil {
+ return m
+ }
+ defer c.Close()
+ evenFrom, evenTo, oddFrom, oddTo := commitment.ContractTrunkKeyRanges(commitment.ContractNibbles(contractHash))
+ scan := func(from, to []byte) {
+ for k, v, err := c.Seek(from); k != nil && err == nil; k, v, err = c.NextNoDup() {
+ if bytes.Compare(k, to) >= 0 {
+ return
+ }
+ if len(v) < 8 {
+ continue
+ }
+ m[string(common.Copy(k))] = common.Copy(v[8:])
+ }
+ }
+ scan(evenFrom, evenTo)
+ scan(oddFrom, oddTo)
+ return m
+ }
+ sd.adaptivePinController.SetParallelMode(factory, provider)
+ sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader)
+ sd.adaptivePinController.SetParallelMode(nil, nil)
+ }
+ }
+ // Refresh pinned-tier gauges once per Flush — once-per-batch
+ // cadence avoids per-lookup cost in the hot read path.
+ sd.branchCache.PublishMetrics()
+ }
+ return nil
+ }
return sd.mem.Flush(ctx, tx)
}
-// TemporalDomain satisfaction
+// ClearBranchCache empties the aggregator-scope commitment BranchCache.
+// Use after operations that mutate commitment state outside the normal
+// sd.Flush callback path — notably SetHead unwind, which truncates
+// commitment domain history but does not re-write all the keys whose
+// cached values are now stale. Without this call, the next FCU sees
+// the pre-unwind KeyCommitmentState and trips ErrBehindCommitment.
+func (sd *SharedDomains) ClearBranchCache() {
+ if sd.branchCache != nil {
+ sd.branchCache.Clear()
+ }
+}
+
+// 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) {
+ return sd.getLatestMetered(domain, tx, k, nil)
+}
+
+// GetLatestContext is the context-aware read for callers that read on behalf of
+// a concurrent worker: metrics go to the per-worker, lock-free accumulator
+// carried by ctx (nil ctx-value => no metrics). Lets a worker's reader meter
+// without any shared accumulator or lock. Mirrors temporalGetter.GetLatestContext
+// for readers that hold the SD directly (e.g. the committer's asOfStateReader).
+func (sd *SharedDomains) GetLatestContext(ctx context.Context, domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) {
+ return sd.getLatestMetered(domain, tx, k, changeset.MetricsFromContext(ctx))
+}
+
+// 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
+// into the shared DomainMetrics later via Merge.
+func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k []byte, wm *changeset.DomainMetrics) (v []byte, step kv.Step, err error) {
if tx == nil {
return nil, 0, errors.New("sd.GetLatest: unexpected nil tx")
}
@@ -693,7 +1018,7 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte)
// so the value is already accessible without caching it again.
if v, step, ok := sd.mem.GetLatest(domain, k); ok {
if dbg.KVReadLevelledMetrics {
- sd.metrics.UpdateCacheReads(domain, start)
+ wm.UpdateCacheReads(domain, start)
}
return v, step, nil
} else {
@@ -706,7 +1031,7 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte)
if sd.parent != nil {
if v, step, ok := sd.parent.mem.GetLatest(domain, k); ok {
if dbg.KVReadLevelledMetrics {
- sd.metrics.UpdateCacheReads(domain, start)
+ wm.UpdateCacheReads(domain, start)
}
return v, step, nil
} else {
@@ -719,18 +1044,32 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte)
type MeteredGetter interface {
MeteredGetLatest(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *changeset.DomainMetrics, start time.Time) (v []byte, step kv.Step, ok bool, err error)
}
+ // MeteredGetterWithTxN exposes the txN of the read so the
+ // BranchCache entry can be tagged; falls back to MeteredGetter
+ // when only the legacy interface is implemented (test stubs).
+ type MeteredGetterWithTxN interface {
+ MeteredGetLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *changeset.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error)
+ }
// stateCache holds in-flight values from previous transactions in the same batch
// that haven't been flushed to DB yet. Early return keeps correctness AND performance.
if sd.stateCache != nil {
- if v, ok := sd.stateCache.Get(domain, k); ok {
+ v, ok := sd.stateCache.Get(domain, k)
+ if dbg.KVReadLevelledMetrics {
+ if ok {
+ wm.UpdateStateCacheHit(domain)
+ } else {
+ wm.UpdateStateCacheMiss(domain)
+ }
+ }
+ if ok {
if dbg.AssertStateCache {
// 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.
var vDB []byte
if aggTx, okAgg := tx.AggTx().(MeteredGetter); okAgg {
- vDB, _, _, _ = aggTx.MeteredGetLatest(domain, k, tx, maxStep, &sd.metrics, start)
+ vDB, _, _, _ = aggTx.MeteredGetLatest(domain, k, tx, maxStep, wm, start)
} else {
vDB, _, _ = tx.GetLatest(domain, k)
}
@@ -743,8 +1082,57 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte)
}
}
+ // branchCache sits between sd.mem/parent.mem and the aggTx files for
+ // CommitmentDomain only. It mirrors MDBX-flushed bytes; writers' fresh
+ // bytes are held in sd.mem above, so a cache hit here is always
+ // equivalent to reading from MDBX. Cleared by sd.Flush so a new MDBX
+ // state never coexists with stale cache entries.
+ if domain == kv.CommitmentDomain && sd.branchCache != nil {
+ if cv, cTxNum, 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.
+ cStep := kv.Step(cTxNum / sd.StepSize())
+ if cStep <= maxStep {
+ if dbg.EnvBool("DBG_BC", false) {
+ var vDB []byte
+ if aggTx, okAgg := tx.AggTx().(MeteredGetter); okAgg {
+ vDB, _, _, _ = aggTx.MeteredGetLatest(domain, k, tx, maxStep, wm, start)
+ } else {
+ vDB, _, _ = tx.GetLatest(domain, k)
+ }
+ if !bytes.Equal(cv, vDB) {
+ fmt.Fprintf(os.Stderr, "[BC-DIVERGE] key=%x cStep=%d maxStep=%d cTxNum=%d sdTxNum=%d cached=%x db=%x\n", k, cStep, maxStep, cTxNum, sd.txNum, cv, vDB)
+ }
+ }
+ return cv, cStep, nil
+ }
+ }
+ }
+
+ // CodeDomain L2b transparent bypass: many addresses share one codeHash
+ // (proxies, factory-deployed clones, ERC-20 holder set, OpenZeppelin
+ // templates). Today's addr-keyed cache misses on every fresh address
+ // even when the bytecode is already in the L2b layer. Resolve the
+ // codeHash from the account record (warm in AccountsDomain cache for
+ // any account the EVM has already loaded) and probe L2b before paying
+ // the code-file accessor stack cost.
+ var codeEthHash []byte
+ if domain == kv.CodeDomain && sd.stateCache != nil {
+ if h := sd.codeHashForAddr(tx, k); len(h) > 0 {
+ codeEthHash = h
+ if cv, ok := sd.stateCache.GetCodeByHash(codeEthHash); ok {
+ return cv, 0, nil
+ }
+ }
+ }
+
if aggTx, ok := tx.AggTx().(MeteredGetter); ok {
- v, step, _, err = aggTx.MeteredGetLatest(domain, k, tx, maxStep, &sd.metrics, start)
+ v, step, _, err = aggTx.MeteredGetLatest(domain, k, tx, maxStep, wm, start)
} else {
v, step, err = tx.GetLatest(domain, k)
}
@@ -752,14 +1140,158 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte)
return nil, 0, fmt.Errorf("storage %x read error: %w", k, err)
}
- // Populate state cache on successful storage read
+ // 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. (CodeCache addr layers are cleared wholesale
+ // on unwind, so PutCodeWithHash needs no txNum.)
if sd.stateCache != nil {
- sd.stateCache.Put(domain, k, v)
+ if domain == kv.CodeDomain && len(codeEthHash) > 0 && len(v) > 0 {
+ sd.stateCache.PutCodeWithHash(k, v, codeEthHash)
+ } else {
+ readTxNum := (uint64(step)+1)*sd.StepSize() - 1
+ sd.stateCache.Put(domain, k, v, readTxNum)
+ }
+ }
+ if domain == kv.CommitmentDomain && sd.branchCache != nil && len(v) > 0 {
+ // Stamp the upper-bound txNum of the step the bytes came from, so an
+ // unwind into a recent step drops this entry (frozen-step reads, with
+ // a low txNum, stay warm). Mirrors the stateCache read-pop above.
+ sd.branchCache.Put(k, v, (uint64(step)+1)*sd.StepSize()-1, "sd.GetLatest")
}
return v, step, nil
}
+// GetCodeSize returns the length of the contract code at addr, probing a
+// size-only cache (geth-style codeSizeCache) before falling through to the
+// full bytes path. For workloads dominated by EXTCODESIZE / EXTCODEHASH
+// this avoids the file-accessor + decompression cost of the full bytes on
+// the second-and-later access to any codeHash seen anywhere in the process.
+//
+// Correctness invariant: the fast path is purely additive. When it cannot
+// answer, the function delegates to GetLatest(CodeDomain, addr) — the
+// authoritative path that hits L1/parent/stateCache/L2b/file in order.
+// Never short-circuits to (0, false, nil) based on account-record
+// resolution alone; that broke EIP-7002 / EIP-7251 system-contract
+// syscalls (the predeploy has CodeDomain entries but the AccountsDomain
+// record may be empty at block boundary).
+//
+// Returns (size, true, nil) on success and (0, false, nil) only when
+// CodeDomain itself confirms no code.
+func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte) (int, bool, error) {
+ if tx == nil {
+ return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx")
+ }
+
+ // Fast path: when we can resolve codeHash from the account cache AND
+ // the size is in the size cache, return without loading bytes.
+ if sd.stateCache != nil {
+ if codeEthHash := sd.codeHashForAddr(tx, addr); len(codeEthHash) > 0 {
+ if size, ok := sd.stateCache.GetCodeSizeByHash(codeEthHash); ok {
+ return size, true, nil
+ }
+ if cv, ok := sd.stateCache.GetCodeByHash(codeEthHash); ok {
+ sd.stateCache.PutCodeSizeByHash(codeEthHash, len(cv))
+ return len(cv), true, nil
+ }
+ }
+ }
+
+ // Cold path: authoritative read via the normal SD.GetLatest chain.
+ // Populates L1, L2b, and (via PutWithEthHash) the size layer for
+ // future callers.
+ v, _, err := sd.GetLatest(kv.CodeDomain, tx, addr)
+ if err != nil {
+ return 0, false, err
+ }
+ if len(v) == 0 {
+ return 0, false, nil
+ }
+ return len(v), true, nil
+}
+
+// codeHashForAddr returns the Ethereum codeHash for an account, or nil if the
+// account cannot be resolved or has no code. Reads the account through this
+// SD's normal layered lookup chain so the AccountsDomain cache absorbs the
+// cost (the typical case for any address the EVM has already loaded).
+//
+// Returns nil quietly on any error or missing account — the caller falls
+// through to the addr-keyed file read so correctness is unaffected.
+func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte) []byte {
+ if len(addr) == 0 {
+ return nil
+ }
+ // Nethermind-style addr → codeHash LRU. Skip the full AccountsDomain
+ // chain when the codeHash for this addr is already known. The zero
+ // hash sentinel means "no code or missing account" (negative cache).
+ if sd.stateCache != nil {
+ if h, ok := sd.stateCache.GetAddrCodeHash(addr); ok {
+ if h == ([32]byte{}) {
+ return nil
+ }
+ return h[:]
+ }
+ }
+
+ resolve := func() []byte {
+ if v, _, ok := sd.mem.GetLatest(kv.AccountsDomain, addr); ok {
+ return decodeAccountCodeHash(v)
+ }
+ if sd.parent != nil {
+ if v, _, ok := sd.parent.mem.GetLatest(kv.AccountsDomain, addr); ok {
+ return decodeAccountCodeHash(v)
+ }
+ }
+ if sd.stateCache != nil {
+ if v, ok := sd.stateCache.Get(kv.AccountsDomain, addr); ok {
+ return decodeAccountCodeHash(v)
+ }
+ }
+ v, _, err := tx.GetLatest(kv.AccountsDomain, addr)
+ if err != nil || len(v) == 0 {
+ return nil
+ }
+ return decodeAccountCodeHash(v)
+ }
+
+ h := resolve()
+ if sd.stateCache != nil {
+ var fixed [32]byte
+ if len(h) == 32 {
+ copy(fixed[:], h)
+ }
+ // Always populate, including the zero-hash sentinel for misses —
+ // repeat lookups skip the whole resolve() chain.
+ sd.stateCache.PutAddrCodeHash(addr, fixed)
+ }
+ 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, so codeHashForAddr returned nil for
+ // every contract and the CodeDomain ethHash bypass never fired.
+ 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() *changeset.DomainMetrics {
return &sd.metrics
}
@@ -779,6 +1311,14 @@ func (sd *SharedDomains) LogMetrics() []any {
"cdur", common.Round(sd.metrics.CacheReadDuration/time.Duration(readCount), 0))
}
+ if hits, misses := sd.metrics.StateCacheHitCount, sd.metrics.StateCacheMissCount; hits+misses > 0 {
+ metrics = append(metrics, "stateCache",
+ fmt.Sprintf("hit=%s miss=%s rate=%.0f%%",
+ common.PrettyCounter(hits),
+ common.PrettyCounter(misses),
+ 100*float64(hits)/float64(hits+misses)))
+ }
+
if readCount := sd.metrics.DbReadCount; readCount > 0 {
metrics = append(metrics, "db", common.PrettyCounter(readCount), "dbdur", common.Round(sd.metrics.DbReadDuration/time.Duration(readCount), 0))
}
@@ -803,6 +1343,14 @@ func (sd *SharedDomains) DomainLogMetrics() map[kv.Domain][]any {
metrics = append(metrics, "cache", common.PrettyCounter(readCount), "cdur", common.Round(dm.CacheReadDuration/time.Duration(readCount), 0))
}
+ if hits, misses := dm.StateCacheHitCount, dm.StateCacheMissCount; hits+misses > 0 {
+ metrics = append(metrics, "stateCache",
+ fmt.Sprintf("hit=%s miss=%s rate=%.0f%%",
+ common.PrettyCounter(hits),
+ common.PrettyCounter(misses),
+ 100*float64(hits)/float64(hits+misses)))
+ }
+
if readCount := dm.DbReadCount; readCount > 0 {
metrics = append(metrics, "db", common.PrettyCounter(readCount), "dbdur", common.Round(dm.DbReadDuration/time.Duration(readCount), 0))
}
@@ -864,10 +1412,12 @@ func (sd *SharedDomains) domainPut(domain kv.Domain, roTx kv.TemporalTx, k, v []
}
}
- // Update state cache when writing
- if sd.stateCache != nil {
- sd.stateCache.Put(domain, k, v)
- }
+ // The state cache is NOT updated here. This write goes into sd.mem and
+ // is served from there (checked first on every read, fork-isolated via
+ // the parent chain); the shared cache is refreshed only on flush
+ // (SharedDomains.Flush → FlushWithCallback), so it mirrors committed,
+ // fork-agnostic state. A per-write update would leak non-flushed,
+ // fork-specific bytes into a sibling fork's reads.
// Serialize against the calculator's accumulator-swap window — see
// changesetMu doc on the SharedDomains struct. Skipped when the caller
@@ -911,28 +1461,20 @@ func (sd *SharedDomains) DomainDel(domain kv.Domain, tx kv.TemporalTx, k []byte,
if err := sd.DomainDel(kv.CodeDomain, tx, k, txNum, nil); err != nil {
return err
}
- // Remove from state cache when account is deleted
- if sd.stateCache != nil {
- sd.stateCache.Delete(kv.AccountsDomain, k)
- sd.stateCache.Delete(kv.CodeDomain, k)
- }
+ // State cache is refreshed on flush only — see DomainPut. The flush
+ // callback handles the empty-value (delete) case for accounts, code
+ // and the addr→codeHash mapping.
// AccountsDomain — apply-side. Serialize against swap window.
sd.changesetMu.Lock()
defer sd.changesetMu.Unlock()
return sd.mem.DomainDel(kv.AccountsDomain, ks, txNum, prevVal)
case kv.StorageDomain:
- // Remove from state cache when storage is deleted
- if sd.stateCache != nil {
- sd.stateCache.Delete(kv.StorageDomain, k)
- }
+ // State cache refreshed on flush only — see DomainPut.
case kv.CodeDomain:
if prevVal == nil {
return nil
}
- // Remove from state cache when code is deleted
- if sd.stateCache != nil {
- sd.stateCache.Delete(kv.CodeDomain, k)
- }
+ // State cache refreshed on flush only — see DomainPut.
default:
//noop
}
@@ -1042,22 +1584,35 @@ func (sd *SharedDomains) computeCommitment(ctx context.Context, tx kv.TemporalTx
return sd.sdCtx.ComputeCommitment(ctx, tx, saveStateAfter, blockNum, txNum, logPrefix, onProgress)
}
-func (sd *SharedDomains) EnableParaTrieDB(db kv.TemporalRoDB) {
- sd.sdCtx.EnableParaTrieDB(db)
-}
-
// EnableTrieWarmup enables parallel warmup of MDBX page cache during commitment.
// It requires a DB to be enabled via EnableParaTrieDB.
func (sd *SharedDomains) EnableTrieWarmup(trieWarmup bool) {
sd.sdCtx.EnableTrieWarmup(trieWarmup)
}
-func (sd *SharedDomains) EnableWarmupCache(enable bool) {
- sd.sdCtx.EnableWarmupCache(enable)
-}
+func (sd *SharedDomains) EnableParaTrieDB(db kv.TemporalRoDB) {
+ sd.sdCtx.EnableParaTrieDB(db)
+
+ if sd.branchCache == nil {
+ return
+ }
+
+ // PIN_CONTRACT_TRUNKS preload is fire-once-per-BranchCache-lifetime:
+ // the cache is aggregator-scope and the preload's IO cost is paid up
+ // front; subsequent SDs reuse the already-populated pins.
+ if sd.branchCache.TryClaimPreload() {
+ if pinList := dbg.EnvString("PIN_CONTRACT_TRUNKS", ""); pinList != "" {
+ triggerTrunkPreload(context.Background(), sd.branchCache, db, pinList, sd.logger)
+ }
+ }
-func (sd *SharedDomains) ClearWarmupCache() {
- sd.sdCtx.ClearWarmupCache()
+ // Bind the adaptive controller on every SD: the controller is
+ // per-SD and Bind installs the cache's onMiss callback. Without
+ // this, SDs created after the first have inert controllers and
+ // the cache's onMiss may point at a closed predecessor.
+ if sd.adaptivePinController != nil {
+ sd.adaptivePinController.Bind()
+ }
}
// SetDeferCommitmentUpdates enables or disables deferred commitment updates.
@@ -1104,3 +1659,236 @@ func (sd *SharedDomains) touchChangedKeys(tx kv.TemporalTx, d kv.Domain, fromTxN
}
return changes, nil
}
+
+// triggerTrunkPreload spawns a goroutine that pre-pins the storage-trunk
+// of each contract in pinList for the given BranchCache. pinList is a
+// comma-separated list of 64-hex-char contract hashes (keccak256(addr)).
+//
+// The goroutine opens its OWN kv.TemporalRoTx via db.BeginTemporalRo,
+// distinct from the staged-sync's main tx. Two earlier shapes failed:
+// 1. Goroutine sharing the SD's MDBX tx → cgo "unpinned Go pointer"
+// panic from concurrent cursor use across goroutines.
+// 2. Synchronous from NewSharedDomains → blocked the engine HTTP
+// handler for ~3-4s during the preload window, dropping the bench's
+// first NewPayload and breaking subsequent backward-download.
+//
+// Own-tx async sidesteps both. Caller must guarantee fire-once via
+// BranchCache.TryClaimPreload (the only call site does this).
+func triggerTrunkPreload(ctx context.Context, branchCache *commitment.BranchCache, db kv.TemporalRoDB, pinList string, logger log.Logger) {
+ // RAM budget per contract. 64 MiB is the perf knee on the
+ // SSTORE-bloat workload — covers the structural d=68 saturation
+ // (~70K branches) plus most-shared d=69 branches, beyond which
+ // marginal returns drop sharply (256 MiB sweep showed −2 ms TEST
+ // took for +35 s preload).
+ //
+ // 64 MiB is the per-contract MAX for this PR. Larger budgets are
+ // not safe to ship without (a) per-account touch-driven minimisation
+ // that prunes the pinned set to the actually-useful subset (R11)
+ // and (b) a global RAM cap aware of available memory (R12). Until
+ // both land, an operator setting 256 MiB across many promoted
+ // contracts could pin GBs without material perf benefit.
+ //
+ // Override is plausibly justified in narrow cases — a measured-hot
+ // mainnet contract whose perf curve genuinely requires deeper
+ // coverage, or operator-driven diagnostic profiling. NOT in scope
+ // for this PR; an override path lands once R11 + R12 + a
+ // per-contract whitelist mechanism exist. Until then, larger values
+ // silently clamp with a warning so an operator can see they were
+ // ignored.
+ const maxPerContractBudgetMB = 64
+ ramBudgetMB := dbg.EnvInt("PIN_TRUNK_RAM_BUDGET_MB", maxPerContractBudgetMB)
+ if ramBudgetMB <= 0 {
+ ramBudgetMB = maxPerContractBudgetMB
+ }
+ if ramBudgetMB > maxPerContractBudgetMB {
+ logger.Warn("[trunk-preload] clamping budget to per-contract max",
+ "requested_mb", ramBudgetMB, "clamped_mb", maxPerContractBudgetMB)
+ ramBudgetMB = maxPerContractBudgetMB
+ }
+ ramBudgetBytes := ramBudgetMB << 20
+ logger.Info("[trunk-preload] entering", "pin_list_raw", pinList)
+ var hashes [][]byte
+ for _, hexStr := range strings.Split(pinList, ",") {
+ hexStr = strings.TrimSpace(hexStr)
+ if hexStr == "" {
+ continue
+ }
+ h, err := hex.DecodeString(hexStr)
+ if err != nil || len(h) != 32 {
+ logger.Warn("[trunk-preload] skipping invalid PIN_CONTRACT_TRUNKS entry",
+ "entry", hexStr, "err", err, "len", len(h))
+ continue
+ }
+ hashes = append(hashes, h)
+ }
+ logger.Info("[trunk-preload] hashes parsed", "count", len(hashes), "ram_budget_mb", ramBudgetMB)
+ if len(hashes) == 0 {
+ return
+ }
+ go func() {
+ // PIN_TRUNK_PARALLEL (default on): the parallel file-only wave-BFS
+ // (PreloadContractTrunkParallel). =false falls back to the per-prefix
+ // BFS via tx.GetLatest. Worker txs: each goroutine MUST use its own
+ // tx — MDBX cursors aren't concurrent-safe under one tx (cgo "unpinned
+ // Go pointer" panic). Reads here go via the file layer only
+ // (DebugGetLatestFromFiles): no MDBX cursor, no per-key cgo crossing,
+ // and values are already dereferenced. Correct for the from-cold-
+ // snapshot trigger path (MDBX holds nothing for the subtree).
+ parallel := dbg.EnvBool("PIN_TRUNK_PARALLEL", true)
+ nWorkers := runtime.GOMAXPROCS(0)
+ if nWorkers < 1 {
+ nWorkers = 1
+ }
+ if nWorkers > 16 {
+ nWorkers = 16
+ }
+ if !parallel {
+ nWorkers = 1
+ }
+ txs := make([]kv.TemporalTx, 0, nWorkers)
+ for i := 0; i < nWorkers; i++ {
+ t, err := db.BeginTemporalRo(ctx) //nolint:gocritic // collected into txs; all rolled back via the defer below
+ if err != nil {
+ for _, t := range txs {
+ t.Rollback()
+ }
+ logger.Warn("[trunk-preload] BeginTemporalRo failed", "worker", i, "err", err)
+ return
+ }
+ txs = append(txs, t)
+ }
+ defer func() {
+ for _, t := range txs {
+ t.Rollback()
+ }
+ }()
+
+ reader := func(prefix []byte) ([]byte, uint64, bool, error) {
+ v, step, err := txs[0].GetLatest(kv.CommitmentDomain, prefix)
+ if err != nil {
+ return nil, 0, false, err
+ }
+ return v, uint64(step), len(v) > 0, nil
+ }
+
+ resolve := func(keys [][]byte) ([][]byte, error) {
+ n := len(keys)
+ vals := make([][]byte, n)
+ if n == 0 {
+ return vals, nil
+ }
+ nw := nWorkers
+ if nw > n {
+ nw = n
+ }
+ chunk := (n + nw - 1) / nw
+ var wg sync.WaitGroup
+ var mu sync.Mutex
+ var firstErr error
+ for w := 0; w < nw; w++ {
+ lo, hi := w*chunk, (w+1)*chunk
+ if hi > n {
+ hi = n
+ }
+ if lo >= hi {
+ break
+ }
+ wg.Add(1)
+ go func(t kv.TemporalTx, lo, hi int) {
+ defer wg.Done()
+ d := t.Debug()
+ for i := lo; i < hi; i++ {
+ v, found, _, _, err := d.GetLatestFromFiles(kv.CommitmentDomain, keys[i], 0)
+ if err != nil {
+ mu.Lock()
+ if firstErr == nil {
+ firstErr = err
+ }
+ mu.Unlock()
+ return
+ }
+ if found {
+ vc := make([]byte, len(v))
+ copy(vc, v)
+ vals[i] = vc
+ }
+ }
+ }(txs[w], lo, hi)
+ }
+ wg.Wait()
+ return vals, firstErr
+ }
+
+ // dbPrefetch: phase 1 — pull the contract's MDBX-resident commitment
+ // branches (the freshest values: recently rewritten, not yet flushed to
+ // files) into an in-memory map via one range cursor per parity range, so
+ // the parallel wave-BFS resolves them from memory and the file layer is
+ // authoritative only for the keys MDBX doesn't have. The DupSort
+ // valsTable dups are invertedStep||value (latest first), so Seek +
+ // NextNoDup walks the latest value per key. Bounded by ramBudgetBytes;
+ // on a from-cold-snapshot start this finds nothing.
+ dbPrefetch := func(h []byte) map[string][]byte {
+ m := map[string][]byte{}
+ c, cerr := txs[0].CursorDupSort(kv.TblCommitmentVals)
+ if cerr != nil {
+ logger.Warn("[trunk-preload] phase-1 CursorDupSort failed", "err", cerr)
+ return m
+ }
+ defer c.Close()
+ evenFrom, evenTo, oddFrom, oddTo := commitment.ContractTrunkKeyRanges(commitment.ContractNibbles(h))
+ bytesBudget := ramBudgetBytes
+ scanRange := func(from, to []byte) error {
+ for k, v, err := c.Seek(from); k != nil; k, v, err = c.NextNoDup() {
+ if err != nil {
+ return err
+ }
+ if bytes.Compare(k, to) >= 0 {
+ break
+ }
+ if len(v) < 8 {
+ continue
+ }
+ val := common.Copy(v[8:])
+ m[string(common.Copy(k))] = val
+ if bytesBudget -= len(val) + 8; bytesBudget <= 0 {
+ return nil
+ }
+ }
+ return nil
+ }
+ if err := scanRange(evenFrom, evenTo); err != nil {
+ logger.Warn("[trunk-preload] phase-1 even-range scan", "err", err)
+ } else if bytesBudget > 0 {
+ if err := scanRange(oddFrom, oddTo); err != nil {
+ logger.Warn("[trunk-preload] phase-1 odd-range scan", "err", err)
+ }
+ }
+ return m
+ }
+
+ for i, h := range hashes {
+ started := time.Now()
+ logger.Info("[trunk-preload] contract starting", "i", i, "hash", hex.EncodeToString(h), "parallel", parallel, "workers", nWorkers)
+ var n int
+ var err error
+ if parallel {
+ dbBranches := dbPrefetch(h)
+ if len(dbBranches) > 0 {
+ logger.Info("[trunk-preload] phase-1 db prefetch", "hash", hex.EncodeToString(h), "db_branches", len(dbBranches))
+ }
+ n, err = commitment.PreloadContractTrunkParallel(h, ramBudgetBytes, dbBranches, resolve, branchCache, logger)
+ } else {
+ n, err = commitment.PreloadContractTrunk(h, ramBudgetBytes, reader, branchCache, logger)
+ }
+ took := time.Since(started)
+ if err != nil {
+ logger.Warn("[trunk-preload] failed",
+ "hash", hex.EncodeToString(h), "pinned_so_far", n, "took", took, "err", err)
+ continue
+ }
+ logger.Info("[trunk-preload] contract done",
+ "hash", hex.EncodeToString(h), "pinned", n, "took", took)
+ }
+ logger.Info("[trunk-preload] all done", "contracts", len(hashes))
+ }()
+}
diff --git a/db/state/squeeze.go b/db/state/squeeze.go
index b480103f46d..cec54905940 100644
--- a/db/state/squeeze.go
+++ b/db/state/squeeze.go
@@ -518,8 +518,7 @@ func RebuildCommitmentFilesWithHistory(ctx context.Context, rwDb kv.TemporalRwDB
return nil, err
}
logger.Info("[rebuild_commitment_history] starting", "blockFrom", blockFrom, "blockTo", blockTo,
- "txNumFrom", startFromTxNum, "txNumTo", endToTxNum, "stepSize", stepSize,
- "warmupCache", useWarmupCache)
+ "txNumFrom", startFromTxNum, "txNumTo", endToTxNum, "stepSize", stepSize)
}
var totalKeysProcessed uint64
var rh []byte
@@ -701,11 +700,12 @@ func RebuildCommitmentFilesWithHistory(ctx context.Context, rwDb kv.TemporalRwDB
return err
}
}
- // Set correct state reader and clear stale warmup cache before TouchKey calls begin.
+ // Set correct state reader before TouchKey calls begin. The previous
+ // ClearWarmupCache call here cleared the now-removed Go-side warmup
+ // cache; with no such cache, no clear is needed.
toTxNum := batch.TxNum(blockNum)
domains.SetTxNum(toTxNum)
domains.GetCommitmentCtx().SetStateReader(commitmentdb.NewRebuildStateReader(rwTx, domains, toTxNum+1))
- domains.ClearWarmupCache()
curBlock = blockNum
}
var domain kv.Domain
diff --git a/db/state/temporal_mem_batch.go b/db/state/temporal_mem_batch.go
index 15e4c482d73..d68afa04828 100644
--- a/db/state/temporal_mem_batch.go
+++ b/db/state/temporal_mem_batch.go
@@ -762,12 +762,74 @@ func (sd *TemporalMemBatch) Merge(o kv.TemporalMemBatch) error {
return nil
}
-func (sd *TemporalMemBatch) Flush(ctx context.Context, tx kv.RwTx) error {
+// FlushWithCallback flushes the mem-batch to tx, but first invokes cb for
+// every (domain, key, latest-value, step) tuple so downstream caches can be
+// refreshed, then drains the in-memory latest/history state.
+//
+// The callback, the MDBX flush and the drain run under latestStateLock as
+// one atomic window: a concurrent reader sees either the full pre-flush mem
+// state or the fully-drained post-flush state, never a partial mix. cb runs
+// before the flush so that during the MDBX write window the cache already
+// holds the entry — a reader going mem → cache → MDBX never sees a key
+// missing from all three. data may be nil/empty for deletes — cb
+// distinguishes on len(v)==0.
+//
+// Used by SharedDomains.Flush to keep the BranchCache (commitment domain)
+// and the StateCache (accounts/storage/code) in sync — refreshed only on
+// flush, so they hold committed, fork-agnostic state.
+func (sd *TemporalMemBatch) FlushWithCallback(
+ ctx context.Context, tx kv.RwTx,
+ cb func(domain kv.Domain, k []byte, v []byte, txNum uint64),
+) error {
+ sd.latestStateLock.Lock()
+ defer sd.latestStateLock.Unlock()
+
+ for d := range sd.domains {
+ for keyStr, history := range sd.domains[d] {
+ if len(history) == 0 {
+ continue
+ }
+ latest := history[len(history)-1]
+ cb(kv.Domain(d), []byte(keyStr), latest.data, latest.txNum)
+ }
+ }
+ // StorageDomain entries live in sd.storage (a btree), not sd.domains.
+ sd.storage.Scan(func(keyStr string, history []dataWithTxNum) bool {
+ if len(history) == 0 {
+ return true
+ }
+ latest := history[len(history)-1]
+ cb(kv.StorageDomain, []byte(keyStr), latest.data, latest.txNum)
+ return true
+ })
+
+ if err := sd.flushLocked(ctx, tx); err != nil {
+ return err
+ }
+ sd.drainLocked()
+ return nil
+}
+
+// drainLocked clears the in-memory latest/history state after a flush. The
+// caller must hold latestStateLock. Mirrors the data-clearing half of
+// ClearRam (without the metrics reset): once flushed, the mem-batch holds
+// no data — values now live in the DB and the refreshed caches, and a child
+// SD chained to this one as parent reads through instead of being shadowed
+// by stale, fork-specific bytes.
+func (sd *TemporalMemBatch) drainLocked() {
+ for i := range sd.domains {
+ sd.domains[i] = map[string][]dataWithTxNum{}
+ }
+ sd.storage = btree2.NewMap[string, []dataWithTxNum](128)
+ sd.unwindToTxNum = 0
+ sd.unwindChangeset = nil
+ sd.unwindChangesetRaw = nil
+}
+
+// flushLocked is the body of Flush, factored so FlushWithCallback can
+// run it inside latestStateLock without re-acquiring.
+func (sd *TemporalMemBatch) flushLocked(ctx context.Context, tx kv.RwTx) error {
if sd.unwindChangesetRaw != nil {
- // Replay against the RAW (step-preserving) changeset — the collapsed
- // unwindChangeset would lose every (key, step) entry except one per
- // real key, which leaves orphan domain-values entries at steps above
- // the unwind target (observed on mainnet for the commitment domain).
for domain := range sd.unwindChangesetRaw {
sort.Slice(sd.unwindChangesetRaw[domain], func(i, j int) bool {
return sd.unwindChangesetRaw[domain][i].Key < sd.unwindChangesetRaw[domain][j].Key
@@ -777,7 +839,6 @@ func (sd *TemporalMemBatch) Flush(ctx context.Context, tx kv.RwTx) error {
return err
}
}
-
if err := sd.flushDiffSet(ctx, tx); err != nil {
return err
}
@@ -790,6 +851,12 @@ func (sd *TemporalMemBatch) Flush(ctx context.Context, tx kv.RwTx) error {
return nil
}
+func (sd *TemporalMemBatch) Flush(ctx context.Context, tx kv.RwTx) error {
+ sd.latestStateLock.Lock()
+ defer sd.latestStateLock.Unlock()
+ return sd.flushLocked(ctx, tx)
+}
+
func (sd *TemporalMemBatch) flushDiffSet(_ context.Context, tx kv.RwTx) error {
for key, changeSet := range sd.pastChangesAccumulator {
blockNum := binary.BigEndian.Uint64(common.ToBytesZeroCopy(key[:8]))
diff --git a/execution/balcache/balcache.go b/execution/balcache/balcache.go
new file mode 100644
index 00000000000..afd371c4068
--- /dev/null
+++ b/execution/balcache/balcache.go
@@ -0,0 +1,195 @@
+// 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 .
+
+package balcache
+
+import (
+ "context"
+ "sync/atomic"
+
+ lru "github.com/hashicorp/golang-lru/v2"
+
+ "github.com/erigontech/erigon/common"
+)
+
+// balCacheSize is how many recent blocks' EIP-7928 BlockAccessLists are kept in
+// memory. Peers fetching BALs (eth/71) and the eth_getBlockAccessList RPC ask
+// for recent blocks; older BALs are not persisted by default and must be
+// regenerated by re-executing the block (see BALRegenerator).
+const balCacheSize = 100
+
+var balCache *lru.Cache[common.Hash, []byte]
+
+// balRegenerator holds the optionally-installed re-execution backend that can
+// produce a BAL when neither the cache nor the chaindata DB has one. The value
+// is wrapped in an addressable struct (atomic.Value rejects nil interface
+// stores). Set once at startup via SetBALRegenerator; reads are lock-free.
+type balRegeneratorSlot struct{ r BALRegenerator }
+
+var balRegenerator atomic.Pointer[balRegeneratorSlot]
+
+// balSyncFetcher holds the optionally-installed eth/71 peer-fetch backend used
+// by sync paths to opportunistically fill the cache with peer-fetched BALs
+// before falling back to local re-execution.
+type balSyncFetcherSlot struct{ f BALSyncFetcher }
+
+var balSyncFetcher atomic.Pointer[balSyncFetcherSlot]
+
+func init() {
+ var err error
+ balCache, err = lru.New[common.Hash, []byte](balCacheSize)
+ if err != nil {
+ panic(err)
+ }
+}
+
+// BALRegenerator produces the RLP-encoded BlockAccessList for a block by
+// re-executing it against the parent state. Implementations live close to the
+// exec module (they need the full block + chain config + state-reader stack);
+// the lookup path here installs one via SetBALRegenerator and consults it as a
+// fallback when neither the in-memory cache nor the chaindata DB has the BAL.
+//
+// The returned bytes are the canonical RLP encoding (matching what
+// types.EncodeBlockAccessListBytes produces) so callers can hand them to
+// peers unchanged. The hash of the decoded BAL MUST match
+// header.BlockAccessListHash — peers verify this on receipt; a mismatch
+// fails the eth/71 BAL response.
+type BALRegenerator interface {
+ RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error)
+}
+
+// SetBALRegenerator installs the re-execution backend used by
+// BlockAccessListBytes as a last-resort fallback. Pass nil to clear. Safe to
+// call multiple times; the most recent setter wins. Typically called once at
+// node startup after the exec module is constructed.
+func SetBALRegenerator(r BALRegenerator) {
+ if r == nil {
+ balRegenerator.Store(nil)
+ return
+ }
+ balRegenerator.Store(&balRegeneratorSlot{r: r})
+}
+
+func getBALRegenerator() BALRegenerator {
+ slot := balRegenerator.Load()
+ if slot == nil {
+ return nil
+ }
+ return slot.r
+}
+
+// BALSyncFetcher is invoked by sync paths (engine_block_downloader,
+// engine_newPayload) to opportunistically fetch BALs from eth/71 peers and
+// cache them. Implementations are non-blocking: they queue work and return
+// quickly; sync stages don't wait on the fetch.
+//
+// hashes / numbers / expected are aligned positionally — entry i refers to
+// the block at hashes[i] with number numbers[i], whose header committed to
+// BAL hash expected[i]. Each successful fetch is hash-verified against
+// expected before being cached (matches the spec for eth/71 GetBlockAccessLists
+// responses).
+//
+// A missing or nil BALSyncFetcher is fine — the cache will fall through to
+// the BALRegenerator on a later lookup.
+type BALSyncFetcher interface {
+ FetchBALs(ctx context.Context, hashes []common.Hash, numbers []uint64, expected []common.Hash)
+}
+
+// SetBALSyncFetcher installs the eth/71 peer-fetch backend used by sync
+// hooks. Pass nil to clear. Safe to call multiple times.
+func SetBALSyncFetcher(f BALSyncFetcher) {
+ if f == nil {
+ balSyncFetcher.Store(nil)
+ return
+ }
+ balSyncFetcher.Store(&balSyncFetcherSlot{f: f})
+}
+
+// GetBALSyncFetcher returns the installed fetcher, or nil if none is set.
+// Sync hooks call this and dispatch when non-nil.
+func GetBALSyncFetcher() BALSyncFetcher {
+ slot := balSyncFetcher.Load()
+ if slot == nil {
+ return nil
+ }
+ return slot.f
+}
+
+// CacheBlockAccessList stores the block's RLP-encoded BAL bytes in the in-memory
+// cache instead of persisting them to the chaindata DB. Writing the BAL to MDBX
+// on the NewPayload critical path was a multi-hundred-KB Put per block that, on a
+// churned DB, took tens of seconds; the cache + regenerate-on-miss model mirrors
+// what we do for receipts (--persist.receipt).
+func CacheBlockAccessList(hash common.Hash, data []byte) {
+ if len(data) == 0 {
+ return
+ }
+ balCache.Add(hash, common.Copy(data))
+}
+
+// CachedBlockAccessList returns the cached RLP-encoded BAL bytes for hash, if the
+// block is still within the in-memory window. Cache-only — does NOT consult the
+// chaindata DB or invoke the regenerator. Use BlockAccessListBytes for the
+// full lookup chain.
+func CachedBlockAccessList(hash common.Hash) ([]byte, bool) {
+ return balCache.Get(hash)
+}
+
+// BlockAccessListBytes returns the BAL bytes for (hash, number). Two-tier
+// lookup:
+// 1. In-memory LRU cache (recent blocks, freshly produced or recently
+// regenerated).
+// 2. If a BALRegenerator is installed, re-execute the block to derive the
+// BAL. The result is added to the cache before returning.
+//
+// The BAL is intentionally NOT persisted to MDBX. Writing the BAL on the
+// NewPayload critical path was a multi-hundred-KB Put per block that, on a
+// churned DB, took tens of seconds. The cache + regenerate-on-miss model
+// removes the write entirely; older blocks needed by peers (or RPC) are
+// reconstructed on demand.
+//
+// Returns (nil, nil) if no source has it (no regenerator installed, or
+// regenerator returned nil). Returns a non-nil error only on actual
+// regenerator failure.
+//
+// Callers passing the context (ctx) get cancellation of the regeneration leg
+// — peer-driven serving paths should respect the peer's read deadline.
+func BlockAccessListBytes(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) {
+ if v, ok := balCache.Get(hash); ok {
+ return v, nil
+ }
+ regen := getBALRegenerator()
+ if regen == nil {
+ return nil, nil
+ }
+ generated, err := regen.RegenerateBlockAccessList(ctx, hash, number)
+ if err != nil {
+ return nil, err
+ }
+ if len(generated) == 0 {
+ return nil, nil
+ }
+ CacheBlockAccessList(hash, generated)
+ return generated, nil
+}
+
+// ResetBALCacheForTest clears the in-memory cache, the regenerator, and the
+// sync fetcher. Test-only.
+func ResetBALCacheForTest() {
+ balCache.Purge()
+ balRegenerator.Store(nil)
+ balSyncFetcher.Store(nil)
+}
diff --git a/execution/balcache/balcache_test.go b/execution/balcache/balcache_test.go
new file mode 100644
index 00000000000..a4d7bc5d66d
--- /dev/null
+++ b/execution/balcache/balcache_test.go
@@ -0,0 +1,190 @@
+// 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 .
+
+package balcache_test
+
+import (
+ "context"
+ "errors"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/execution/balcache"
+)
+
+// fakeRegenerator records every regeneration request + serves canned responses.
+type fakeRegenerator struct {
+ calls atomic.Int32
+ // resultFor maps the (hash) → bytes the regenerator will return. Unset
+ // hashes get the default canned bytes if set, else (nil, nil).
+ resultFor map[common.Hash][]byte
+ defaultBytes []byte
+ errAlways error
+}
+
+func (f *fakeRegenerator) RegenerateBlockAccessList(_ context.Context, hash common.Hash, _ uint64) ([]byte, error) {
+ f.calls.Add(1)
+ if f.errAlways != nil {
+ return nil, f.errAlways
+ }
+ if v, ok := f.resultFor[hash]; ok {
+ return v, nil
+ }
+ if f.defaultBytes != nil {
+ return f.defaultBytes, nil
+ }
+ return nil, nil
+}
+
+var errFakeRegen = errors.New("fake regen failure")
+
+func hashFromByte(b byte) common.Hash {
+ var h common.Hash
+ for i := range h {
+ h[i] = b
+ }
+ return h
+}
+
+func TestBlockAccessListBytes_CacheHitShortCircuits(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x01)
+ data := []byte{0xc1, 0x00}
+ balcache.CacheBlockAccessList(hash, data)
+
+ // Install a regenerator that, if called, fails the test.
+ balcache.SetBALRegenerator(&fakeRegenerator{errAlways: errFakeRegen})
+
+ got, err := balcache.BlockAccessListBytes(context.Background(), hash, 7)
+ require.NoError(t, err)
+ require.Equal(t, data, got)
+}
+
+func TestBlockAccessListBytes_RegeneratorFallback(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x03)
+ regenerated := []byte{0xc3, 0x42}
+ regen := &fakeRegenerator{defaultBytes: regenerated}
+ balcache.SetBALRegenerator(regen)
+
+ got, err := balcache.BlockAccessListBytes(context.Background(), hash, 22)
+ require.NoError(t, err)
+ require.Equal(t, regenerated, got)
+ require.Equal(t, int32(1), regen.calls.Load(), "regenerator should be called once on miss")
+
+ // Repeated lookup hits the cache, regenerator NOT called again.
+ got2, err := balcache.BlockAccessListBytes(context.Background(), hash, 22)
+ require.NoError(t, err)
+ require.Equal(t, regenerated, got2)
+ require.Equal(t, int32(1), regen.calls.Load(), "cached regenerated BAL must short-circuit subsequent lookups")
+}
+
+func TestBlockAccessListBytes_NoRegeneratorOnMiss(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x04)
+ balcache.SetBALRegenerator(nil)
+ got, err := balcache.BlockAccessListBytes(context.Background(), hash, 33)
+ require.NoError(t, err)
+ require.Nil(t, got, "no cache, no regenerator → nil bytes (peer sees 'not available')")
+}
+
+func TestBlockAccessListBytes_RegeneratorReturnsNil(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x05)
+ regen := &fakeRegenerator{} // defaultBytes nil
+ balcache.SetBALRegenerator(regen)
+
+ got, err := balcache.BlockAccessListBytes(context.Background(), hash, 44)
+ require.NoError(t, err)
+ require.Nil(t, got)
+ require.Equal(t, int32(1), regen.calls.Load())
+
+ // A nil-from-regenerator must NOT be cached (so a later install of a
+ // real regenerator can succeed).
+ _, ok := balcache.CachedBlockAccessList(hash)
+ require.False(t, ok, "nil regeneration result must not be cached")
+}
+
+func TestBlockAccessListBytes_RegeneratorError(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x06)
+ regen := &fakeRegenerator{errAlways: errFakeRegen}
+ balcache.SetBALRegenerator(regen)
+
+ _, err := balcache.BlockAccessListBytes(context.Background(), hash, 55)
+ require.ErrorIs(t, err, errFakeRegen)
+ _, ok := balcache.CachedBlockAccessList(hash)
+ require.False(t, ok, "regenerator error must not pollute the cache")
+}
+
+func TestCacheBlockAccessList_EmptyIsNoOp(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+ hash := hashFromByte(0x07)
+ balcache.CacheBlockAccessList(hash, nil)
+ balcache.CacheBlockAccessList(hash, []byte{})
+ _, ok := balcache.CachedBlockAccessList(hash)
+ require.False(t, ok, "empty data must not be cached (would conflate with 'not available')")
+}
+
+func TestCacheBlockAccessList_CopiesBytes(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+ hash := hashFromByte(0x08)
+ src := []byte{0xde, 0xad, 0xbe, 0xef}
+ balcache.CacheBlockAccessList(hash, src)
+ src[0] = 0xff // mutate caller's slice — cache must hold its own copy
+
+ got, ok := balcache.CachedBlockAccessList(hash)
+ require.True(t, ok)
+ require.Equal(t, byte(0xde), got[0], "cache must defensively copy the input bytes")
+}
+
+func TestSetBALRegenerator_ReplaceAndClear(t *testing.T) {
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.ResetBALCacheForTest()
+
+ hash := hashFromByte(0x09)
+ r1 := &fakeRegenerator{defaultBytes: []byte{0xa1}}
+ r2 := &fakeRegenerator{defaultBytes: []byte{0xa2}}
+ balcache.SetBALRegenerator(r1)
+ balcache.SetBALRegenerator(r2) // replace
+
+ got, err := balcache.BlockAccessListBytes(context.Background(), hash, 99)
+ require.NoError(t, err)
+ require.Equal(t, []byte{0xa2}, got)
+ require.Zero(t, r1.calls.Load(), "old regenerator must not be called after replacement")
+ require.Equal(t, int32(1), r2.calls.Load())
+
+ balcache.ResetBALCacheForTest() // clear cache so the next lookup hits the regenerator again
+ balcache.SetBALRegenerator(nil) // explicit clear
+ got, err = balcache.BlockAccessListBytes(context.Background(), hash, 99)
+ require.NoError(t, err)
+ require.Nil(t, got, "cleared regenerator → miss returns nil")
+}
diff --git a/execution/cache/cache.go b/execution/cache/cache.go
index 170ab31dee7..2171137a0ca 100644
--- a/execution/cache/cache.go
+++ b/execution/cache/cache.go
@@ -16,16 +16,15 @@
package cache
-import "github.com/erigontech/erigon/common"
-
// Cache is the interface for domain caches.
// Implementations: GenericCache (for Account/Storage), CodeCache (for Code).
type Cache interface {
// Get retrieves data for the given key.
Get(key []byte) ([]byte, bool)
- // Put stores data for the given key.
- Put(key []byte, value []byte)
+ // Put stores data for the given key, stamped with the txNum the value
+ // reflects (used for txNum/epoch unwind invalidation).
+ Put(key []byte, value []byte, txNum uint64)
// Delete removes the data for the given key.
Delete(key []byte)
@@ -33,18 +32,11 @@ type Cache interface {
// Clear removes all mutable entries from the cache.
Clear()
- // GetBlockHash returns the hash of the last block processed.
- GetBlockHash() common.Hash
-
- // SetBlockHash sets the hash of the current block being processed.
- SetBlockHash(hash common.Hash)
-
- // ValidateAndPrepare checks if parentHash matches the cache's blockHash.
- // If mismatch, clears mutable caches. Returns true if valid, false if cleared.
- ValidateAndPrepare(parentHash common.Hash, incomingBlockHash common.Hash) bool
-
- // ClearWithHash clears the cache and sets the block hash.
- ClearWithHash(hash common.Hash)
+ // Unwind invalidates entries that reflect state above unwindToTxNum on a
+ // now-dead fork. Diffset-free: GenericCache bumps an epoch and lowers a
+ // floor (lazy evict on read); CodeCache clears its small mutable addr
+ // layers. Immutable content-addressed code layers are untouched.
+ Unwind(unwindToTxNum uint64)
// Len returns the number of entries in the cache.
Len() int
diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go
index 588c1e3bc7a..7c3bd977e13 100644
--- a/execution/cache/cache_test.go
+++ b/execution/cache/cache_test.go
@@ -53,13 +53,6 @@ func makeValue(i int) []byte {
// DomainCache Tests
// =============================================================================
-func TestDomainCache_NewDomainCache(t *testing.T) {
- c := NewDomainCache(100)
- require.NotNil(t, c)
- assert.Equal(t, 0, c.Len())
- assert.Equal(t, common.Hash{}, c.GetBlockHash())
-}
-
func TestDomainCache_NewWithByteCapacity(t *testing.T) {
c := NewDomainCache(1 * datasize.MB) // 1MB
require.NotNil(t, c)
@@ -80,7 +73,7 @@ func TestDomainCache_GetPut(t *testing.T) {
assert.Nil(t, v)
// Put and Get
- c.Put(addr, value)
+ c.Put(addr, value, 0)
v, ok = c.Get(addr)
assert.True(t, ok)
assert.Equal(t, value, v)
@@ -94,47 +87,76 @@ func TestDomainCache_PutUpdateValue(t *testing.T) {
value1 := []byte{1, 2, 3, 4, 5, 6, 7, 8} // 8 bytes
value2 := []byte{9, 10, 11} // 3 bytes
- c.Put(addr, value1)
+ c.Put(addr, value1, 0)
// Update with different value
- c.Put(addr, value2)
+ c.Put(addr, value2, 0)
v, ok := c.Get(addr)
assert.True(t, ok)
assert.Equal(t, value2, v)
}
-func TestDomainCache_PutCapacityLimit(t *testing.T) {
- // Each entry is 8 (overhead) + 3 (value) = 11 bytes
- // Set capacity to 22 bytes - enough for 2 entries but not 3
- c := NewDomainCache(22)
+func TestDomainCache_PutCapacityLimit_NoOpMode(t *testing.T) {
+ // ModeNoOp keeps the historical fill-and-freeze behaviour: once
+ // full, new keys are silently dropped. Counted via the dropped metric.
+ // Entry overhead is 20 (addr key) + 3 (value) + 24 = 47 bytes per entry.
+ // Two entries take 94 bytes; cap at 100 leaves no room for a third.
+ c := NewDomainCacheMode(100, ModeNoOp)
- // Fill to capacity
- c.Put(makeAddr(1), makeValue(1))
- c.Put(makeAddr(2), makeValue(2))
+ c.Put(makeAddr(1), makeValue(1), 0)
+ c.Put(makeAddr(2), makeValue(2), 0)
assert.Equal(t, 2, c.Len())
- // Try to add more - should be ignored (no-op when full)
- c.Put(makeAddr(3), makeValue(3))
+ c.Put(makeAddr(3), makeValue(3), 0)
assert.Equal(t, 2, c.Len())
- // New key should not exist
_, ok := c.Get(makeAddr(3))
assert.False(t, ok)
- // But updating existing key should still work
+ // Updating an existing key always succeeds in either mode.
newValue := []byte{100, 101, 102}
- c.Put(makeAddr(1), newValue)
+ c.Put(makeAddr(1), newValue, 0)
v, ok := c.Get(makeAddr(1))
assert.True(t, ok)
assert.Equal(t, newValue, v)
}
+func TestDomainCache_PutEvictsWhenFull_EvictMode(t *testing.T) {
+ // ModeEvictLRU lets the per-shard LRU evict on insert when its
+ // entry-count cap is reached. Eviction is per-shard, not globally
+ // LRU (a known trade-off of freelru.ShardedLRU; see policy.go).
+ //
+ // Build with capacityEntries=2 so that a third insert forces an
+ // eviction event. Capacity-bytes is unused for the eviction
+ // decision and is only carried for telemetry.
+ c := &DomainCache{
+ GenericCache: newGenericCacheEntries[[]byte](1<<20, 2, func(v []byte) int { return len(v) }, ModeEvictLRU),
+ }
+
+ for i := 1; i <= 64; i++ {
+ c.Put(makeAddr(i), makeValue(i), 0)
+ }
+ // The newest entry must still be findable.
+ v, ok := c.Get(makeAddr(64))
+ assert.True(t, ok, "newest key must be present after eviction")
+ assert.Equal(t, makeValue(64), v)
+
+ // At least some early entries must have been evicted by now.
+ missingCount := 0
+ for i := 1; i <= 32; i++ {
+ if _, ok := c.Get(makeAddr(i)); !ok {
+ missingCount++
+ }
+ }
+ assert.Positive(t, missingCount, "ModeEvictLRU should have evicted some early entries")
+}
+
func TestDomainCache_Delete(t *testing.T) {
c := NewDomainCache(100)
addr := makeAddr(1)
- c.Put(addr, makeValue(1))
+ c.Put(addr, makeValue(1), 0)
assert.Equal(t, 1, c.Len())
c.Delete(addr)
@@ -147,89 +169,19 @@ func TestDomainCache_Delete(t *testing.T) {
func TestDomainCache_Clear(t *testing.T) {
c := NewDomainCache(100)
- c.Put(makeAddr(1), makeValue(1))
- c.Put(makeAddr(2), makeValue(2))
+ c.Put(makeAddr(1), makeValue(1), 0)
+ c.Put(makeAddr(2), makeValue(2), 0)
assert.Equal(t, 2, c.Len())
c.Clear()
assert.Equal(t, 0, c.Len())
}
-func TestDomainCache_BlockHash(t *testing.T) {
- c := NewDomainCache(100)
-
- assert.Equal(t, common.Hash{}, c.GetBlockHash())
-
- hash := makeHash(1)
- c.SetBlockHash(hash)
- assert.Equal(t, hash, c.GetBlockHash())
-}
-
-func TestDomainCache_ValidateAndPrepare_EmptyCache(t *testing.T) {
- c := NewDomainCache(100)
-
- parentHash := makeHash(1)
- incomingHash := makeHash(2)
-
- // Empty cache should always be valid
- valid := c.ValidateAndPrepare(parentHash, incomingHash)
- assert.True(t, valid)
- assert.Equal(t, incomingHash, c.GetBlockHash())
-}
-
-func TestDomainCache_ValidateAndPrepare_MatchingParent(t *testing.T) {
- c := NewDomainCache(100)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
-
- c.SetBlockHash(block1)
- c.Put(makeAddr(1), makeValue(1))
-
- // Parent matches current block hash
- valid := c.ValidateAndPrepare(block1, block2)
- assert.True(t, valid)
- assert.Equal(t, block2, c.GetBlockHash())
- // Data should be preserved
- assert.Equal(t, 1, c.Len())
-}
-
-func TestDomainCache_ValidateAndPrepare_Mismatch(t *testing.T) {
- c := NewDomainCache(100)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
- block3 := makeHash(3)
-
- c.SetBlockHash(block1)
- c.Put(makeAddr(1), makeValue(1))
-
- // Parent doesn't match - should clear
- valid := c.ValidateAndPrepare(block2, block3)
- assert.False(t, valid)
- assert.Equal(t, block3, c.GetBlockHash())
- // Data should be cleared
- assert.Equal(t, 0, c.Len())
-}
-
-func TestDomainCache_ClearWithHash(t *testing.T) {
- c := NewDomainCache(100)
-
- c.Put(makeAddr(1), makeValue(1))
- c.SetBlockHash(makeHash(1))
-
- newHash := makeHash(2)
- c.ClearWithHash(newHash)
-
- assert.Equal(t, 0, c.Len())
- assert.Equal(t, newHash, c.GetBlockHash())
-}
-
func TestDomainCache_PrintStatsAndReset(t *testing.T) {
c := NewDomainCache(100)
// Generate some hits and misses
- c.Put(makeAddr(1), makeValue(1))
+ c.Put(makeAddr(1), makeValue(1), 0)
c.Get(makeAddr(1)) // hit
c.Get(makeAddr(1)) // hit
c.Get(makeAddr(2)) // miss
@@ -255,14 +207,6 @@ func TestDomainCache_ImplementsInterface(t *testing.T) {
// CodeCache Tests
// =============================================================================
-func TestCodeCache_NewCodeCache(t *testing.T) {
- c := NewCodeCache(100, 200)
- require.NotNil(t, c)
- assert.Equal(t, 0, c.Len())
- assert.Equal(t, 0, c.CodeLen())
- assert.Equal(t, common.Hash{}, c.GetBlockHash())
-}
-
func TestCodeCache_NewDefaultCodeCache(t *testing.T) {
c := NewDefaultCodeCache()
require.NotNil(t, c)
@@ -282,7 +226,7 @@ func TestCodeCache_GetPut(t *testing.T) {
assert.Nil(t, v)
// Put and Get
- c.Put(addr, code)
+ c.Put(addr, code, 0)
v, ok = c.Get(addr)
assert.True(t, ok)
assert.Equal(t, code, v)
@@ -294,7 +238,7 @@ func TestCodeCache_PutEmptyCode(t *testing.T) {
c := NewCodeCache(100, 200)
addr := makeAddr(1)
- c.Put(addr, []byte{})
+ c.Put(addr, []byte{}, 0)
// Should not store empty code
assert.Equal(t, 0, c.Len())
@@ -310,9 +254,9 @@ func TestCodeCache_CodeDeduplication(t *testing.T) {
addr3 := makeAddr(3)
// Three addresses with same code
- c.Put(addr1, code)
- c.Put(addr2, code)
- c.Put(addr3, code)
+ c.Put(addr1, code, 0)
+ c.Put(addr2, code, 0)
+ c.Put(addr3, code, 0)
// Should have 3 address mappings but only 1 code entry
assert.Equal(t, 3, c.Len())
@@ -328,31 +272,49 @@ func TestCodeCache_CodeDeduplication(t *testing.T) {
}
func TestCodeCache_AddrCapacityLimit(t *testing.T) {
- // Each addr entry is 20 (addr) + 8 (uint64 hash) = 28 bytes
- // Set addr capacity to 60 bytes - enough for 2 entries but not 3
- c := NewCodeCache(1024*1024, 60) // 1MB code, 60 bytes addr
+ // addrToHash is an LRU keyed by 20-byte address. Verify eviction is
+ // LRU rather than no-op-when-full — fresh-address workloads must
+ // warm up (geth's lru.Cache pattern, mirroring core/state/database_code.go).
+ // makeAddr / makeCode wrap at 256, so we generate addrs/codes from
+ // a wider 16-bit space directly.
+ wideAddr := func(i int) []byte {
+ a := make([]byte, 20)
+ a[18] = byte(i >> 8)
+ a[19] = byte(i)
+ return a
+ }
+ wideCode := func(i int) []byte {
+ return []byte{0x60, byte(i >> 8), byte(i)}
+ }
- // Fill to addr capacity
- c.Put(makeAddr(1), makeCode(1))
- c.Put(makeAddr(2), makeCode(2))
- assert.Equal(t, 2, c.Len())
+ c := NewCodeCache(1024*1024, 1024*28) // 1MB code, ~1024 addr LRU entries
+ for i := 0; i < 1100; i++ {
+ c.Put(wideAddr(i), wideCode(i), 0)
+ }
- // Adding more should be no-op for addr (at capacity)
- c.Put(makeAddr(3), makeCode(3))
- assert.Equal(t, 2, c.Len()) // addr not added
+ // Len should be exactly the LRU cap (1024), not silently truncated to 0.
+ assert.Equal(t, 1024, c.Len())
- // Code cache should have all 3 codes (code capacity not reached)
- assert.Equal(t, 3, c.CodeLen())
+ // Oldest entries (addrs 0..75) must have been evicted.
+ _, ok := c.Get(wideAddr(0))
+ assert.False(t, ok, "oldest entry should be evicted by LRU")
+ _, ok = c.Get(wideAddr(50))
+ assert.False(t, ok, "second-oldest range should be evicted by LRU")
- // Can't get addr3 since it wasn't added
- _, ok := c.Get(makeAddr(3))
- assert.False(t, ok)
+ // Most recent entry must be present.
+ v, ok := c.Get(wideAddr(1099))
+ assert.True(t, ok, "most recent entry should remain")
+ assert.Equal(t, wideCode(1099), v)
- // But updating existing addr should work
- c.Put(makeAddr(1), makeCode(4))
- v, ok := c.Get(makeAddr(1))
+ // hashToCode stores all 1100 distinct codes (content-addressed,
+ // independent of addr LRU eviction).
+ assert.Equal(t, 1100, c.CodeLen())
+
+ // Updating an existing addr re-writes the entry (LRU promotes to MRU).
+ c.Put(wideAddr(1099), wideCode(4242), 0)
+ v, ok = c.Get(wideAddr(1099))
assert.True(t, ok)
- assert.Equal(t, makeCode(4), v)
+ assert.Equal(t, wideCode(4242), v)
}
func TestCodeCache_CodeCapacityLimit(t *testing.T) {
@@ -361,12 +323,12 @@ func TestCodeCache_CodeCapacityLimit(t *testing.T) {
c := NewCodeCache(25, 1024*1024) // 25 bytes code, 1MB addr
// Fill code capacity
- c.Put(makeAddr(1), makeCode(1))
- c.Put(makeAddr(2), makeCode(2))
+ c.Put(makeAddr(1), makeCode(1), 0)
+ c.Put(makeAddr(2), makeCode(2), 0)
assert.Equal(t, 2, c.CodeLen())
// Try to add more code - addr mapping added, but code not stored
- c.Put(makeAddr(3), makeCode(3))
+ c.Put(makeAddr(3), makeCode(3), 0)
assert.Equal(t, 3, c.Len()) // addr mapping added
assert.Equal(t, 2, c.CodeLen()) // code not added (at capacity)
@@ -380,7 +342,7 @@ func TestCodeCache_Delete(t *testing.T) {
addr := makeAddr(1)
code := makeCode(1)
- c.Put(addr, code)
+ c.Put(addr, code, 0)
c.Delete(addr)
assert.Equal(t, 0, c.Len())
@@ -394,8 +356,8 @@ func TestCodeCache_Delete(t *testing.T) {
func TestCodeCache_Clear(t *testing.T) {
c := NewCodeCache(100, 200)
- c.Put(makeAddr(1), makeCode(1))
- c.Put(makeAddr(2), makeCode(2))
+ c.Put(makeAddr(1), makeCode(1), 0)
+ c.Put(makeAddr(2), makeCode(2), 0)
c.Clear()
assert.Equal(t, 0, c.Len())
@@ -403,81 +365,10 @@ func TestCodeCache_Clear(t *testing.T) {
assert.Equal(t, 2, c.CodeLen())
}
-func TestCodeCache_BlockHash(t *testing.T) {
- c := NewCodeCache(100, 200)
-
- assert.Equal(t, common.Hash{}, c.GetBlockHash())
-
- hash := makeHash(1)
- c.SetBlockHash(hash)
- assert.Equal(t, hash, c.GetBlockHash())
-}
-
-func TestCodeCache_ValidateAndPrepare_EmptyCache(t *testing.T) {
- c := NewCodeCache(100, 200)
-
- parentHash := makeHash(1)
- incomingHash := makeHash(2)
-
- valid := c.ValidateAndPrepare(parentHash, incomingHash)
- assert.True(t, valid)
- assert.Equal(t, incomingHash, c.GetBlockHash())
-}
-
-func TestCodeCache_ValidateAndPrepare_MatchingParent(t *testing.T) {
- c := NewCodeCache(100, 200)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
-
- c.SetBlockHash(block1)
- c.Put(makeAddr(1), makeCode(1))
-
- valid := c.ValidateAndPrepare(block1, block2)
- assert.True(t, valid)
- assert.Equal(t, block2, c.GetBlockHash())
- // Addr mapping should be preserved
- assert.Equal(t, 1, c.Len())
-}
-
-func TestCodeCache_ValidateAndPrepare_Mismatch(t *testing.T) {
- c := NewCodeCache(100, 200)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
- block3 := makeHash(3)
-
- c.SetBlockHash(block1)
- c.Put(makeAddr(1), makeCode(1))
-
- valid := c.ValidateAndPrepare(block2, block3)
- assert.False(t, valid)
- assert.Equal(t, block3, c.GetBlockHash())
- // Addr mapping should be cleared
- assert.Equal(t, 0, c.Len())
- // Code should still exist (immutable)
- assert.Equal(t, 1, c.CodeLen())
-}
-
-func TestCodeCache_ClearWithHash(t *testing.T) {
- c := NewCodeCache(100, 200)
-
- c.Put(makeAddr(1), makeCode(1))
- c.SetBlockHash(makeHash(1))
-
- newHash := makeHash(2)
- c.ClearWithHash(newHash)
-
- assert.Equal(t, 0, c.Len())
- assert.Equal(t, newHash, c.GetBlockHash())
- // Code should still exist
- assert.Equal(t, 1, c.CodeLen())
-}
-
func TestCodeCache_PrintStatsAndReset(t *testing.T) {
c := NewCodeCache(100, 200)
- c.Put(makeAddr(1), makeCode(1))
+ c.Put(makeAddr(1), makeCode(1), 0)
c.Get(makeAddr(1)) // hit
c.Get(makeAddr(2)) // miss
@@ -497,7 +388,7 @@ func TestCodeCache_GetMissingCode(t *testing.T) {
// Manually set addr mapping without code (simulates capacity limit scenario)
addr := makeAddr(1)
code := makeCode(1)
- c.Put(addr, code)
+ c.Put(addr, code, 0)
// Clear the code cache but keep addr mapping
c.hashToCode.Clear()
@@ -551,7 +442,7 @@ func TestStateCache_GetPut_Account(t *testing.T) {
assert.Nil(t, v)
// Put and Get
- c.Put(kv.AccountsDomain, addr, value)
+ c.Put(kv.AccountsDomain, addr, value, 0)
v, ok = c.Get(kv.AccountsDomain, addr)
assert.True(t, ok)
assert.Equal(t, value, v)
@@ -565,7 +456,7 @@ func TestStateCache_GetPut_Storage(t *testing.T) {
key[51] = 1
value := makeValue(1)
- c.Put(kv.StorageDomain, key, value)
+ c.Put(kv.StorageDomain, key, value, 0)
v, ok := c.Get(kv.StorageDomain, key)
assert.True(t, ok)
assert.Equal(t, value, v)
@@ -577,7 +468,7 @@ func TestStateCache_GetPut_Code(t *testing.T) {
addr := makeAddr(1)
code := makeCode(1)
- c.Put(kv.CodeDomain, addr, code)
+ c.Put(kv.CodeDomain, addr, code, 0)
v, ok := c.Get(kv.CodeDomain, addr)
assert.True(t, ok)
assert.Equal(t, code, v)
@@ -587,7 +478,7 @@ func TestStateCache_GetPut_UnsupportedDomain(t *testing.T) {
c := NewStateCache(100, 100, 100, 100, 100)
// ReceiptDomain is not supported
- c.Put(kv.ReceiptDomain, makeAddr(1), makeValue(1))
+ c.Put(kv.ReceiptDomain, makeAddr(1), makeValue(1), 0)
v, ok := c.Get(kv.ReceiptDomain, makeAddr(1))
assert.False(t, ok)
assert.Nil(t, v)
@@ -597,7 +488,7 @@ func TestStateCache_Delete(t *testing.T) {
c := NewStateCache(100, 100, 100, 100, 100)
addr := makeAddr(1)
- c.Put(kv.AccountsDomain, addr, makeValue(1))
+ c.Put(kv.AccountsDomain, addr, makeValue(1), 0)
c.Delete(kv.AccountsDomain, addr)
_, ok := c.Get(kv.AccountsDomain, addr)
@@ -614,7 +505,7 @@ func TestStateCache_PutEmpty_ThenGet_IsCacheHit(t *testing.T) {
key[0] = 0x1d
key[51] = 0xa2
- c.Put(kv.StorageDomain, key, nil)
+ c.Put(kv.StorageDomain, key, nil, 0)
v, ok := c.Get(kv.StorageDomain, key)
assert.True(t, ok, "Get after Put(nil) must be a cache hit, not a miss")
@@ -629,7 +520,7 @@ func TestStateCache_PutEmptySlice_ThenGet_IsCacheHit(t *testing.T) {
key[0] = 0x1d
key[51] = 0xa2
- c.Put(kv.StorageDomain, key, []byte{})
+ c.Put(kv.StorageDomain, key, []byte{}, 0)
v, ok := c.Get(kv.StorageDomain, key)
assert.True(t, ok, "Get after Put([]byte{}) must be a cache hit")
@@ -646,9 +537,9 @@ func TestStateCache_Delete_UnsupportedDomain(t *testing.T) {
func TestStateCache_Clear(t *testing.T) {
c := NewStateCache(100, 100, 100, 100, 100)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
- c.Put(kv.StorageDomain, makeAddr(2), makeValue(2))
- c.Put(kv.CodeDomain, makeAddr(3), makeCode(3))
+ c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1), 0)
+ c.Put(kv.StorageDomain, makeAddr(2), makeValue(2), 0)
+ c.Put(kv.CodeDomain, makeAddr(3), makeCode(3), 0)
c.Clear()
@@ -661,51 +552,6 @@ func TestStateCache_Clear(t *testing.T) {
assert.False(t, ok3)
}
-func TestStateCache_ValidateAndPrepare_AllValid(t *testing.T) {
- c := NewStateCache(100, 100, 100, 100, 100)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
-
- // Set same block hash on all caches
- c.GetCache(kv.AccountsDomain).SetBlockHash(block1)
- c.GetCache(kv.StorageDomain).SetBlockHash(block1)
- c.GetCache(kv.CodeDomain).SetBlockHash(block1)
-
- valid := c.ValidateAndPrepare(block1, block2)
- assert.True(t, valid)
-}
-
-func TestStateCache_ValidateAndPrepare_SomeMismatch(t *testing.T) {
- c := NewStateCache(100, 100, 100, 100, 100)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
- block3 := makeHash(3)
-
- // Set different block hashes
- c.GetCache(kv.AccountsDomain).SetBlockHash(block1)
- c.GetCache(kv.StorageDomain).SetBlockHash(block2) // mismatch
- c.GetCache(kv.CodeDomain).SetBlockHash(block1)
-
- valid := c.ValidateAndPrepare(block1, block3)
- assert.False(t, valid)
-}
-
-func TestStateCache_ClearWithHash(t *testing.T) {
- c := NewStateCache(100, 100, 100, 100, 100)
-
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
- c.Put(kv.StorageDomain, makeAddr(2), makeValue(2))
-
- newHash := makeHash(5)
- c.ClearWithHash(newHash)
-
- assert.Equal(t, newHash, c.GetCache(kv.AccountsDomain).GetBlockHash())
- assert.Equal(t, newHash, c.GetCache(kv.StorageDomain).GetBlockHash())
- assert.Equal(t, newHash, c.GetCache(kv.CodeDomain).GetBlockHash())
-}
-
func TestStateCache_GetCache_OutOfBounds(t *testing.T) {
c := NewStateCache(100, 100, 100, 100, 100)
@@ -729,7 +575,7 @@ func TestDomainCache_ConcurrentAccess(t *testing.T) {
// Writer goroutine
go func() {
for i := 0; i < 100; i++ {
- c.Put(makeAddr(i), makeValue(i))
+ c.Put(makeAddr(i), makeValue(i), 0)
}
done <- true
}()
@@ -754,7 +600,7 @@ func TestCodeCache_ConcurrentAccess(t *testing.T) {
// Writer goroutine
go func() {
for i := 0; i < 100; i++ {
- c.Put(makeAddr(i), makeCode(i))
+ c.Put(makeAddr(i), makeCode(i), 0)
}
done <- true
}()
@@ -783,9 +629,9 @@ func TestStateCache_DomainIsolation(t *testing.T) {
storageData := []byte("storage")
codeData := []byte{0x60, 0x00, 0x60, 0x00} // valid code
- c.Put(kv.AccountsDomain, addr, accountData)
- c.Put(kv.StorageDomain, addr, storageData)
- c.Put(kv.CodeDomain, addr, codeData)
+ c.Put(kv.AccountsDomain, addr, accountData, 0)
+ c.Put(kv.StorageDomain, addr, storageData, 0)
+ c.Put(kv.CodeDomain, addr, codeData, 0)
v1, ok1 := c.Get(kv.AccountsDomain, addr)
v2, ok2 := c.Get(kv.StorageDomain, addr)
@@ -804,50 +650,13 @@ func TestStateCache_DomainIsolation(t *testing.T) {
// Block Continuity Tests
// =============================================================================
-func TestBlockContinuity_Sequential(t *testing.T) {
- c := NewDefaultStateCache()
-
- block0 := common.Hash{}
- block1 := makeHash(1)
- block2 := makeHash(2)
- block3 := makeHash(3)
-
- // Block 1 (parent = empty)
- valid := c.ValidateAndPrepare(block0, block1)
- assert.True(t, valid)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
-
- // Block 2 (parent = block1)
- valid = c.ValidateAndPrepare(block1, block2)
- assert.True(t, valid)
- // Data from block 1 should still be there
- _, ok := c.Get(kv.AccountsDomain, makeAddr(1))
- assert.True(t, ok)
-
- // Block 3 (parent = block2)
- valid = c.ValidateAndPrepare(block2, block3)
- assert.True(t, valid)
-}
-
-func TestBlockContinuity_Reorg(t *testing.T) {
- c := NewDefaultStateCache()
-
- block1 := makeHash(1)
- block2 := makeHash(2)
- block2prime := makeHash(22) // fork
-
- // Build on block 1
- c.ValidateAndPrepare(common.Hash{}, block1)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
-
- // Continue to block 2
- c.ValidateAndPrepare(block1, block2)
-
- // Reorg: new block with different parent
- valid := c.ValidateAndPrepare(block1, block2prime)
- assert.False(t, valid) // Should detect mismatch
-}
-
+// Fork-validation (engine_newPayload) of a block building on the canonical tip
+// must NOT purge the hot cache when that block is subsequently applied
+// canonically. Regression for the tip purge_rate bug: fork-validation advancing
+// blockHash to the speculative block made the canonical apply mismatch & purge.
+// Fork-validation of a block on a DIFFERENT parent (reorg proposal) must still
+// purge, since cache-as-of-canonical-tip would serve incoherent reads, but it
+// must not advance blockHash (canonical continues cleanly afterward).
// =============================================================================
// RevertWithDiffset Tests
// =============================================================================
@@ -869,134 +678,99 @@ func makeDiffKey(baseKey []byte, step uint64) string {
return string(k)
}
-func TestRevertWithDiffset_SurgicalEviction(t *testing.T) {
- c := NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)
-
- blockTip := makeHash(3)
- blockTarget := makeHash(1)
-
- // Simulate cache state at the tip block.
- c.ClearWithHash(blockTip)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1)) // touched by unwind
- c.Put(kv.AccountsDomain, makeAddr(2), makeValue(2)) // untouched
- c.Put(kv.StorageDomain, makeAddr(3), makeValue(3)) // touched by unwind
- c.Put(kv.StorageDomain, makeAddr(4), makeValue(4)) // untouched
-
- // Build diffset: addr(1) changed in accounts, addr(3) changed in storage.
- var diffset [kv.DomainLen][]kv.DomainEntryDiff
- diffset[kv.AccountsDomain] = []kv.DomainEntryDiff{
- {Key: makeDiffKey(makeAddr(1), 0), Value: makeValue(10)},
- }
- diffset[kv.StorageDomain] = []kv.DomainEntryDiff{
- {Key: makeDiffKey(makeAddr(3), 0), Value: makeValue(30)},
- }
+// --- txNum/epoch unwind invalidation (replaces the blockHash/diffset model) ---
- c.RevertWithDiffset(&diffset, blockTip, blockTarget)
+// Entries stamped at/below the unwind point survive (warm hot set kept); entries
+// above it from the now-dead epoch are dropped lazily on read.
+func TestUnwind_KeepsBelowFloor_EvictsAbove(t *testing.T) {
+ c := NewDomainCache(1 * datasize.MB)
+ below := makeAddr(1)
+ above := makeAddr(2)
+ c.Put(below, makeValue(1), 50) // predates the unwind
+ c.Put(above, makeValue(2), 150) // written in the unwound range
- // Touched keys should be evicted (cache miss).
- _, ok := c.Get(kv.AccountsDomain, makeAddr(1))
- assert.False(t, ok, "touched account key should be evicted")
- _, ok = c.Get(kv.StorageDomain, makeAddr(3))
- assert.False(t, ok, "touched storage key should be evicted")
+ c.Unwind(100) // floor=100 (first unwound txNum): keep <100, drop >=100
- // Untouched keys should be preserved.
- v, ok := c.Get(kv.AccountsDomain, makeAddr(2))
- assert.True(t, ok, "untouched account key should be preserved")
- assert.Equal(t, makeValue(2), v)
- v, ok = c.Get(kv.StorageDomain, makeAddr(4))
- assert.True(t, ok, "untouched storage key should be preserved")
- assert.Equal(t, makeValue(4), v)
+ v, ok := c.Get(below)
+ assert.True(t, ok, "entry below the unwind point must stay warm")
+ assert.Equal(t, makeValue(1), v)
- // Block hash should be updated to the unwind target.
- assert.Equal(t, blockTarget, c.GetCache(kv.AccountsDomain).GetBlockHash())
- assert.Equal(t, blockTarget, c.GetCache(kv.StorageDomain).GetBlockHash())
+ _, ok = c.Get(above)
+ assert.False(t, ok, "entry above the unwind point must be invalidated")
+ assert.Equal(t, 1, c.Len(), "the stale entry is evicted lazily on its read")
}
-func TestRevertWithDiffset_HashMismatch_ClearsAll(t *testing.T) {
- c := NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)
+// Boundary regression: unwindToTxNum is the FIRST rolled-back txNum
+// (Min(UnwindPoint+1)), so an entry stamped at exactly that txNum belongs to the
+// first dead block and must be evicted — the drop rule is txNum>=floor, not
+// txNum>floor. This reproduces the Sidechain Reorg wrong-root bug: an EIP-4788
+// beacon-root storage write runs in a block's begin-system-tx, stamped at the
+// block's first txNum; when that block is the first unwound one, txNum==floor and
+// a strict `>` left the dead-fork value served stale.
+func TestUnwind_EvictsEntryAtFloor(t *testing.T) {
+ c := NewDomainCache(1 * datasize.MB)
+ atFloor := makeAddr(1)
+ belowFloor := makeAddr(2)
+ c.Put(atFloor, makeValue(1), 100) // first txNum of the first unwound block
+ c.Put(belowFloor, makeValue(2), 99) // last txNum of the surviving block
- blockTip := makeHash(3)
- blockStale := makeHash(99) // doesn't match any cache
- blockTarget := makeHash(1)
+ c.Unwind(100) // floor=100, epoch->1
- c.ClearWithHash(blockTip)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
- c.Put(kv.StorageDomain, makeAddr(2), makeValue(2))
+ _, ok := c.Get(atFloor)
+ assert.False(t, ok, "entry at txNum==floor is on the dead fork and must be evicted")
- var diffset [kv.DomainLen][]kv.DomainEntryDiff
-
- // revertFromHash doesn't match the cache's block hash — full clear.
- c.RevertWithDiffset(&diffset, blockStale, blockTarget)
-
- _, ok := c.Get(kv.AccountsDomain, makeAddr(1))
- assert.False(t, ok, "all keys should be cleared on hash mismatch")
- _, ok = c.Get(kv.StorageDomain, makeAddr(2))
- assert.False(t, ok, "all keys should be cleared on hash mismatch")
-
- assert.Equal(t, blockTarget, c.GetCache(kv.AccountsDomain).GetBlockHash())
+ v, ok := c.Get(belowFloor)
+ assert.True(t, ok, "entry at txNum==floor-1 predates the unwind and must stay warm")
+ assert.Equal(t, makeValue(2), v)
}
-func TestRevertWithDiffset_AccountChange_EvictsCode(t *testing.T) {
- c := NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)
+// The reused-txNum case: after an unwind, the live fork re-writes a key at the
+// SAME txNum as the dead fork's write. The epoch — not the txNum — distinguishes
+// them, so the dead entry reads stale and the re-written one reads valid.
+func TestUnwind_ReusedTxNumDisambiguatedByEpoch(t *testing.T) {
+ c := NewDomainCache(1 * datasize.MB)
+ k := makeAddr(1)
+ c.Put(k, makeValue(1), 150) // dead fork, epoch 0
- blockTip := makeHash(5)
- blockTarget := makeHash(3)
+ c.Unwind(100) // epoch -> 1, floor -> 100
- c.ClearWithHash(blockTip)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
- c.Put(kv.CodeDomain, makeAddr(1), makeCode(1))
- c.Put(kv.CodeDomain, makeAddr(2), makeCode(2)) // untouched
+ _, ok := c.Get(k)
+ assert.False(t, ok, "dead-fork entry (old epoch, above floor) reads stale")
- var diffset [kv.DomainLen][]kv.DomainEntryDiff
- diffset[kv.AccountsDomain] = []kv.DomainEntryDiff{
- {Key: makeDiffKey(makeAddr(1), 0), Value: makeValue(10)},
- }
+ c.Put(k, makeValue(2), 150) // live fork re-writes at the same txNum, epoch 1
+ v, ok := c.Get(k)
+ assert.True(t, ok, "live-fork entry at the same txNum is valid (current epoch)")
+ assert.Equal(t, makeValue(2), v)
+}
- c.RevertWithDiffset(&diffset, blockTip, blockTarget)
+// A straggler the live fork never re-writes must not resurrect: it stays in a
+// dead epoch above the floor and reads stale no matter how far execution
+// advances afterwards (there is no rising high-water mark to re-validate it).
+func TestUnwind_StragglerNeverResurrects(t *testing.T) {
+ c := NewDomainCache(1 * datasize.MB)
+ straggler := makeAddr(1)
+ c.Put(straggler, makeValue(1), 150) // epoch 0
- // Account change evicts both the account and its code.
- _, ok := c.Get(kv.AccountsDomain, makeAddr(1))
- assert.False(t, ok, "touched account should be evicted")
- _, ok = c.Get(kv.CodeDomain, makeAddr(1))
- assert.False(t, ok, "code for touched account should be evicted")
+ c.Unwind(100) // epoch 1
- // Unrelated code entry preserved.
- v, ok := c.Get(kv.CodeDomain, makeAddr(2))
- assert.True(t, ok, "untouched code should be preserved")
- assert.Equal(t, makeCode(2), v)
+ // Advance the live fork far past the straggler's txNum (no re-write of it).
+ for i := 2; i < 50; i++ {
+ c.Put(makeAddr(i), makeValue(i), uint64(200+i))
+ }
+ _, ok := c.Get(straggler)
+ assert.False(t, ok, "straggler in a dead epoch must stay stale, never resurrect")
}
-func TestRevertWithDiffset_ThenValidateAndPrepare_Continuity(t *testing.T) {
- c := NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)
-
- block1 := makeHash(1)
- block2 := makeHash(2)
- block3 := makeHash(3)
- block2new := makeHash(22) // new block 2 after re-execution
-
- // Execute blocks 1, 2, 3.
- c.ValidateAndPrepare(common.Hash{}, block1)
- c.Put(kv.AccountsDomain, makeAddr(1), makeValue(1))
- c.ValidateAndPrepare(block1, block2)
- c.Put(kv.AccountsDomain, makeAddr(2), makeValue(2))
- c.ValidateAndPrepare(block2, block3)
- c.Put(kv.AccountsDomain, makeAddr(3), makeValue(3))
-
- // Unwind to block 1: revert blocks 2-3.
- var diffset [kv.DomainLen][]kv.DomainEntryDiff
- diffset[kv.AccountsDomain] = []kv.DomainEntryDiff{
- {Key: makeDiffKey(makeAddr(2), 0), Value: nil},
- {Key: makeDiffKey(makeAddr(3), 0), Value: nil},
- }
- c.RevertWithDiffset(&diffset, block3, block1)
+// A second, shallower unwind must not resurrect entries a deeper earlier unwind
+// invalidated (floor only moves down).
+func TestUnwind_FloorOnlyMovesDown(t *testing.T) {
+ c := NewDomainCache(1 * datasize.MB)
+ k := makeAddr(1)
+ c.Put(k, makeValue(1), 70) // epoch 0
- // Cache should now have blockHash = block1.
- // Executing a new block 2 with parent=block1 should preserve surviving cache data.
- valid := c.ValidateAndPrepare(block1, block2new)
- assert.True(t, valid, "ValidateAndPrepare should succeed after surgical unwind")
+ c.Unwind(50) // floor 50, epoch 1 — k(70>50, epoch0) now stale
+ c.Unwind(100) // shallower; floor must stay 50, not rise to 100
- // addr(1) was not in the diffset — should survive both the revert and ValidateAndPrepare.
- v, ok := c.Get(kv.AccountsDomain, makeAddr(1))
- assert.True(t, ok, "untouched key should survive revert + ValidateAndPrepare")
- assert.Equal(t, makeValue(1), v)
+ _, ok := c.Get(k)
+ assert.False(t, ok, "deeper unwind's floor must not be raised by a later shallower one")
}
diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go
index 39351e8c6a8..fc94bbe04c6 100644
--- a/execution/cache/code_cache.go
+++ b/execution/cache/code_cache.go
@@ -17,12 +17,12 @@
package cache
import (
- "sync"
"sync/atomic"
"unsafe"
"github.com/c2h5oh/datasize"
- "github.com/erigontech/erigon/common"
+ lru "github.com/hashicorp/golang-lru/v2"
+
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/common/maphash"
)
@@ -33,20 +33,39 @@ func uint64AsBytes(v *uint64) []byte {
}
const (
- // DefaultCodeCacheBytes is the byte limit for code cache (512 MB)
- DefaultCodeCacheBytes = 512 * datasize.MB
+ // DefaultCodeCacheBytes is the byte limit for code cache (100 MB — investigation knob; permanent default returns to 512 MB)
+ DefaultCodeCacheBytes = 100 * datasize.MB
// DefaultAddrCacheBytes is the byte limit for address cache (16 MB)
DefaultAddrCacheBytes = 16 * datasize.MB
+ // DefaultAddrCacheEntries derives from DefaultAddrCacheBytes assuming
+ // ~28 bytes per entry (20-byte addr + 8-byte maphash codeID). Used as
+ // the LRU entry cap so the cache evicts oldest entries instead of
+ // silently dropping new ones when full — fresh-address workloads
+ // (e.g. mainnet thousands of new addrs per block) actually warm up
+ // over time, matching geth's lru.Cache pattern in
+ // core/state/database_code.go.
+ DefaultAddrCacheEntries = int(DefaultAddrCacheBytes) / 28
+ // DefaultCodeSizeCacheEntries is the max entry count for the size-only
+ // cache (geth-style: code size answers without loading bytes for
+ // EXTCODESIZE / EXTCODEHASH callers).
+ DefaultCodeSizeCacheEntries int64 = 1_000_000
)
-// CodeCache is a two-level concurrent cache for contract code.
+// CodeCache is a multi-level concurrent cache for contract code.
// Level 1: addr → maphash(code) (mutable, cleared on reorg)
// Level 2: maphash(code) → code (immutable, never cleared)
+// Level 2b: ethHash(code) → code (immutable, never cleared) — enables
+// bypass of the addr level when the caller already knows the
+// Ethereum codeHash from a prior account read (common path
+// for EXTCODESIZE / EXTCODEHASH / CALL where many addresses
+// share the same bytecode — proxies, factory-deployed clones,
+// ERC-20 holders, etc.).
//
// This design is efficient because:
// - Multiple addresses can share the same code (common with proxies/clones)
-// - Uses fast maphash instead of cryptographic Keccak256
+// - Uses fast maphash instead of cryptographic Keccak256 for L1/L2
// - Address mappings are small (8 bytes) so we can cache many more
+// - L2b lets callers with the codeHash in hand skip L1 entirely
// - Thread-safe via sync.Map
//
// Capacity is byte-based. Once full, new puts are no-ops but
@@ -57,18 +76,48 @@ type versionedAddressID struct {
}
type CodeCache struct {
- addrToHash *maphash.Map[versionedAddressID] // addr → maphash(code), concurrent
- addrSize atomic.Int64 // current size in bytes
- hashToCode *maphash.Map[[]byte] // maphash(code) → code, concurrent
- codeSize atomic.Int64 // current size in bytes (code only, hash is fixed 8 bytes)
- blockHash common.Hash // hash of the last block processed
- mu sync.RWMutex
+ // addrToHash maps a 20-byte Ethereum address to the maphash-derived
+ // codeID for the code at that address. Real LRU (was a no-op-when-full
+ // maphash.Map until commit 7d0998d0db) — fresh-address workloads now
+ // evict oldest entries and warm up the working set, matching geth's
+ // lru.Cache pattern at core/state/database_code.go.
+ addrToHash *lru.Cache[[20]byte, versionedAddressID]
+ hashToCode *maphash.Map[[]byte] // maphash(code) → code, concurrent
+ codeSize atomic.Int64 // current size in bytes (code only, hash is fixed 8 bytes)
+
+ // addrToEthHash maps a 20-byte address to its 32-byte Ethereum codeHash
+ // (keccak), separately from addrToHash (which uses the cheap maphash
+ // for bytes-lookup chaining). Used by SharedDomains.codeHashForAddr to
+ // skip a cold account-domain read when the EVM-known codeHash is
+ // already in cache. Nethermind-style addr → codeHash LRU.
+ addrToEthHash *lru.Cache[[20]byte, [32]byte]
+
+ // L2b: 32-byte Ethereum codeHash (keccak256) → code bytes. Populated
+ // alongside L2 when the caller provides ethHash on Put. Independent
+ // of L1 — Get-by-ethHash bypasses addr lookup entirely. Memory cost:
+ // duplicates code bytes vs L2 (worst case 2x byte storage); accepted
+ // for the per-key fast-path on many-addrs-one-code workloads.
+ ethHashToCode *maphash.Map[[]byte] // keccak(code) → code, concurrent
+ ethHashCodeSize atomic.Int64 // current size in bytes (ethHash layer)
+
+ // Size-only layer: ethCodeHash → int (length in bytes). Answers
+ // EXTCODESIZE / EXTCODEHASH without loading the bytes. Geth has the
+ // equivalent at core/state/database_code.go (1 M-entry LRU). Tiny
+ // per-entry footprint (32B key + 8B value) so the same memory budget
+ // gives ~1000x the hit surface vs the bytes cache.
+ codeSizeByEthHash *maphash.Map[int]
+ codeSizeEntries atomic.Int64
+ codeSizeCapEntries int64
// Stats counters (atomic for concurrent access)
- addrHits atomic.Uint64
- addrMisses atomic.Uint64
- codeHits atomic.Uint64
- codeMisses atomic.Uint64
+ addrHits atomic.Uint64
+ addrMisses atomic.Uint64
+ codeHits atomic.Uint64
+ codeMisses atomic.Uint64
+ ethHashHits atomic.Uint64
+ ethHashMisses atomic.Uint64
+ codeSizeHits atomic.Uint64
+ codeSizeMisses atomic.Uint64
addrCapacityB datasize.ByteSize // capacity in bytes
codeCapacityB datasize.ByteSize // capacity in bytes
@@ -76,12 +125,42 @@ type CodeCache struct {
// NewCodeCache creates a new CodeCache with the specified byte capacities.
func NewCodeCache(codeCapacityBytes, addrCapacityBytes datasize.ByteSize) *CodeCache {
+ addrEntries := int(addrCapacityBytes) / 28
+ if addrEntries < 1024 {
+ addrEntries = 1024
+ }
+ addrLRU, err := lru.New[[20]byte, versionedAddressID](addrEntries)
+ if err != nil {
+ panic(err)
+ }
+ addrEthHashLRU, err := lru.New[[20]byte, [32]byte](addrEntries)
+ if err != nil {
+ panic(err)
+ }
return &CodeCache{
- addrToHash: maphash.NewMap[versionedAddressID](),
- hashToCode: maphash.NewMap[[]byte](),
- addrCapacityB: addrCapacityBytes,
- codeCapacityB: codeCapacityBytes,
+ addrToHash: addrLRU,
+ addrToEthHash: addrEthHashLRU,
+ hashToCode: maphash.NewMap[[]byte](),
+ ethHashToCode: maphash.NewMap[[]byte](),
+ codeSizeByEthHash: maphash.NewMap[int](),
+ codeSizeCapEntries: DefaultCodeSizeCacheEntries,
+ addrCapacityB: addrCapacityBytes,
+ codeCapacityB: codeCapacityBytes,
+ }
+}
+
+// addrKey casts a 20-byte slice to a fixed-size key without allocation.
+// Caller MUST pass a 20-byte slice (all Ethereum addresses are 20 bytes).
+// Returns the zero [20]byte if addr is shorter; only longer slices are
+// truncated silently — defensive but should not happen on the hot path.
+func addrKey(addr []byte) [20]byte {
+ var k [20]byte
+ if len(addr) >= 20 {
+ copy(k[:], addr[:20])
+ } else {
+ copy(k[:], addr)
}
+ return k
}
// NewDefaultCodeCache creates a new CodeCache with the default sizes.
@@ -91,15 +170,14 @@ func NewDefaultCodeCache() *CodeCache {
// Get retrieves contract code for the given address, implementing the Cache interface.
func (c *CodeCache) Get(addr []byte) ([]byte, bool) {
- // First, look up the code hash for this address
- vID, ok := c.addrToHash.Get(addr)
+ k := addrKey(addr)
+ vID, ok := c.addrToHash.Get(k)
if !ok || vID.addrID == 0 {
c.addrMisses.Add(1)
return nil, false
}
c.addrHits.Add(1)
- // Then, look up the code by hash
code, ok := c.hashToCode.Get(uint64AsBytes(&vID.addrID))
if !ok || len(code) == 0 {
c.codeMisses.Add(1)
@@ -111,107 +189,158 @@ func (c *CodeCache) Get(addr []byte) ([]byte, bool) {
// Put stores contract code for the given address, implementing the Cache interface.
// Uses fast maphash to compute the code identifier.
-// If caches are at capacity, new entries are no-ops but updates are allowed.
-func (c *CodeCache) Put(addr []byte, code []byte) {
+// addrToHash is an LRU (auto-evicts oldest when full); hashToCode is byte-capped
+// and no-ops on new writes when full (code is immutable, so existing entries stay).
+func (c *CodeCache) Put(addr []byte, code []byte, _ uint64) {
if len(code) == 0 {
return
}
codeHash := maphash.Hash(code)
- addrEntrySize := int64(len(addr) + 8) // addr + uint64 hash
-
- // Check if addr already exists - updates are always allowed
- if _, exists := c.addrToHash.Get(addr); exists {
- c.addrToHash.Set(addr, versionedAddressID{addrID: codeHash})
- } else if c.addrSize.Load()+addrEntrySize <= int64(c.addrCapacityB) {
- c.addrToHash.Set(addr, versionedAddressID{addrID: codeHash})
- c.addrSize.Add(addrEntrySize)
- }
- // Check if code already stored
+ c.addrToHash.Add(addrKey(addr), versionedAddressID{addrID: codeHash})
+
hashKey := uint64AsBytes(&codeHash)
if _, exists := c.hashToCode.Get(hashKey); exists {
return
}
-
- // New code entry - check capacity
- codeEntrySize := int64(8 + len(code)) // hash + code
+ codeEntrySize := int64(8 + len(code))
if c.codeSize.Load()+codeEntrySize > int64(c.codeCapacityB) {
- return // no-op when full
+ return
}
c.hashToCode.Set(hashKey, code)
c.codeSize.Add(codeEntrySize)
}
-// Delete removes the address → codeHash mapping.
-// The codeHash → code mapping is kept since it's immutable.
-func (c *CodeCache) Delete(addr []byte) {
- if _, exists := c.addrToHash.Get(addr); exists {
- addrEntrySize := int64(len(addr) + 8)
- c.addrToHash.Delete(addr)
- c.addrSize.Add(-addrEntrySize)
- }
+// GetAddrCodeHash returns the Ethereum codeHash for addr if cached.
+// Nethermind-style lookup that lets SharedDomains.codeHashForAddr skip a
+// cold AccountsDomain read when the EVM-known codeHash is already known.
+// Eviction is LRU; freshly seen addrs replace coldest entries.
+func (c *CodeCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) {
+ h, ok := c.addrToEthHash.Get(addrKey(addr))
+ return h, ok
}
-// Clear removes all address mappings from the cache.
-// The codeHash → code mappings are preserved since they're immutable.
-func (c *CodeCache) Clear() {
- c.addrToHash.Clear()
- c.addrSize.Store(0)
+// PutAddrCodeHash records the addr → codeHash mapping. Called from the
+// account-decode populate path inside SD.codeHashForAddr; also called by
+// readAhead's BAL prefetch when it learns the codeHash from the decoded
+// account record.
+func (c *CodeCache) PutAddrCodeHash(addr []byte, h [32]byte) {
+ c.addrToEthHash.Add(addrKey(addr), h)
}
-// GetBlockHash returns the hash of the last block processed by the cache.
-func (c *CodeCache) GetBlockHash() common.Hash {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return c.blockHash
+// DeleteAddrCodeHash drops the addr → codeHash mapping. Called on
+// SELFDESTRUCT / CREATE2-replace / unwind where the account's codeHash
+// has been mutated.
+func (c *CodeCache) DeleteAddrCodeHash(addr []byte) {
+ c.addrToEthHash.Remove(addrKey(addr))
}
-// SetBlockHash sets the hash of the current block being processed.
-func (c *CodeCache) SetBlockHash(hash common.Hash) {
- c.mu.Lock()
- c.blockHash = hash
- c.mu.Unlock()
+// GetByEthHash retrieves contract code by its Ethereum codeHash (keccak256).
+// Bypasses the addr-keyed L1/L2 path. Returns (code, true) on hit, (nil, false) on miss.
+//
+// Designed for the common path where the caller has already loaded the
+// account and knows the codeHash (EXTCODESIZE, EXTCODEHASH, CALL targets
+// after account-load). Many addresses sharing one codeHash all hit this
+// single L2b entry after the first population.
+func (c *CodeCache) GetByEthHash(ethHash []byte) ([]byte, bool) {
+ code, ok := c.ethHashToCode.Get(ethHash)
+ if !ok || len(code) == 0 {
+ c.ethHashMisses.Add(1)
+ return nil, false
+ }
+ c.ethHashHits.Add(1)
+ return code, true
+}
+
+// PutWithEthHash stores contract code, populating both the addr-keyed
+// path (L1+L2) and the ethHash-keyed path (L2b). Use when the caller
+// has the codeHash in hand (typically from a just-loaded account record);
+// avoids the maphash-vs-keccak collision risk of re-deriving the ethHash
+// from the value, and ensures L2b is fillable without an extra keccak.
+//
+// addr may be empty to populate only L2b (e.g. when populating from a
+// codehash-only path that hasn't seen the addr yet).
+func (c *CodeCache) PutWithEthHash(addr []byte, code []byte, ethHash []byte) {
+ if len(code) == 0 || len(ethHash) == 0 {
+ return
+ }
+
+ if len(addr) > 0 {
+ c.Put(addr, code, 0) // addr layer is cleared on unwind, not txNum-tracked
+ }
+
+ // Populate the size-only layer alongside the bytes layer — every time
+ // we touch the bytes we can answer a future EXTCODESIZE for free.
+ c.PutCodeSizeByEthHash(ethHash, len(code))
+
+ if _, exists := c.ethHashToCode.Get(ethHash); exists {
+ return
+ }
+ entrySize := int64(len(ethHash) + len(code))
+ if c.ethHashCodeSize.Load()+entrySize > int64(c.codeCapacityB) {
+ return
+ }
+ c.ethHashToCode.Set(ethHash, code)
+ c.ethHashCodeSize.Add(entrySize)
}
-// ValidateAndPrepare checks if the given parentHash matches the cache's current blockHash.
-// If there's a mismatch (indicating non-sequential block processing), the address cache is cleared.
-// The code cache (hash → code) is preserved since code hashes are immutable.
-// Returns true if the cache was valid (hashes matched or cache was empty), false if cleared.
-func (c *CodeCache) ValidateAndPrepare(parentHash common.Hash, incomingBlockHash common.Hash) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- // Empty blockHash means cache hasn't processed any block yet - always valid
- if c.blockHash == (common.Hash{}) {
- c.addrToHash.Clear()
- c.addrSize.Store(0)
- c.blockHash = incomingBlockHash
- return true
+// GetCodeSizeByEthHash retrieves the size (in bytes) of a contract by its
+// Ethereum codeHash, without loading the bytes. Returns (0, false) on miss.
+//
+// Designed for EXTCODESIZE / EXTCODEHASH which only need the length; on a
+// cache hit the caller answers a 4-instruction map probe instead of paying
+// the file-accessor + decompression stack for the full bytes. Geth has the
+// equivalent at core/state/database_code.go.
+func (c *CodeCache) GetCodeSizeByEthHash(ethHash []byte) (int, bool) {
+ size, ok := c.codeSizeByEthHash.Get(ethHash)
+ if !ok {
+ c.codeSizeMisses.Add(1)
+ return 0, false
}
+ c.codeSizeHits.Add(1)
+ return size, true
+}
- // Check if we're continuing from the expected block
- if c.blockHash == parentHash {
- c.blockHash = incomingBlockHash
- return true
+// PutCodeSizeByEthHash stores the size of code keyed by its Ethereum
+// codeHash. No-op when full (limitation; addrToHash-style LRU is queued as
+// a separate surgical change).
+func (c *CodeCache) PutCodeSizeByEthHash(ethHash []byte, size int) {
+ if len(ethHash) == 0 || size < 0 {
+ return
}
+ if _, exists := c.codeSizeByEthHash.Get(ethHash); exists {
+ return
+ }
+ if c.codeSizeEntries.Load() >= c.codeSizeCapEntries {
+ return
+ }
+ c.codeSizeByEthHash.Set(ethHash, size)
+ c.codeSizeEntries.Add(1)
+}
- // Mismatch - clear address mappings (they may be stale)
- // Keep code mappings since hash → code is immutable
- c.addrToHash.Clear()
- c.addrSize.Store(0)
- c.blockHash = incomingBlockHash
- return false
+// Delete removes the address → codeHash mapping.
+// The codeHash → code mapping is kept since it's immutable.
+func (c *CodeCache) Delete(addr []byte) {
+ c.addrToHash.Remove(addrKey(addr))
+}
+
+// Clear removes all address mappings from the cache.
+// The codeHash → code mappings are preserved since they're immutable.
+func (c *CodeCache) Clear() {
+ c.addrToHash.Purge()
+ c.addrToEthHash.Purge()
}
-// ClearWithHash clears the address cache and sets the block hash.
-// Used during unwind to reset the cache to a known state.
-func (c *CodeCache) ClearWithHash(hash common.Hash) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.addrToHash.Clear()
- c.addrSize.Store(0)
- c.blockHash = hash
+// Unwind drops the mutable address → code mappings, which may now reflect a
+// dead fork's code for an address. The content-addressed code layers
+// (hash → code, ethHash → code, codeSize) are immutable and kept. The addr
+// layers are small and clearing them on the rare unwind is cheap, so unlike
+// GenericCache they don't need epoch tracking. unwindToTxNum is accepted for
+// interface symmetry; the addr layers don't carry per-entry txNums.
+func (c *CodeCache) Unwind(_ uint64) {
+ c.addrToHash.Purge()
+ c.addrToEthHash.Purge()
}
// Len returns the number of entries in the address cache.
@@ -224,9 +353,10 @@ func (c *CodeCache) CodeLen() int {
return c.hashToCode.Len()
}
-// AddrSizeBytes returns the current size of the address cache in bytes.
+// AddrSizeBytes returns the estimated size of the address cache in bytes.
+// LRU-based; estimate uses ~28 bytes per entry (20-byte addr + 8-byte codeID).
func (c *CodeCache) AddrSizeBytes() int64 {
- return c.addrSize.Load()
+ return int64(c.addrToHash.Len() * 28)
}
// CodeSizeBytes returns the current size of the code cache in bytes.
@@ -253,7 +383,7 @@ func (c *CodeCache) PrintStatsAndReset() {
codeHitRate = float64(codeHits) / float64(codeTotal) * 100
}
- addrSizeB := c.addrSize.Load()
+ addrSizeB := c.AddrSizeBytes()
codeSizeB := c.codeSize.Load()
addrUsagePct := float64(addrSizeB) / float64(c.addrCapacityB) * 100
codeUsagePct := float64(codeSizeB) / float64(c.codeCapacityB) * 100
diff --git a/execution/cache/code_cache_ethhash_test.go b/execution/cache/code_cache_ethhash_test.go
new file mode 100644
index 00000000000..e8580533e83
--- /dev/null
+++ b/execution/cache/code_cache_ethhash_test.go
@@ -0,0 +1,214 @@
+// 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 .
+
+package cache
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/c2h5oh/datasize"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// makeEthHash builds a deterministic 32-byte ethHash for tests/benchmarks.
+func makeEthHash(i int) []byte {
+ h := make([]byte, 32)
+ h[0] = byte(i)
+ h[1] = byte(i >> 8)
+ return h
+}
+
+func TestCodeCache_GetByEthHash_HitAfterPut(t *testing.T) {
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ addr := makeAddr(1)
+ code := []byte{0x60, 0x80, 0x60, 0x40, 0x52} // small contract preamble
+ ethHash := makeEthHash(0xab)
+
+ // Empty cache: miss.
+ v, ok := c.GetByEthHash(ethHash)
+ require.False(t, ok)
+ require.Nil(t, v)
+
+ // Populate via PutWithEthHash — both addr and ethHash paths fill.
+ c.PutWithEthHash(addr, code, ethHash)
+
+ // Hit by ethHash directly.
+ v, ok = c.GetByEthHash(ethHash)
+ require.True(t, ok)
+ require.True(t, bytes.Equal(v, code))
+
+ // Hit by addr via existing two-level path.
+ v, ok = c.Get(addr)
+ require.True(t, ok)
+ require.True(t, bytes.Equal(v, code))
+}
+
+func TestCodeCache_GetByEthHash_DistinctAddrsSameCode(t *testing.T) {
+ // The point of L2b: many addresses sharing one codeHash all hit a
+ // single entry once any one of them has been populated.
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ code := []byte{0x60, 0x80, 0x60, 0x40, 0x52}
+ ethHash := makeEthHash(0xcd)
+
+ addr1 := makeAddr(1)
+ c.PutWithEthHash(addr1, code, ethHash)
+
+ // A different address never seen at L1 still hits the L2b layer
+ // when looked up by the shared ethHash.
+ v, ok := c.GetByEthHash(ethHash)
+ require.True(t, ok)
+ require.True(t, bytes.Equal(v, code))
+
+ // The addr2 lookup itself still misses (L1 unknown), as expected —
+ // L2b is meant to be probed by callers that already hold the hash.
+ addr2 := makeAddr(2)
+ _, ok = c.Get(addr2)
+ require.False(t, ok)
+}
+
+func TestCodeCache_PutWithEthHash_EmptyHashOrCodeIsNoOp(t *testing.T) {
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ addr := makeAddr(1)
+ code := []byte{0x60, 0x00}
+
+ c.PutWithEthHash(addr, code, nil) // empty hash → skip L2b
+ v, ok := c.GetByEthHash(makeEthHash(7))
+ require.False(t, ok)
+ require.Nil(t, v)
+
+ c.PutWithEthHash(addr, nil, makeEthHash(7)) // empty code → skip both
+ v, ok = c.GetByEthHash(makeEthHash(7))
+ require.False(t, ok)
+ require.Nil(t, v)
+}
+
+func TestCodeCache_PutWithEthHash_RespectsCodeCapacity(t *testing.T) {
+ // 8-byte cap: 32-byte ethHash + 4-byte code > 32. New L2b puts must
+ // no-op when the layer is full. Use tiny code to keep math obvious.
+ c := NewCodeCache(8, 1*datasize.MB)
+ c.PutWithEthHash(makeAddr(1), []byte{1, 2, 3, 4}, makeEthHash(1))
+ // Second put exceeds the L2b budget — must no-op.
+ c.PutWithEthHash(makeAddr(2), []byte{5, 6, 7, 8}, makeEthHash(2))
+
+ _, ok := c.GetByEthHash(makeEthHash(2))
+ assert.False(t, ok, "second L2b entry should not exist when capacity is exceeded")
+}
+
+func TestCodeCache_CodeSize_PopulatedAlongsideBytes(t *testing.T) {
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ code := []byte{0x60, 0x80, 0x60, 0x40, 0x52, 0x60, 0x10}
+ ethHash := makeEthHash(0xee)
+
+ // Miss before any populate.
+ _, ok := c.GetCodeSizeByEthHash(ethHash)
+ require.False(t, ok)
+
+ // PutWithEthHash should fill the size layer alongside the bytes.
+ c.PutWithEthHash(makeAddr(1), code, ethHash)
+
+ size, ok := c.GetCodeSizeByEthHash(ethHash)
+ require.True(t, ok)
+ require.Equal(t, len(code), size)
+}
+
+func TestCodeCache_CodeSize_DirectPutAndGet(t *testing.T) {
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ ethHash := makeEthHash(0xff)
+
+ // Direct Put without going through the bytes layer.
+ c.PutCodeSizeByEthHash(ethHash, 4096)
+
+ size, ok := c.GetCodeSizeByEthHash(ethHash)
+ require.True(t, ok)
+ require.Equal(t, 4096, size)
+}
+
+func TestCodeCache_CodeSize_EmptyHashOrNegativeIsNoOp(t *testing.T) {
+ c := NewCodeCache(1*datasize.MB, 1*datasize.MB)
+ c.PutCodeSizeByEthHash(nil, 100)
+ c.PutCodeSizeByEthHash(makeEthHash(1), -1)
+ _, ok := c.GetCodeSizeByEthHash(makeEthHash(1))
+ assert.False(t, ok)
+}
+
+// =============================================================================
+// Microbenchmarks — measure the per-op cost of the L2b path.
+// =============================================================================
+
+func BenchmarkCodeCache_GetByEthHash_Hit(b *testing.B) {
+ c := NewCodeCache(64*datasize.MB, 16*datasize.MB)
+ code := bytes.Repeat([]byte{0x5b}, 2048) // 2 KiB typical contract size
+ ethHash := makeEthHash(0x11)
+ c.PutWithEthHash(makeAddr(1), code, ethHash)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ v, ok := c.GetByEthHash(ethHash)
+ if !ok || len(v) == 0 {
+ b.Fatal("expected hit")
+ }
+ }
+}
+
+func BenchmarkCodeCache_GetByEthHash_Miss(b *testing.B) {
+ c := NewCodeCache(64*datasize.MB, 16*datasize.MB)
+ missHash := makeEthHash(0x22)
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ _, _ = c.GetByEthHash(missHash)
+ }
+}
+
+// BenchmarkCodeCache_Get_AddrLevel_Hit baseline: the existing addr-keyed
+// path. Compare against GetByEthHash to verify the L2b lookup is at least
+// as fast (one map probe vs two: addr→hash then hash→code).
+func BenchmarkCodeCache_Get_AddrLevel_Hit(b *testing.B) {
+ c := NewCodeCache(64*datasize.MB, 16*datasize.MB)
+ code := bytes.Repeat([]byte{0x5b}, 2048)
+ addr := makeAddr(1)
+ c.PutWithEthHash(addr, code, makeEthHash(0x33))
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ v, ok := c.Get(addr)
+ if !ok || len(v) == 0 {
+ b.Fatal("expected hit")
+ }
+ }
+}
+
+// BenchmarkCodeCache_GetByEthHash_ManyAddrs_OneCode measures the workload
+// shape this layer is designed for: many addresses sharing one codeHash.
+// Without L2b every fresh addr would pay a file read. With L2b every
+// caller that already knows the hash hits one shared entry.
+func BenchmarkCodeCache_GetByEthHash_ManyAddrs_OneCode(b *testing.B) {
+ c := NewCodeCache(64*datasize.MB, 16*datasize.MB)
+ code := bytes.Repeat([]byte{0x5b}, 2048)
+ ethHash := makeEthHash(0x44)
+ c.PutWithEthHash(makeAddr(1), code, ethHash) // populate once
+
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ // Caller knows the hash from a prior account read; probes L2b.
+ v, ok := c.GetByEthHash(ethHash)
+ if !ok || len(v) == 0 {
+ b.Fatal("expected hit")
+ }
+ }
+}
diff --git a/execution/cache/generic_cache.go b/execution/cache/generic_cache.go
index 74beff86a48..3b6a91b7f59 100644
--- a/execution/cache/generic_cache.go
+++ b/execution/cache/generic_cache.go
@@ -17,35 +17,111 @@
package cache
import (
- "sync"
+ "bytes"
+ "math"
"sync/atomic"
"github.com/c2h5oh/datasize"
+ "github.com/elastic/go-freelru"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/common/maphash"
)
-// GenericCache is a bounded concurrent cache for key-value data.
+// avgBytesPerEntry is the assumption used to translate a byte budget into
+// the entry-count cap that freelru.ShardedLRU is sized against. 256 B
+// approximates account-record + key overhead and storage-slot value+key
+// overhead in the same order of magnitude. Actual residency tracked in
+// currentSize and reported via PrintStatsAndReset.
+const avgBytesPerEntry = 256
+
+// entry stores the full key alongside the value so callers can detect
+// hash collisions (the freelru shard key is the uint64 maphash of the
+// byte-string key — Go's randomized stdlib hasher, so collisions are
+// rare but not impossible). size carries the byte cost of the entry so
+// the OnEvict callback can update currentSize without re-running
+// sizeFunc.
+type entry[T any] struct {
+ key []byte
+ val T
+ size int
+ txNum uint64 // commit/read txNum the cached value reflects (upper bound)
+ epoch uint32 // unwind generation the entry was written in
+}
+
+// GenericCache is a sharded, LRU-evicting bounded cache for key-value
+// data. Eviction mode is fixed at construction (see policy.go).
type GenericCache[T any] struct {
- data *maphash.Map[T]
+ data *freelru.ShardedLRU[uint64, entry[T]]
capacityB datasize.ByteSize
currentSize atomic.Int64
- blockHash common.Hash
- mu sync.RWMutex
- hits atomic.Uint64
- misses atomic.Uint64
- sizeFunc func(T) int // calculates size of value in bytes
+ mode Mode
+
+ // Cache coherence across unwinds is txNum/epoch based — no block awareness,
+ // no diffset. An entry is valid iff it was written in the current epoch OR
+ // its txNum is strictly below unwindFloor (the first unwound txNum), so it
+ // predates every unwind seen and can't be stale. Unwind bumps the epoch and
+ // lowers the floor (O(1), no scan); stale entries are dropped lazily on their
+ // next read. txNum slots
+ // are reused across forks, so the epoch — not the txNum — is what tells a
+ // dead fork's write apart from the live fork's at the same txNum.
+ epoch atomic.Uint32
+ unwindFloor atomic.Uint64
+
+ hits atomic.Uint64
+ misses atomic.Uint64
+ inserts atomic.Uint64
+ evictions atomic.Uint64
+ dropped atomic.Uint64
+ staleEvicted atomic.Uint64 // entries dropped lazily on read after an unwind
+
+ sizeFunc func(T) int
+}
+
+func u64identity(k uint64) uint32 { return uint32(k) }
+
+// NewGenericCache creates a new GenericCache with the specified byte
+// capacity. mode selects ModeEvictLRU (default in this tree) or ModeNoOp
+// (kept for diagnostic baselines).
+func NewGenericCache[T any](capacityBytes datasize.ByteSize, sizeFunc func(T) int, mode Mode) *GenericCache[T] {
+ capacityEntries := uint32(uint64(capacityBytes) / avgBytesPerEntry)
+ if capacityEntries < 1024 {
+ capacityEntries = 1024
+ }
+ if capacityEntries > 1<<22 {
+ capacityEntries = 1 << 22
+ }
+ return newGenericCacheEntries(capacityBytes, capacityEntries, sizeFunc, mode)
}
-// NewGenericCache creates a new GenericCache with the specified byte capacity.
-func NewGenericCache[T any](capacityBytes datasize.ByteSize, sizeFunc func(T) int) *GenericCache[T] {
- return &GenericCache[T]{
- data: maphash.NewMap[T](),
+// newGenericCacheEntries builds a cache against an explicit entry-count
+// cap. Used by tests that want to exercise eviction with small capacities;
+// production constructs via NewGenericCache.
+func newGenericCacheEntries[T any](capacityBytes datasize.ByteSize, capacityEntries uint32, sizeFunc func(T) int, mode Mode) *GenericCache[T] {
+ if capacityEntries == 0 {
+ capacityEntries = 1
+ }
+ lru, err := freelru.NewSharded[uint64, entry[T]](capacityEntries, u64identity)
+ if err != nil {
+ panic(err)
+ }
+ c := &GenericCache[T]{
+ data: lru,
capacityB: capacityBytes,
+ mode: mode,
sizeFunc: sizeFunc,
}
+ // Before any unwind every entry predates the (nonexistent) floor, so all
+ // reads are valid; the floor only drops once an unwind happens.
+ c.unwindFloor.Store(math.MaxUint64)
+ // OnEvict fires for capacity-driven LRU eviction (ModeEvictLRU) and
+ // for explicit Remove(). In both cases we want currentSize to follow.
+ lru.SetOnEvict(func(_ uint64, e entry[T]) {
+ c.currentSize.Add(-int64(e.size))
+ c.evictions.Add(1)
+ })
+ return c
}
// DomainCache wraps GenericCache[[]byte] to implement the Cache interface.
@@ -53,10 +129,16 @@ type DomainCache struct {
*GenericCache[[]byte]
}
-// NewDomainCache creates a new cache for domain data.
+// NewDomainCache creates a new cache for domain data. Defaults to
+// ModeEvictLRU; use NewDomainCacheMode to override.
func NewDomainCache(capacityBytes datasize.ByteSize) *DomainCache {
+ return NewDomainCacheMode(capacityBytes, ModeEvictLRU)
+}
+
+// NewDomainCacheMode creates a new domain cache with the given mode.
+func NewDomainCacheMode(capacityBytes datasize.ByteSize, mode Mode) *DomainCache {
return &DomainCache{
- GenericCache: NewGenericCache(capacityBytes, func(v []byte) int { return len(v) }),
+ GenericCache: NewGenericCache(capacityBytes, func(v []byte) int { return len(v) }, mode),
}
}
@@ -70,8 +152,8 @@ func (c *DomainCache) Get(key []byte) ([]byte, bool) {
}
// Put stores data for the given key, implementing the Cache interface.
-func (c *DomainCache) Put(key []byte, value []byte) {
- c.GenericCache.Put(key, value)
+func (c *DomainCache) Put(key []byte, value []byte, txNum uint64) {
+ c.GenericCache.Put(key, value, txNum)
}
// Delete removes the data for the given key, delegating to GenericCache.
@@ -81,102 +163,107 @@ func (c *DomainCache) Delete(key []byte) {
// Get retrieves data for the given key.
func (c *GenericCache[T]) Get(key []byte) (T, bool) {
- value, ok := c.data.Get(key)
- if !ok {
+ h := maphash.Hash(key)
+ e, ok := c.data.Get(h)
+ if !ok || !bytes.Equal(e.key, key) {
+ c.misses.Add(1)
+ var zero T
+ return zero, false
+ }
+ // Lazy unwind invalidation: an entry from a superseded epoch whose txNum is
+ // at or above the unwind floor reflects dead-fork state — drop it and miss so
+ // the read falls through to the reverted domain and repopulates. The floor is
+ // the first unwound txNum (Min(UnwindPoint+1), the first txNum of the first
+ // rolled-back block), so an entry stamped exactly at the floor belongs to a
+ // dead block — e.g. an EIP-4788 beacon-root write in the block-begin system
+ // tx — and must be dropped; >= not > (the surviving block's last txNum is
+ // floor-1, so this never drops a live entry).
+ if e.epoch != c.epoch.Load() && e.txNum >= c.unwindFloor.Load() {
+ c.data.Remove(h)
+ c.staleEvicted.Add(1)
c.misses.Add(1)
var zero T
return zero, false
}
c.hits.Add(1)
- return value, true
+ return e.val, true
}
-// Put stores data for the given key.
-func (c *GenericCache[T]) Put(key []byte, value T) {
- entrySize := int64(8 + c.sizeFunc(value))
+// Put stores data for the given key. In ModeEvictLRU the underlying
+// sharded LRU evicts cold entries when its entry-count cap is reached.
+// In ModeNoOp inserts that would overflow the byte budget are dropped
+// (and counted via the dropped metric).
+func (c *GenericCache[T]) Put(key []byte, value T, txNum uint64) {
+ h := maphash.Hash(key)
+ valBytes := c.sizeFunc(value)
+ newSize := len(key) + valBytes + 24
+ ep := c.epoch.Load()
- // Check if key already exists
- if existing, ok := c.data.Get(key); ok {
- oldSize := int64(8 + c.sizeFunc(existing))
- sizeDiff := entrySize - oldSize
- c.data.Set(key, value)
- c.currentSize.Add(sizeDiff)
+ // Existing key — update in place. Reuse the stored key buffer to
+ // avoid an extra allocation; the freshly-decoded value replaces the
+ // old one.
+ if existing, ok := c.data.Get(h); ok && bytes.Equal(existing.key, key) {
+ oldSize := existing.size
+ c.data.Add(h, entry[T]{key: existing.key, val: value, size: newSize, txNum: txNum, epoch: ep})
+ c.currentSize.Add(int64(newSize - oldSize))
return
}
- // New key
- if c.currentSize.Load()+entrySize > int64(c.capacityB) {
- return
+ if c.mode == ModeNoOp {
+ if c.currentSize.Load()+int64(newSize) > int64(c.capacityB) {
+ c.dropped.Add(1)
+ return
+ }
}
+ // In ModeEvictLRU the per-shard LRU evicts the oldest entry inside
+ // freelru.Add when its slot cap is reached; OnEvict drops the size
+ // from currentSize. Eviction is per-shard, not globally-LRU — same
+ // trade-off code_cache.go / balcache.go / db/state/cache.go accept.
- c.data.Set(key, value)
- c.currentSize.Add(entrySize)
+ keyCopy := common.Copy(key)
+ c.data.Add(h, entry[T]{key: keyCopy, val: value, size: newSize, txNum: txNum, epoch: ep})
+ c.currentSize.Add(int64(newSize))
+ c.inserts.Add(1)
}
// Delete removes the data for the given key.
func (c *GenericCache[T]) Delete(key []byte) {
- if existing, ok := c.data.Get(key); ok {
- entrySize := int64(8 + c.sizeFunc(existing))
- c.data.Delete(key)
- c.currentSize.Add(-entrySize)
+ h := maphash.Hash(key)
+ if existing, ok := c.data.Get(h); ok && bytes.Equal(existing.key, key) {
+ c.data.Remove(h)
}
}
// Clear removes all entries from the cache.
func (c *GenericCache[T]) Clear() {
- c.data.Clear()
+ c.data.Purge()
c.currentSize.Store(0)
}
-// GetBlockHash returns the hash of the last block processed by the cache.
-func (c *GenericCache[T]) GetBlockHash() common.Hash {
- c.mu.RLock()
- defer c.mu.RUnlock()
- return c.blockHash
-}
-
-// SetBlockHash sets the hash of the current block being processed.
-func (c *GenericCache[T]) SetBlockHash(hash common.Hash) {
- c.mu.Lock()
- c.blockHash = hash
- c.mu.Unlock()
-}
-
-// ValidateAndPrepare checks if the given parentHash matches the cache's current blockHash.
-func (c *GenericCache[T]) ValidateAndPrepare(parentHash common.Hash, incomingBlockHash common.Hash) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- if c.blockHash == (common.Hash{}) {
- c.data.Clear()
- c.currentSize.Store(0)
- c.blockHash = incomingBlockHash
- return true
+// Unwind invalidates entries that reflect dead-fork state. unwindToTxNum is the
+// first rolled-back txNum (Min(UnwindPoint+1)); every entry at or above it is on
+// the dead fork. O(1): bump the epoch (so entries written in the new, live epoch
+// stay valid) and lower the floor to unwindToTxNum (so entries strictly below it
+// — which predate the unwind and can't be stale — stay warm). Stale entries (old
+// epoch, txNum >= floor) are dropped lazily on their next read. The floor only
+// ever moves down, to the deepest unwind seen, so a later shallower unwind can't
+// resurrect entries an earlier deeper one invalidated.
+func (c *GenericCache[T]) Unwind(unwindToTxNum uint64) {
+ c.epoch.Add(1)
+ for {
+ cur := c.unwindFloor.Load()
+ if unwindToTxNum >= cur {
+ break
+ }
+ if c.unwindFloor.CompareAndSwap(cur, unwindToTxNum) {
+ break
+ }
}
-
- if c.blockHash == parentHash {
- c.blockHash = incomingBlockHash
- return true
- }
-
- c.data.Clear()
- c.currentSize.Store(0)
- c.blockHash = incomingBlockHash
- return false
-}
-
-// ClearWithHash clears the cache and sets the block hash.
-func (c *GenericCache[T]) ClearWithHash(hash common.Hash) {
- c.mu.Lock()
- defer c.mu.Unlock()
- c.data.Clear()
- c.currentSize.Store(0)
- c.blockHash = hash
}
// Len returns the number of entries in the cache.
func (c *GenericCache[T]) Len() int {
- return c.data.Len()
+ return int(c.data.Len())
}
// SizeBytes returns the current size of the cache in bytes.
@@ -189,10 +276,19 @@ func (c *GenericCache[T]) CapacityBytes() datasize.ByteSize {
return c.capacityB
}
+// Mode returns the eviction mode this cache was constructed with.
+func (c *GenericCache[T]) Mode() Mode {
+ return c.mode
+}
+
// PrintStatsAndReset prints cache statistics and resets counters.
func (c *GenericCache[T]) PrintStatsAndReset(name string) {
hits := c.hits.Swap(0)
misses := c.misses.Swap(0)
+ inserts := c.inserts.Swap(0)
+ evictions := c.evictions.Swap(0)
+ dropped := c.dropped.Swap(0)
+ staleEvicted := c.staleEvicted.Swap(0)
total := hits + misses
var hitRate float64
if total > 0 {
@@ -200,8 +296,11 @@ func (c *GenericCache[T]) PrintStatsAndReset(name string) {
}
sizeBytes := c.currentSize.Load()
usagePct := float64(sizeBytes) / float64(c.capacityB) * 100
- log.Debug(name+" cache stats",
+ log.Info(name+" cache stats",
+ "mode", c.mode.String(),
"hits", hits, "misses", misses, "hit_rate", hitRate,
+ "inserts", inserts, "evictions", evictions, "dropped", dropped,
+ "stale_evicted", staleEvicted, "epoch", c.epoch.Load(),
"entries", c.data.Len(), "size_mb", sizeBytes/(1024*1024),
"capacity_mb", int64(c.capacityB/datasize.MB), "usage_pct", usagePct,
)
diff --git a/execution/cache/policy.go b/execution/cache/policy.go
new file mode 100644
index 00000000000..6297df9108c
--- /dev/null
+++ b/execution/cache/policy.go
@@ -0,0 +1,71 @@
+// 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 .
+
+package cache
+
+// Mode selects what GenericCache does when an insert would overflow the
+// capacity. The seam is the same for both modes — the in-package eviction
+// pop is the only behaviour difference.
+//
+// ModeEvictLRU is the default and matches the policy other state caches in
+// the tree use (db/state/cache.go's DomainGetFromFileCache /
+// IISeekInFilesCache, execution/cache/code_cache.go's addr LRUs,
+// execution/balcache).
+//
+// ModeNoOp preserves the historical "first writers win forever" behaviour:
+// once full, every new key is silently dropped (counted via the dropped
+// metric). Kept as a deliberate diagnostic baseline so the regression
+// bench can compare against the pre-policy behaviour without flipping
+// branches.
+//
+// Pure LRU is scan-fragile in principle: a flood of one-shot keys (mainnet's
+// long tail of single-touch slots) can evict the genuinely-hot working set
+// because every cold scan is "more recent" than the hot entries. Two known
+// follow-up policies sit behind this seam:
+//
+// - ModeEvictFixedCache (reth's choice). Reth's execution cache is
+// `fixed-cache`: a lock-free direct-mapped / set-associative array
+// with collision-evict semantics — no LRU list, no LFU sketch. Their
+// PR #21128 (v1.11.0) quoted ~25% newPayload p50 / +33% gas/s vs the
+// prior moka / quick-cache attempts; the win came from *removing*
+// LRU/LFU bookkeeping, not adding LFU.
+// - ModeEvictLFU. W-TinyLFU (Caffeine-style) admission policy keeps a
+// small frequency sketch so single-touch entries can't displace
+// frequently-touched ones. Helps mainnet steady-state in principle
+// (+5-15pp on Zipfian-with-scan per Caffeine literature). Does NOT
+// help the cycle-2 bloat fixtures — those are pure cold scans with
+// no reuse, so admission policy has nothing to admit.
+//
+// See agentspecs/lfu-vs-lru-state-cache-decision-2026-05-15.md for the
+// full analysis (which lib to use if we ship LFU — otter, not ristretto)
+// and the decision criterion (24h mainnet replay hit-rate < 90% before
+// LFU is worth a new dep).
+type Mode uint8
+
+const (
+ ModeEvictLRU Mode = iota
+ ModeNoOp
+)
+
+func (m Mode) String() string {
+ switch m {
+ case ModeEvictLRU:
+ return "evict"
+ case ModeNoOp:
+ return "noop"
+ }
+ return "unknown"
+}
diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go
index 816cdae11d3..da5a6a53fe7 100644
--- a/execution/cache/state_cache.go
+++ b/execution/cache/state_cache.go
@@ -18,21 +18,35 @@ package cache
import (
"bytes"
+ "strings"
"github.com/c2h5oh/datasize"
"github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/dbg"
+ "github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/execution/commitment/commitmentdb"
)
const (
- // DefaultAccountCacheBytes is the byte limit for account cache (1 GB)
- DefaultAccountCacheBytes = 1 * datasize.GB
- // DefaultStorageCacheBytes is the byte limit for storage cache (1 GB)
- DefaultStorageCacheBytes = 1 * datasize.GB
+ // DefaultAccountCacheBytes is the byte limit for account cache (100 MB — investigation knob; permanent default returns to 1 GB)
+ DefaultAccountCacheBytes = 100 * datasize.MB
+ // DefaultStorageCacheBytes is the byte limit for storage cache. 150 MB: the
+ // measured mainnet-tip storage working set is ~106 MB for 95% of reads
+ // (top 1% of keys = 48%, ~10 MB for 80%); 150 MB leaves headroom over the
+ // 95% set so eviction pressure doesn't push the hot set out.
+ DefaultStorageCacheBytes = 150 * datasize.MB
// DefaultCommitmentCacheBytes is the byte limit for commitment cache (128 MB)
DefaultCommitmentCacheBytes = 128 * datasize.MB
+
+ // Per-domain avg entry size used to translate the byte budget into the
+ // entry-count cap the underlying sharded LRU is sized against. Account
+ // and storage are near-fixed: addr + record or addr+slot + value plus
+ // entry overhead. Commitment values are smaller branch nodes.
+ avgAccountEntryBytes = 96 // 20 addr + ~50 account record + 24 overhead
+ avgStorageEntryBytes = 88 // 52 addr+slot + ~12 value + 24 overhead
+ avgCommitmentEntryBytes = 80
)
// StateCache is a unified cache for domain data (Account, Storage, Code).
@@ -46,15 +60,48 @@ type StateCache struct {
}
// NewStateCache creates a new StateCache with the specified byte capacities.
+// Mode for the byte-budget DomainCaches (Account/Storage/Commitment) is
+// read once from STATE_CACHE_MODE (evict|noop, default evict). CodeCache
+// has its own LRU and is not gated by this knob.
func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes, commitmentBytes datasize.ByteSize) *StateCache {
+ mode := stateCacheModeFromEnv()
sc := &StateCache{}
- sc.caches[kv.AccountsDomain] = NewDomainCache(accountBytes)
- sc.caches[kv.StorageDomain] = NewDomainCache(storageBytes)
+ sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode)
+ sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode)
sc.caches[kv.CodeDomain] = NewCodeCache(codeBytes, addrBytes)
- //sc.caches[kv.CommitmentDomain] = NewDomainCache(commitmentBytes)
+ //sc.caches[kv.CommitmentDomain] = newDomainCacheBytes(commitmentBytes, avgCommitmentEntryBytes, mode)
return sc
}
+// stateCacheModeFromEnv reads STATE_CACHE_MODE once. Unset or unrecognised
+// returns ModeEvictLRU. Recognised values: "evict", "noop". Logged at
+// startup so operators can see the mode pick.
+func stateCacheModeFromEnv() Mode {
+ v := strings.ToLower(strings.TrimSpace(dbg.EnvString("STATE_CACHE_MODE", "")))
+ switch v {
+ case "", "evict":
+ return ModeEvictLRU
+ case "noop":
+ log.Info("[cache] STATE_CACHE_MODE=noop — Account/Storage caches will drop new keys when full (diagnostic baseline; not for production)")
+ return ModeNoOp
+ default:
+ log.Warn("[cache] unrecognised STATE_CACHE_MODE; defaulting to evict", "value", v)
+ return ModeEvictLRU
+ }
+}
+
+// newDomainCacheBytes constructs a DomainCache where the entry-count cap
+// is derived from the byte budget using the supplied per-domain avg.
+func newDomainCacheBytes(capacityBytes datasize.ByteSize, avgBytes uint32, mode Mode) *DomainCache {
+ capacityEntries := uint32(uint64(capacityBytes) / uint64(avgBytes))
+ if capacityEntries == 0 {
+ capacityEntries = 1
+ }
+ return &DomainCache{
+ GenericCache: newGenericCacheEntries(capacityBytes, capacityEntries, func(v []byte) int { return len(v) }, mode),
+ }
+}
+
// NewDefaultStateCache creates a new StateCache with default byte capacities.
// Account: 256MB, Storage: 512MB, Code: 512MB, Addr: 16MB
func NewDefaultStateCache() *StateCache {
@@ -78,8 +125,91 @@ func (c *StateCache) Get(domain kv.Domain, key []byte) ([]byte, bool) {
return cache.Get(key)
}
-// Put stores data for the given domain and key.
-func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte) {
+// GetCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256),
+// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss or
+// when the code domain cache is not a CodeCache (defensive fallback).
+//
+// Use when the caller has the codeHash in hand (post-account-load) — typical
+// for EXTCODESIZE / EXTCODEHASH / CALL targets. Lets many-addrs-one-code
+// patterns (proxies, factory clones, ERC-20 holders) share a single L2b
+// entry.
+func (c *StateCache) GetCodeByHash(ethHash []byte) ([]byte, bool) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return nil, false
+ }
+ return cc.GetByEthHash(ethHash)
+}
+
+// PutCodeWithHash stores code populating both the addr-keyed path and the
+// ethHash-keyed L2b layer. Callers should prefer this over Put when they
+// have the codeHash from the account record — avoids a redundant keccak.
+func (c *StateCache) PutCodeWithHash(addr, code, ethHash []byte) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return
+ }
+ cc.PutWithEthHash(addr, common.Copy(code), ethHash)
+}
+
+// GetCodeSizeByHash returns the size of code by its Ethereum codeHash
+// without loading the bytes. Returns (0, false) when the size-only layer
+// is not populated for this hash.
+func (c *StateCache) GetCodeSizeByHash(ethHash []byte) (int, bool) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return 0, false
+ }
+ return cc.GetCodeSizeByEthHash(ethHash)
+}
+
+// PutCodeSizeByHash records the code size for a given ethHash. Useful when
+// the caller has the size in hand (e.g. from an account-domain probe that
+// resolved a sibling addr to the same code) but doesn't have the bytes.
+func (c *StateCache) PutCodeSizeByHash(ethHash []byte, size int) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return
+ }
+ cc.PutCodeSizeByEthHash(ethHash, size)
+}
+
+// GetAddrCodeHash returns the Ethereum codeHash for addr without an
+// account-domain round-trip. (0xff..ff/false, no entry on miss; (h, true)
+// on hit.)
+func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return [32]byte{}, false
+ }
+ return cc.GetAddrCodeHash(addr)
+}
+
+// PutAddrCodeHash records the addr → codeHash mapping in the addr-keyed
+// LRU above SD. Callers that have just decoded an account record should
+// call this so subsequent lookups skip the account-domain read.
+func (c *StateCache) PutAddrCodeHash(addr []byte, h [32]byte) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return
+ }
+ cc.PutAddrCodeHash(addr, h)
+}
+
+// DeleteAddrCodeHash drops the addr → codeHash mapping. Used by
+// invalidation paths (SELFDESTRUCT / CREATE2-replace / unwind diffsets)
+// where the account's codeHash has been mutated.
+func (c *StateCache) DeleteAddrCodeHash(addr []byte) {
+ cc, ok := c.caches[kv.CodeDomain].(*CodeCache)
+ if !ok {
+ return
+ }
+ cc.DeleteAddrCodeHash(addr)
+}
+
+// Put stores data for the given domain and key, stamped with the txNum the
+// value reflects (for txNum/epoch unwind invalidation).
+func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint64) {
cache := c.caches[domain]
if cache == nil {
return
@@ -87,7 +217,7 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte) {
if domain == kv.CommitmentDomain && bytes.Equal(key, commitmentdb.KeyCommitmentState) {
return
}
- cache.Put(key, common.Copy(value))
+ cache.Put(key, common.Copy(value), txNum)
}
// Delete removes the data for the given domain and key.
@@ -108,25 +238,15 @@ func (c *StateCache) Clear() {
}
}
-// ValidateAndPrepare validates and prepares all caches for a new block.
-// Returns true if all caches were valid, false if any were cleared.
-func (c *StateCache) ValidateAndPrepare(parentHash common.Hash, incomingBlockHash common.Hash) bool {
- allValid := true
+// Unwind invalidates, across all caches, entries reflecting state above
+// unwindToTxNum on a now-dead fork. Diffset-free and O(1) (GenericCache bumps
+// an epoch + lowers a floor; CodeCache clears its mutable addr layers). This is
+// the sole cache-invalidation path on unwind — the executor never touches the
+// cache during forward execution.
+func (c *StateCache) Unwind(unwindToTxNum uint64) {
for _, cache := range c.caches {
if cache != nil {
- if !cache.ValidateAndPrepare(parentHash, incomingBlockHash) {
- allValid = false
- }
- }
- }
- return allValid
-}
-
-// ClearWithHash clears all caches and sets their block hash.
-func (c *StateCache) ClearWithHash(hash common.Hash) {
- for _, cache := range c.caches {
- if cache != nil {
- cache.ClearWithHash(hash)
+ cache.Unwind(unwindToTxNum)
}
}
}
@@ -158,40 +278,3 @@ func (c *StateCache) PrintStatsAndReset() {
code.PrintStatsAndReset()
}
}
-
-func (c *StateCache) RevertWithDiffset(diffset *[6][]kv.DomainEntryDiff, revertFromHash, newBlockHash common.Hash) {
- // If the cache's block hash doesn't match the block we're unwinding from,
- // the cache was modified by a rolled-back tx (e.g. ValidatePayload).
- // Clear everything and set the new hash — surgical eviction can't fix stale data
- // from a different execution path.
- for _, cache := range c.caches {
- if cache != nil && cache.GetBlockHash() != revertFromHash {
- c.ClearWithHash(newBlockHash)
- return
- }
- }
-
- for _, entry := range diffset[kv.AccountsDomain] {
- k := []byte(entry.Key[:len(entry.Key)-8])
- c.Delete(kv.CodeDomain, k)
- c.Delete(kv.AccountsDomain, k)
- }
- for _, entry := range diffset[kv.CodeDomain] {
- k := []byte(entry.Key[:len(entry.Key)-8])
- c.Delete(kv.CodeDomain, k)
- }
- for _, entry := range diffset[kv.StorageDomain] {
- k := []byte(entry.Key[:len(entry.Key)-8])
- c.Delete(kv.StorageDomain, k)
- }
- for _, entry := range diffset[kv.CommitmentDomain] {
- k := []byte(entry.Key[:len(entry.Key)-8])
- c.Delete(kv.CommitmentDomain, k)
- }
- // Update block hash after successful surgical eviction
- for _, cache := range c.caches {
- if cache != nil {
- cache.SetBlockHash(newBlockHash)
- }
- }
-}
diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go
new file mode 100644
index 00000000000..baff8961978
--- /dev/null
+++ b/execution/commitment/adaptive_pin.go
@@ -0,0 +1,397 @@
+// 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.
+
+package commitment
+
+import (
+ "context"
+ "encoding/hex"
+ "sync"
+ "sync/atomic"
+
+ "github.com/erigontech/erigon/common/log/v3"
+)
+
+// AdaptivePinControllerConfig sets the policy knobs for the adaptive
+// trunk-pin controller. Defaults target the SSTORE-bloat workload class
+// (single contract dominating storage reads).
+type AdaptivePinControllerConfig struct {
+ PromoteThresholdMisses uint64
+ MaxPromotedContracts int
+ DemoteCooldownBlocks int
+ InitialViewBudgetBytes int
+ ExtensionBudgetBytes int
+ PerContractMaxBudgetBytes int
+}
+
+func DefaultAdaptivePinControllerConfig() AdaptivePinControllerConfig {
+ return AdaptivePinControllerConfig{
+ PromoteThresholdMisses: 100,
+ MaxPromotedContracts: 8,
+ DemoteCooldownBlocks: 5,
+ InitialViewBudgetBytes: 4 << 20,
+ ExtensionBudgetBytes: 8 << 20,
+ PerContractMaxBudgetBytes: 64 << 20,
+ }
+}
+
+// AdaptivePinController watches per-contract miss pressure on a
+// BranchCache and decides which contracts to pin (with a sync initial
+// view), grow (per-block extension), or demote (invalidate the pin
+// set after sustained inactivity).
+type AdaptivePinController struct {
+ cache *BranchCache
+ cfg AdaptivePinControllerConfig
+ logger log.Logger
+
+ misses sync.Map // [32]byte → *atomic.Uint64
+
+ mu sync.Mutex
+ states map[[32]byte]*adaptiveContractState
+
+ parallelResolverFactory ParallelResolverFactory
+ dbBranchesProvider DbBranchesProvider
+}
+
+// ParallelResolverFactory builds a fresh BatchBranchResolver for one
+// OnBlockComplete call. release() is invoked after the controller is done
+// with the resolver. Returning (nil, nil, err) makes the controller fall
+// back to the serial-BFS path for this block.
+type ParallelResolverFactory func() (resolve BatchBranchResolver, release func(), err error)
+
+// DbBranchesProvider returns the MDBX-resident branch overlay for one
+// contract — values shadow file values in the parallel preload's wave.
+// Empty/nil result is valid (no overlay; resolver is authoritative).
+type DbBranchesProvider func(contractHash []byte) map[string][]byte
+
+type adaptiveContractState struct {
+ contractHash [32]byte
+ promotedAtBlock uint64
+ preload *ContractTrunkPreload // serial-BFS path (nil when parallel)
+ parallel *ContractTrunkPreloadParallel // parallel-wave-BFS path (nil when serial)
+ coldBlocksInARow int
+}
+
+func (s *adaptiveContractState) pinnedTotal() int {
+ if s.parallel != nil {
+ return s.parallel.PinnedTotal()
+ }
+ return s.preload.PinnedTotal()
+}
+
+func (s *adaptiveContractState) usedBytes() int {
+ if s.parallel != nil {
+ return s.parallel.UsedBytes()
+ }
+ return s.preload.UsedBytes()
+}
+
+func (s *adaptiveContractState) queueRemaining() int {
+ if s.parallel != nil {
+ return s.parallel.QueueRemaining()
+ }
+ return s.preload.QueueRemaining()
+}
+
+func (s *adaptiveContractState) pinnedPrefixes() [][]byte {
+ if s.parallel != nil {
+ return s.parallel.PinnedPrefixes()
+ }
+ return s.preload.PinnedPrefixes()
+}
+
+func NewAdaptivePinController(cache *BranchCache, cfg AdaptivePinControllerConfig, logger log.Logger) *AdaptivePinController {
+ if cfg.InitialViewBudgetBytes <= 0 {
+ cfg.InitialViewBudgetBytes = 4 << 20
+ }
+ if cfg.ExtensionBudgetBytes <= 0 {
+ cfg.ExtensionBudgetBytes = 8 << 20
+ }
+ if cfg.PerContractMaxBudgetBytes <= 0 {
+ cfg.PerContractMaxBudgetBytes = 64 << 20
+ }
+ if cfg.MaxPromotedContracts <= 0 {
+ cfg.MaxPromotedContracts = 8
+ }
+ if cfg.DemoteCooldownBlocks <= 0 {
+ cfg.DemoteCooldownBlocks = 5
+ }
+ if cfg.PromoteThresholdMisses == 0 {
+ cfg.PromoteThresholdMisses = 100
+ }
+ return &AdaptivePinController{
+ cache: cache,
+ cfg: cfg,
+ logger: logger,
+ states: make(map[[32]byte]*adaptiveContractState),
+ }
+}
+
+// Bind installs the controller's miss-callback on the cache.
+// Safe to call multiple times — replaces any prior callback.
+func (c *AdaptivePinController) Bind() {
+ c.cache.SetMissCallback(c.onCacheMiss)
+}
+
+// SetParallelMode switches promote/extend to the wave-BFS parallel preload.
+// Either argument may be nil to clear; with factory==nil the controller uses
+// the serial-BFS CommitmentReader path. Already-promoted contracts keep
+// their existing serial/parallel state until next demote.
+func (c *AdaptivePinController) SetParallelMode(factory ParallelResolverFactory, provider DbBranchesProvider) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.parallelResolverFactory = factory
+ c.dbBranchesProvider = provider
+}
+
+func (c *AdaptivePinController) onCacheMiss(prefix []byte) {
+ hash, ok := ContractHashFromPrefix(prefix)
+ if !ok {
+ return
+ }
+ v, _ := c.misses.LoadOrStore(hash, new(atomic.Uint64))
+ v.(*atomic.Uint64).Add(1)
+}
+
+// OnBlockComplete consumes the per-block miss snapshot and decides
+// promotions, extensions, and demotions. Synchronous — preloads run
+// inline so the new pin set is available for the next block's reads.
+func (c *AdaptivePinController) OnBlockComplete(ctx context.Context, blockNum uint64, reader CommitmentReader) {
+ misses := c.snapshotMisses()
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ // One factory call per block, shared across all contracts. nil falls back to serial.
+ var parallelResolve BatchBranchResolver
+ var releaseParallel func()
+ if c.parallelResolverFactory != nil {
+ r, release, err := c.parallelResolverFactory()
+ if err != nil {
+ c.warnf("[adaptive-pin] parallel resolver factory failed, falling back to serial", "err", err, "block", blockNum)
+ } else {
+ parallelResolve = r
+ releaseParallel = release
+ }
+ }
+ if releaseParallel != nil {
+ defer releaseParallel()
+ }
+
+ var promoted, extended, demoted int
+
+ for hash, state := range c.states {
+ n, hadMisses := misses[hash]
+ if hadMisses && n > 0 {
+ state.coldBlocksInARow = 0
+ delete(misses, hash)
+ if state.queueRemaining() > 0 && state.usedBytes() < c.cfg.PerContractMaxBudgetBytes {
+ remaining := c.cfg.PerContractMaxBudgetBytes - state.usedBytes()
+ step := c.cfg.ExtensionBudgetBytes
+ if step > remaining {
+ step = remaining
+ }
+ if err := c.runExtensionLocked(ctx, state, step, parallelResolve, reader); err != nil {
+ c.warnf("[adaptive-pin] extend failed", "hash", hex.EncodeToString(hash[:]), "err", err)
+ } else {
+ extended++
+ }
+ }
+ continue
+ }
+ state.coldBlocksInARow++
+ if state.coldBlocksInARow >= c.cfg.DemoteCooldownBlocks {
+ c.demoteLocked(hash, state)
+ delete(c.states, hash)
+ demoted++
+ }
+ }
+
+ if len(misses) > 0 && len(c.states) < c.cfg.MaxPromotedContracts {
+ candidates := pickPromotionCandidates(misses, c.cfg.PromoteThresholdMisses, c.cfg.MaxPromotedContracts-len(c.states))
+ for _, hash := range candidates {
+ state, err := c.promoteLocked(ctx, hash, blockNum, parallelResolve, reader)
+ if err != nil {
+ c.warnf("[adaptive-pin] initial-view failed", "hash", hex.EncodeToString(hash[:]), "err", err)
+ continue
+ }
+ c.states[hash] = state
+ promoted++
+ }
+ }
+
+ if promoted > 0 {
+ mxAdaptivePromoted.AddUint64(uint64(promoted))
+ }
+ if extended > 0 {
+ mxAdaptiveExtended.AddUint64(uint64(extended))
+ }
+ if demoted > 0 {
+ mxAdaptiveDemoted.AddUint64(uint64(demoted))
+ }
+ mxAdaptiveActive.SetUint64(uint64(len(c.states)))
+
+ if c.logger != nil && (promoted+extended+demoted > 0 || len(c.states) > 0) {
+ c.logger.Info("[adaptive-pin]",
+ "block", blockNum,
+ "promoted_total", len(c.states),
+ "promoted_this_block", promoted,
+ "extended_this_block", extended,
+ "demoted_this_block", demoted,
+ "cache_pinned_total", c.cache.PinnedCount())
+ }
+}
+
+func (c *AdaptivePinController) snapshotMisses() map[[32]byte]uint64 {
+ out := make(map[[32]byte]uint64)
+ c.misses.Range(func(k, v any) bool {
+ hash := k.([32]byte)
+ n := v.(*atomic.Uint64).Swap(0)
+ if n > 0 {
+ out[hash] = n
+ }
+ return true
+ })
+ return out
+}
+
+// demoteLocked: caller must hold c.mu.
+func (c *AdaptivePinController) demoteLocked(hash [32]byte, state *adaptiveContractState) {
+ for _, prefix := range state.pinnedPrefixes() {
+ c.cache.Invalidate(prefix)
+ }
+ if c.logger != nil {
+ c.logger.Info("[adaptive-pin] demoted",
+ "hash", hex.EncodeToString(hash[:]),
+ "pinned_was", state.pinnedTotal(),
+ "used_mb_was", state.usedBytes()/(1<<20),
+ "cold_blocks", state.coldBlocksInARow)
+ }
+}
+
+// promoteLocked: caller must hold c.mu. On error the partial pin set is rolled back.
+func (c *AdaptivePinController) promoteLocked(
+ ctx context.Context,
+ hash [32]byte,
+ blockNum uint64,
+ parallelResolve BatchBranchResolver,
+ reader CommitmentReader,
+) (*adaptiveContractState, error) {
+ if parallelResolve != nil {
+ p, err := NewContractTrunkPreloadParallel(hash[:])
+ if err != nil {
+ return nil, err
+ }
+ var dbBranches map[string][]byte
+ if c.dbBranchesProvider != nil {
+ dbBranches = c.dbBranchesProvider(hash[:])
+ }
+ if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, dbBranches, parallelResolve, c.cache, c.logger); err != nil {
+ for _, prefix := range p.PinnedPrefixes() {
+ c.cache.Invalidate(prefix)
+ }
+ return nil, err
+ }
+ return &adaptiveContractState{
+ contractHash: hash,
+ promotedAtBlock: blockNum,
+ parallel: p,
+ }, nil
+ }
+ p, err := NewContractTrunkPreload(hash[:])
+ if err != nil {
+ return nil, err
+ }
+ if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, reader, c.cache, c.logger); err != nil {
+ for _, prefix := range p.PinnedPrefixes() {
+ c.cache.Invalidate(prefix)
+ }
+ return nil, err
+ }
+ return &adaptiveContractState{
+ contractHash: hash,
+ promotedAtBlock: blockNum,
+ preload: p,
+ }, nil
+}
+
+// runExtensionLocked: caller must hold c.mu. Uses the saved state's mode
+// (parallel vs serial); a serial state with a parallel resolver available
+// keeps using serial — switching mid-contract would lose the queue position.
+func (c *AdaptivePinController) runExtensionLocked(
+ ctx context.Context,
+ state *adaptiveContractState,
+ stepBudget int,
+ parallelResolve BatchBranchResolver,
+ reader CommitmentReader,
+) error {
+ if state.parallel != nil {
+ if parallelResolve == nil {
+ return nil
+ }
+ var dbBranches map[string][]byte
+ if c.dbBranchesProvider != nil {
+ dbBranches = c.dbBranchesProvider(state.contractHash[:])
+ }
+ _, _, err := state.parallel.Run(stepBudget, dbBranches, parallelResolve, c.cache, c.logger)
+ return err
+ }
+ _, _, err := state.preload.Run(stepBudget, reader, c.cache, c.logger)
+ return err
+}
+
+func (c *AdaptivePinController) PromotedContracts() [][32]byte {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ out := make([][32]byte, 0, len(c.states))
+ for h := range c.states {
+ out = append(out, h)
+ }
+ return out
+}
+
+func pickPromotionCandidates(misses map[[32]byte]uint64, threshold uint64, maxN int) [][32]byte {
+ if maxN <= 0 {
+ return nil
+ }
+ type cand struct {
+ hash [32]byte
+ n uint64
+ }
+ var pool []cand
+ for h, n := range misses {
+ if n >= threshold {
+ pool = append(pool, cand{h, n})
+ }
+ }
+ if len(pool) > maxN {
+ for i := 0; i < maxN; i++ {
+ best := i
+ for j := i + 1; j < len(pool); j++ {
+ if pool[j].n > pool[best].n {
+ best = j
+ }
+ }
+ pool[i], pool[best] = pool[best], pool[i]
+ }
+ pool = pool[:maxN]
+ }
+ out := make([][32]byte, len(pool))
+ for i, c := range pool {
+ out[i] = c.hash
+ }
+ return out
+}
+
+func (c *AdaptivePinController) warnf(msg string, kv ...any) {
+ if c.logger != nil {
+ c.logger.Warn(msg, kv...)
+ }
+}
+
+var _ = context.Background // reserved for cancellation of in-flight preloads
diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go
new file mode 100644
index 00000000000..822d671e215
--- /dev/null
+++ b/execution/commitment/branch_cache.go
@@ -0,0 +1,776 @@
+// 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 .
+
+package commitment
+
+import (
+ "bytes"
+ "fmt"
+ "math"
+ "os"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/erigontech/erigon/common/maphash"
+)
+
+// KeyCommitmentState is the commitment-domain key under which the trie
+// checkpoint (txNum / blockNum / encoded root state) is stored. It is NOT a
+// trie branch: it changes every block, so it must never enter the
+// BranchCache — serving a stale checkpoint restores the trie to the wrong
+// state and corrupts the computed root. BranchCache.Put/Get/PinEntry reject
+// it by construction so no caller can pollute the cache with it.
+var KeyCommitmentState = []byte("state")
+
+func isCommitmentStateKey(prefix []byte) bool {
+ return bytes.Equal(prefix, KeyCommitmentState)
+}
+
+// BranchCache stores commitment-trie branch data:
+//
+// - Bounded LRU tail with configurable capacity (eviction is well-defined,
+// suitable for long-lived caching across many Process calls without
+// unbounded memory growth).
+// - Single pinned slot for the root branch (always hottest, always present
+// once populated, never subject to LRU eviction). Compact prefix of
+// length 0 (or single-byte "no-key" form) targets this slot.
+// - dirty-flag + PutIfClean invariants so cross-block writers can race
+// safely with fold updates.
+// - Lazy-decoded read path (GetDecoded) — cells are populated from the
+// cached encoded form on first decoded-read and reused thereafter.
+//
+// Lifetime: aggregator-scope (one instance per Domain). SharedDomains
+// pulls the instance via BranchCacheProvider on the AggregatorRoTx;
+// commitment-context plumbs it through to the trie via
+// InitializeTrieAndUpdates. The previous WarmupCache type (per-Process,
+// duplicating account/storage/branch caching above this layer) was
+// deleted in the WarmupCache consolidation; BranchCache is now the
+// single branch cache.
+//
+// # Responsibility split (architectural)
+//
+// The cache is a passive store. Reads and writes are driven by the
+// trie walker / encoder; the cache itself never reaches into the
+// underlying state.
+//
+// - BranchCache: passive store of branch bytes / decoded cells.
+// Doesn't fetch anything.
+// - Branch warmer (warmuper.go): narrow scope — pre-fetches
+// *branches* along touched-key paths via SD.GetLatest. No
+// account/storage prefetch — that conflated branch warm-up with
+// leaf-data fetch. If a fold needs leaf data the trie walker
+// fetches it directly (or it's already in Updates / memoized as
+// stateHash).
+// - Trie walker, block-processing path: receives Updates from the
+// executor, folds them. Memoized stateHashes serve siblings; new
+// values come from Updates. Doesn't reach into leaf data via
+// prefetch.
+// - Trie walker, witness / proof generation path: walks the trie
+// structure and *needs* to fetch state to materialize the proof.
+// This is the walker's responsibility — it drives its own reads
+// against SD. If that path turns out to be cold-bound on real
+// workloads it may indicate a need for separate account / storage
+// caches (the `add_execution_context_with_caches` work has a
+// reference design for these). Treat that as a separate concern
+// from this BranchCache — different scope, different lifetime,
+// different invalidation. Do not regrow the branch warmer's
+// scope to cover it.
+//
+// The disk_sto / disk_acc counters on the [commitment][cache-fp] log
+// line surface any fall-through where the trie compute reaches the
+// underlying ctx.Account / ctx.Storage paths. On block-processing
+// workloads they should remain zero; non-zero values signal a
+// memoization gap or a missing walker-side prefetch.
+//
+// # Concurrency contract — caller invariants
+//
+// Internally, the LRU tail is thread-safe (hashicorp/golang-lru/v2) and
+// the pinned root slot is an atomic.Pointer. So any combination of
+// concurrent Get / GetDecoded / Put / PutIfClean / MarkDirty / Invalidate
+// is mechanically safe — no panics, no torn reads. But "mechanically safe"
+// is NOT the same as "logically consistent across writers." The cache is
+// designed to be used under the following caller invariants:
+//
+// 1. Single writer per prefix at any moment. The cache does not coordinate
+// concurrent writes to the same key — last-Put-wins semantics, with no
+// guarantee that the winning value is the one the application wanted.
+//
+// 2. Mark-dirty-then-Put discipline for writers that may race with
+// readers. Caller calls MarkDirty BEFORE producing the new bytes, then
+// Put AFTER the canonical-store write succeeds. This is the
+// deferred-encoding-friendly alternative to inline invalidation
+// (motivated by the prototype investigation that found inline
+// invalidate is incompatible with deferred encoding — see
+// agentspecs/commitment-cache-prototype-dev-context.md).
+//
+// 3. Decoded cells returned by GetDecoded MUST NOT be mutated. The
+// *[16]cell pointer aliases entry-owned storage and is shared across
+// readers; in-place mutation breaks consistency for all subsequent
+// readers of that prefix.
+//
+// # Concurrency contract — how the existing concurrent trie satisfies it
+//
+// The current ConcurrentPatriciaHashed (parallel commitment calculator)
+// satisfies all three caller invariants by construction:
+//
+// - Mounts partition the prefix space by FIRST NIBBLE. Mount N's
+// encoder only writes branches whose key starts with [0x0N ...].
+// Different mounts therefore never write to the same prefix.
+// (See hex_concurrent_patricia_hashed.go: NewConcurrentPatriciaHashed
+// creates 16 mounts via SpawnSubTrie; each mount has its own HPH,
+// own BranchEncoder, own PatriciaContext / roTx.)
+//
+// - Root branch (prefix [0x00]) is written by the single root fold
+// that runs SEQUENTIALLY after errgroup.Wait() in ParallelHashSort.
+// One writer for the pinned root slot.
+//
+// - Mount→root grid roll-up is mutex-protected via
+// ConcurrentPatriciaHashed.rootMu — but that updates IN-MEMORY grid
+// cells, not the cache. The cache only sees the eventual root
+// branch when the post-Wait root fold encodes it.
+//
+// # Concurrency contract — what future parallel fold work must preserve
+//
+// Stage F (parallel tree-reduce fold), described in
+// agentspecs/trie-data-pipeline-complexity-tax.md, would change condition
+// 2 above: the parent fold (incl. root) would no longer be a single
+// post-Wait sequential pass. Multiple goroutines would compute parent
+// branches in parallel as their children complete. This MUST not violate
+// "single writer per prefix" — any future Stage F design needs an
+// explicit per-prefix coordination layer (atomic counter on parent
+// "children remaining"; only the last-decrementer writes the parent).
+// The dirty-flag + PutIfClean primitives in this cache are sufficient
+// for that coordination layer; the cache itself does NOT add per-prefix
+// locking because that would be wasted work for the current architecture.
+//
+// If you are implementing parallel fold (or any other architecture that
+// breaks the "single writer per prefix" invariant), do NOT relax the
+// invariant by adding internal locking to the cache. Add the
+// coordination at the orchestrator layer where the partitioning logic
+// lives. The cache stays simple; the orchestrator owns the discipline.
+//
+// Likewise if you change the prefix partitioning (e.g. by-second-nibble
+// mounts, depth-based partitioning, anything other than first-nibble),
+// re-validate that distinct workers continue to write disjoint prefix
+// spaces. Re-read the partitioning code in
+// hex_concurrent_patricia_hashed.go and confirm.
+type BranchCache struct {
+ // Pinned tier — single slot for the root branch. Atomic-pointer
+ // access so no lock is needed for the hot read path.
+ root atomic.Pointer[branchCacheEntry]
+
+ // Pinned-prefix tier — explicit per-prefix pin via PinEntry. Used
+ // for hot-contract storage-trunk preload (the "storage root trunk
+ // cache for big accounts" path). Entries here NEVER evict — sized
+ // by the preload policy, not by an LRU. Writes to pinned prefixes
+ // (via Put/SD.Flush) update the entry in place rather than displace
+ // it, so cross-block correctness is maintained without losing the
+ // pin. Lookup checks this tier between root and tail.
+ pinned *maphash.Map[*branchCacheEntry]
+
+ // LRU tail — bounded entries, evicts oldest when full. maphash.LRU
+ // wraps hashicorp/golang-lru/v2 which is thread-safe internally.
+ tail *maphash.LRU[*branchCacheEntry]
+
+ // Stats — atomic counters surfaced via Stats().
+ rootHits, rootMisses atomic.Uint64
+ pinnedHits, pinnedMisses atomic.Uint64
+ tailHits, tailMisses atomic.Uint64
+ bytesServed atomic.Uint64
+
+ // Divergence counter — incremented by RecordDivergence when a caller
+ // detects that a cache-served value disagrees with the canonical
+ // store. Driven by branchFromCacheOrDB's verify path (gated by
+ // BRANCH_CACHE_VERIFY env). Helps localise correctness regressions
+ // in cross-block-cache investigations: a non-zero count is a
+ // load-bearing signal that the cache lifecycle is broken before any
+ // trie root mismatch surfaces downstream.
+ verifyDivergences atomic.Uint64
+
+ // writeSeq is incremented on every Put so each entry carries a
+ // monotonic ordering tag — divergence-detection uses this with the
+ // origin label and timestamp to identify which write produced the
+ // stale bytes.
+ writeSeq atomic.Uint64
+
+ // onMiss is an optional hook fired when lookup misses all three
+ // tiers (root, pinned, LRU). Used by the adaptive trunk-pin
+ // controller to attribute miss pressure per-contract and decide
+ // promotions. Stored as atomic.Pointer so registration is
+ // lock-free and the hot read path skips the dereference cleanly
+ // when no callback is installed.
+ onMiss atomic.Pointer[MissCallback]
+
+ // Last-published Prometheus counter snapshots. PublishMetrics
+ // emits the delta between current and last so the monotonic
+ // counters track real activity per Flush, not snapshot absolutes.
+ lastPublishedPinnedHits atomic.Uint64
+ lastPublishedPinnedMisses atomic.Uint64
+
+ // preloadClaimed is set the first time TryClaimPreload is called.
+ // Used by the trunk-preload trigger (PIN_CONTRACT_TRUNKS hook in
+ // SharedDomains construction) so the preload goroutine fires
+ // exactly once per cache lifetime, even though many SharedDomains
+ // instances may be created over a process's lifetime.
+ preloadClaimed atomic.Bool
+
+ // Tx-aware unwind coherence — same discipline as the state-cache
+ // GenericCache. Each entry carries the txNum its bytes are valid as of
+ // and the epoch it was written in. Unwind(txNum) bumps the epoch and
+ // lowers unwindFloor; an entry is valid iff it was written in the
+ // current epoch OR its txNum is at/below the floor. Without this the
+ // cache cannot be unwound: a reorg leaves recent canonical branches
+ // that a later fork-validation reads as a wrong trie root. Step
+ // granularity is too coarse here — an unwind to a txNum inside the
+ // latest step needs txNum precision.
+ epoch atomic.Uint32
+ unwindFloor atomic.Uint64
+}
+
+// TryClaimPreload returns true the first time it's called on a given
+// BranchCache instance, false on every subsequent call. Used by the
+// trunk-preload trigger to ensure the preload goroutine runs exactly
+// once per cache (process-lifetime), regardless of how many
+// SharedDomains instances are constructed.
+func (c *BranchCache) TryClaimPreload() bool {
+ return c.preloadClaimed.CompareAndSwap(false, true)
+}
+
+type branchCacheEntry struct {
+ // data is the canonical encoded form (with the leading 2-byte touch-map
+ // prefix). Always populated by Put / PutIfClean.
+ data []byte
+
+ // Lazy-decoded form. Populated on first GetDecoded for this entry;
+ // subsequent reads return the cached cells. decodeOnce ensures decode
+ // runs at most once even under concurrent reads.
+ decodeOnce sync.Once
+ cells [16]cell
+ cellsBitmap uint16
+ decodedReady bool
+ decodeErr error
+
+ // dirty signals "the canonical store has been written to since this
+ // entry was populated; treat as stale until cleared." Same semantics
+ // as branchEntry.dirty in WarmupCache (carried from step 4).
+ dirty atomic.Bool
+
+ // origin diagnostics — captured at Put time so divergence-detection
+ // can identify which write produced the (now-disagreeing) bytes.
+ // origin is a short label of the write site (e.g. "CollectUpdate",
+ // "L3-fallback-read"); writeSeq is a monotonic counter per
+ // BranchCache instance; writeTimeNanos is unix-nanos at write time.
+ origin string
+ writeSeq uint64
+ writeTimeNanos int64
+
+ // txNum is an upper bound on the txNum the cached bytes are valid as of
+ // (the last txNum of the file/step they came from). Used to gate reads
+ // after an unwind: an entry whose txNum is above the unwind floor and
+ // whose epoch is superseded is stale. 0 means frozen/untracked — always
+ // at/below any unwind floor, so kept (correct for frozen-file preloads,
+ // which can never be unwound into). Real callers pass the value's txNum.
+ txNum uint64
+
+ // epoch is the unwind generation the entry was written in. Disambiguates
+ // a txNum reused across forks: an entry from a superseded epoch with a
+ // txNum above the floor is dropped lazily on its next Get.
+ epoch uint32
+}
+
+// DefaultBranchCacheTailCapacity is the LRU tail size used when no
+// explicit capacity is given. ~50k entries × ~500 bytes = ~25 MB
+// at typical mainnet branch sizes.
+const DefaultBranchCacheTailCapacity = 50000
+
+// BranchCacheProvider exposes the long-lived BranchCache attached to the
+// commitment domain. Implemented by *db/state.AggregatorRoTx (via duck
+// typing) so callers in the SharedDomains construction path can fetch the
+// cache without forcing db/state/execctx to import db/state — that import
+// would create a cycle since db/state imports execctx (squeeze.go,
+// trie_reader_integration_test.go, …).
+//
+// Returning nil is permitted; callers MUST treat nil as "no shared cache,
+// behave as if disabled" rather than panic.
+type BranchCacheProvider interface {
+ BranchCache() *BranchCache
+}
+
+// NewBranchCache constructs a BranchCache with the given LRU tail capacity.
+// Capacity <= 0 panics — pass a positive value or DefaultBranchCacheTailCapacity.
+func NewBranchCache(tailCapacity int) *BranchCache {
+ if tailCapacity <= 0 {
+ panic(fmt.Sprintf("BranchCache: tailCapacity must be positive, got %d", tailCapacity))
+ }
+ tail, err := maphash.NewLRU[*branchCacheEntry](tailCapacity)
+ if err != nil {
+ panic(fmt.Sprintf("BranchCache: NewLRU: %s", err))
+ }
+ bc := &BranchCache{
+ tail: tail,
+ pinned: maphash.NewMap[*branchCacheEntry](),
+ }
+ // No unwind seen yet: every entry's txNum is at/below the floor, so the
+ // epoch check never strands a valid entry.
+ bc.unwindFloor.Store(math.MaxUint64)
+ return bc
+}
+
+// isRootPrefix reports whether prefix targets the pinned root slot. The
+// commitment-trie compact encoding uses a 1-byte even-length flag (0x00)
+// to represent the empty nibble path (root branch). Anything longer goes
+// to the LRU tail.
+func isRootPrefix(prefix []byte) bool {
+ return len(prefix) == 1 && prefix[0] == 0x00
+}
+
+func (c *BranchCache) lookup(prefix []byte) (*branchCacheEntry, bool) {
+ if isRootPrefix(prefix) {
+ entry := c.root.Load()
+ if entry == nil {
+ c.rootMisses.Add(1)
+ c.fireOnMiss(prefix)
+ return nil, false
+ }
+ c.rootHits.Add(1)
+ return entry, true
+ }
+ if entry, ok := c.pinned.Get(prefix); ok {
+ c.pinnedHits.Add(1)
+ return entry, true
+ }
+ c.pinnedMisses.Add(1)
+ entry, ok := c.tail.Get(prefix)
+ if !ok {
+ c.tailMisses.Add(1)
+ c.fireOnMiss(prefix)
+ return nil, false
+ }
+ c.tailHits.Add(1)
+ return entry, true
+}
+
+// peek is the write-path equivalent of lookup: same tier walk, but does
+// not bump hit/miss counters and does not fire the onMiss callback.
+// Used by PutIfClean / MarkDirty so write traffic doesn't masquerade as
+// read-miss pressure for the adaptive pin controller.
+func (c *BranchCache) peek(prefix []byte) (*branchCacheEntry, bool) {
+ if isRootPrefix(prefix) {
+ entry := c.root.Load()
+ return entry, entry != nil
+ }
+ if entry, ok := c.pinned.Get(prefix); ok {
+ return entry, true
+ }
+ return c.tail.Get(prefix)
+}
+
+// fireOnMiss invokes the registered miss callback (if any). Hot path —
+// the no-callback case is a single atomic load and a nil check.
+func (c *BranchCache) fireOnMiss(prefix []byte) {
+ if cb := c.onMiss.Load(); cb != nil {
+ (*cb)(prefix)
+ }
+}
+
+func (c *BranchCache) store(prefix []byte, entry *branchCacheEntry) {
+ if isRootPrefix(prefix) {
+ c.root.Store(entry)
+ return
+ }
+ // If this prefix is pinned, update the pinned entry in place rather
+ // than route to the LRU tail. Keeps the pin alive across the
+ // SD.Flush invalidate+Put cycle that refreshes branch values every
+ // block — without this, every Put would silently lose the pin.
+ if _, ok := c.pinned.Get(prefix); ok {
+ c.pinned.Set(prefix, entry)
+ return
+ }
+ c.tail.Set(prefix, entry)
+}
+
+// PinEntry inserts or replaces a pinned cache entry for prefix. Pinned
+// entries are never evicted by the LRU and survive across blocks
+// (subject to Put updates from SD.Flush refreshing the bytes). Use for
+// eager preload of hot prefixes — e.g. the storage-trunk of big
+// contracts under PIN_CONTRACT_TRUNKS. Data is copied; safe to mutate
+// the input after the call.
+func (c *BranchCache) PinEntry(prefix []byte, data []byte, txNum uint64, origin string) {
+ if isCommitmentStateKey(prefix) {
+ return
+ }
+ dataCopy := make([]byte, len(data))
+ copy(dataCopy, data)
+ c.pinned.Set(prefix, &branchCacheEntry{
+ data: dataCopy,
+ txNum: txNum,
+ epoch: c.epoch.Load(),
+ writeSeq: c.writeSeq.Add(1),
+ origin: origin,
+ writeTimeNanos: time.Now().UnixNano(),
+ })
+}
+
+// PinnedCount returns the number of currently pinned entries. Useful
+// for observability of the preload policy and eviction sanity (pinned
+// entries are never evicted; this counter is monotonic over a
+// PreloadContractTrunk run).
+func (c *BranchCache) PinnedCount() int {
+ return c.pinned.Len()
+}
+
+// MissCallback is invoked when lookup misses ALL three tiers (root,
+// pinned, LRU tail). Called on the hot read path; implementations
+// must be lock-free or short and not block.
+type MissCallback func(prefix []byte)
+
+// SetMissCallback installs a hook fired on every triple-miss. Pass
+// nil to clear. Used by the adaptive controller to attribute miss
+// pressure per contract; the cache itself does no per-contract
+// bookkeeping. Replaces any prior callback atomically.
+func (c *BranchCache) SetMissCallback(cb MissCallback) {
+ if cb == nil {
+ c.onMiss.Store(nil)
+ return
+ }
+ c.onMiss.Store(&cb)
+}
+
+// ContractHashFromPrefix extracts the 32-byte contract hash from a
+// commitment-trunk prefix. Returns (hash, true) if the prefix is a
+// storage-trunk prefix (depth >= 64, encoded as hex-prefix compact with
+// the contract's 64-nibble keccak prefix), or (_, false) for shorter
+// prefixes (account-trie branches at depth < 64).
+//
+// Hex-prefix encoding (see nibbles.HexToCompact): bit 4 of byte 0 is the
+// odd-length flag. When set, the first nibble of the path lives in the
+// low nibble of byte 0 and the remaining nibbles are packed 2-per-byte
+// in buf[1:]; reading prefix[1:33] directly mis-attributes the hash by
+// one nibble. Decoding via the high/low-nibble split here handles both
+// parities.
+func ContractHashFromPrefix(prefix []byte) (hash [32]byte, ok bool) {
+ if len(prefix) < 33 {
+ return hash, false
+ }
+ if prefix[0]&0x10 == 0 {
+ copy(hash[:], prefix[1:33])
+ return hash, true
+ }
+ for i := 0; i < 32; i++ {
+ hash[i] = ((prefix[i] & 0x0f) << 4) | (prefix[i+1] >> 4)
+ }
+ return hash, true
+}
+
+// Get retrieves branch data from the cache. Returns the canonical encoded
+// bytes (with the leading 2-byte touch-map prefix) plus the on-disk file
+// step the bytes came from (0 if not tracked).
+func (c *BranchCache) Get(prefix []byte) ([]byte, uint64, bool) {
+ if isCommitmentStateKey(prefix) {
+ return nil, 0, false
+ }
+ entry, ok := c.lookup(prefix)
+ if !ok {
+ return nil, 0, false
+ }
+ // Tx-aware unwind invalidation: an entry from a superseded epoch whose
+ // txNum is above the unwind floor is stale (its bytes belong to a chain
+ // segment that was unwound). Drop it lazily on read. Entries at/below
+ // the floor (incl. frozen-file preloads stamped txNum 0) stay warm.
+ if entry.epoch != c.epoch.Load() && entry.txNum > c.unwindFloor.Load() {
+ c.Invalidate(prefix)
+ if dbgBC {
+ fmt.Fprintf(os.Stderr, "[BC-EVICT] prefix=%x txNum=%d floor=%d eEpoch=%d cur=%d\n", prefix, entry.txNum, c.unwindFloor.Load(), entry.epoch, c.epoch.Load())
+ }
+ return nil, 0, false
+ }
+ if dbgBC && entry.epoch != c.epoch.Load() {
+ fmt.Fprintf(os.Stderr, "[BC-SERVE-OLD] prefix=%x txNum=%d floor=%d eEpoch=%d cur=%d\n", prefix, entry.txNum, c.unwindFloor.Load(), entry.epoch, c.epoch.Load())
+ }
+ c.bytesServed.Add(uint64(len(entry.data)))
+ return entry.data, entry.txNum, true
+}
+
+var dbgBC = os.Getenv("DBG_BC") != ""
+
+// Unwind invalidates cache entries whose bytes belong to a chain segment
+// above unwindToTxNum. O(1): bump the epoch (so entries written in the new,
+// live epoch stay valid) and lower the floor to the unwind point (so entries
+// at/below it survive); stale entries (superseded epoch, txNum above the
+// floor) are dropped lazily on their next Get. Mirrors GenericCache.Unwind.
+// Driven from SharedDomains.Unwind so a reorg can't leave stale committed
+// branches that a fork-validation then reads as a wrong trie root.
+func (c *BranchCache) Unwind(unwindToTxNum uint64) {
+ c.epoch.Add(1)
+ if dbgBC {
+ fmt.Fprintf(os.Stderr, "[BC-UNWIND] txNum=%d newEpoch=%d\n", unwindToTxNum, c.epoch.Load())
+ }
+ for {
+ cur := c.unwindFloor.Load()
+ if unwindToTxNum >= cur {
+ break
+ }
+ if c.unwindFloor.CompareAndSwap(cur, unwindToTxNum) {
+ break
+ }
+ }
+}
+
+// GetDecoded retrieves the cached branch in decoded form. Lazy-decodes on
+// first access for each entry; subsequent reads return the cached cells
+// pointer without redoing the parse work.
+//
+// Returns the bitmap of present children plus a pointer to the populated
+// cells array. Caller derives touchMap/afterMap based on its own context
+// (deleted vs present-after) — same convention as WarmupCache.GetBranchDecoded.
+//
+// The returned *[16]cell aliases storage owned by the cache entry — the
+// caller MUST NOT modify the cells in place. Read-only consumption is
+// safe across concurrent calls.
+func (c *BranchCache) GetDecoded(prefix []byte) (bitmap uint16, cells *[16]cell, ok bool) {
+ if isCommitmentStateKey(prefix) {
+ return 0, nil, false
+ }
+ entry, found := c.lookup(prefix)
+ if !found {
+ return 0, nil, false
+ }
+ entry.decodeOnce.Do(func() {
+ if len(entry.data) < 2 {
+ entry.decodeErr = fmt.Errorf("branch entry too short for touch-map prefix: %d bytes", len(entry.data))
+ return
+ }
+ maps, err := DecodeBranchInto(entry.data[2:], false /* deleted derived per-caller */, &entry.cells)
+ if err != nil {
+ entry.decodeErr = err
+ return
+ }
+ entry.cellsBitmap = maps.Bitmap
+ entry.decodedReady = true
+ })
+ if !entry.decodedReady {
+ return 0, nil, false
+ }
+ c.bytesServed.Add(uint64(len(entry.data)))
+ return entry.cellsBitmap, &entry.cells, true
+}
+
+// Put stores branch data in the cache, replacing any existing entry
+// (clearing its dirty flag in the process — the new entry is fresh).
+// Always copies the input data so the cache owns it independently of
+// caller buffer lifetime. step is the on-disk file step the bytes came
+// from (0 if not tracked); origin is a short label of the write site
+// captured for divergence-detection diagnostics.
+func (c *BranchCache) Put(prefix []byte, data []byte, txNum uint64, origin string) {
+ if isCommitmentStateKey(prefix) {
+ return
+ }
+ dataCopy := make([]byte, len(data))
+ copy(dataCopy, data)
+ c.store(prefix, &branchCacheEntry{
+ data: dataCopy,
+ txNum: txNum,
+ epoch: c.epoch.Load(),
+ origin: origin,
+ writeSeq: c.writeSeq.Add(1),
+ writeTimeNanos: time.Now().UnixNano(),
+ })
+}
+
+// PutIfClean stores branch data only if no existing entry is marked dirty.
+// Returns true on store, false if a dirty entry was present (indicating a
+// canonical-store write is in progress and the caller's data is potentially
+// stale).
+//
+// Same semantics as WarmupCache.PutBranchIfClean — see that doc for the
+// race-it-protects-against narrative.
+func (c *BranchCache) PutIfClean(prefix []byte, data []byte, txNum uint64, origin string) bool {
+ if existing, ok := c.peek(prefix); ok && existing.dirty.Load() {
+ return false
+ }
+ c.Put(prefix, data, txNum, origin)
+ return true
+}
+
+// GetWithOrigin returns the cached bytes plus the diagnostic origin
+// metadata captured when the entry was put. ok=false on miss. Used by
+// the divergence-detection probe to identify which write produced the
+// stale bytes. Does not bump hit/miss counters or affect LRU recency
+// (uses a non-counting peek so it can be called alongside Get without
+// double-counting).
+func (c *BranchCache) GetWithOrigin(prefix []byte) (data []byte, origin string, writeSeq uint64, writeTimeNanos int64, ok bool) {
+ var entry *branchCacheEntry
+ if isRootPrefix(prefix) {
+ entry = c.root.Load()
+ } else if pinnedEntry, pinnedOk := c.pinned.Get(prefix); pinnedOk {
+ entry = pinnedEntry
+ } else {
+ entry, ok = c.tail.Get(prefix)
+ if !ok {
+ return nil, "", 0, 0, false
+ }
+ }
+ if entry == nil {
+ return nil, "", 0, 0, false
+ }
+ return entry.data, entry.origin, entry.writeSeq, entry.writeTimeNanos, true
+}
+
+// MarkDirty flags the entry at prefix as stale-until-cleared. Subsequent
+// PutIfClean calls for this prefix will skip; reads still return the
+// entry (the dirty signal is consumed only on the write path today, same
+// as WarmupCache.MarkBranchDirty).
+//
+// No-op if no entry exists at prefix.
+func (c *BranchCache) MarkDirty(prefix []byte) {
+ if entry, ok := c.peek(prefix); ok {
+ entry.dirty.Store(true)
+ }
+}
+
+// Invalidate removes the entry at prefix entirely from whichever tier
+// holds it. Use when the caller knows the canonical store has changed
+// and the cached entry should not be served at all (vs MarkDirty which
+// keeps the entry but blocks PutIfClean overwrites).
+//
+// Pinned-tier note: Invalidate does delete from the pinned tier. The
+// usual lifecycle for pinned entries is in-place refresh via Put on
+// SD.Flush (so pin survives every-block writes); Invalidate is the
+// escape hatch for events where the pinned bytes must be discarded
+// outright (unwind, fork-validation reset, manual demotion).
+func (c *BranchCache) Invalidate(prefix []byte) {
+ if isRootPrefix(prefix) {
+ c.root.Store(nil)
+ return
+ }
+ c.pinned.Delete(prefix)
+ c.tail.Delete(prefix)
+}
+
+// Clear empties the cache and resets stats counters across ALL tiers
+// (root, pinned, LRU tail). Use on Reset / fork-validation paths to
+// ensure stale entries from one trie root are not served against a
+// different root. After Clear, the pinned tier is empty; callers
+// using trunk preload need to re-issue PreloadContractTrunk to
+// repopulate it.
+func (c *BranchCache) Clear() {
+ c.root.Store(nil)
+ c.pinned = maphash.NewMap[*branchCacheEntry]()
+ c.tail.Purge()
+ c.unwindFloor.Store(math.MaxUint64)
+ c.rootHits.Store(0)
+ c.rootMisses.Store(0)
+ c.pinnedHits.Store(0)
+ c.pinnedMisses.Store(0)
+ c.tailHits.Store(0)
+ c.tailMisses.Store(0)
+ c.bytesServed.Store(0)
+ c.verifyDivergences.Store(0)
+}
+
+// RecordDivergence increments the divergence counter. Called by
+// branchFromCacheOrDB's verify path when a cache-served value disagrees
+// with a parallel ctx.Branch read.
+func (c *BranchCache) RecordDivergence() {
+ c.verifyDivergences.Add(1)
+}
+
+// Fingerprint returns a deterministic hash of all current entries (root +
+// tail). Two caches with the same set of (key, data) pairs produce the
+// same fingerprint regardless of insertion order. Use for cross-run
+// divergence localisation: emit per-block in two builds, diff the logs to
+// see exactly which block their caches first differ.
+//
+// Mixes (key-hash, data-hash) pairs because the LRU stores by hash and
+// discards the original key bytes on insert. Two entries with the same
+// original key produce the same hash, so the fingerprint is still
+// equality-equivalent to the (key, data) set modulo hash collision.
+//
+// Cheap: one FNV-1a fold over data per entry. Not cryptographic.
+func (c *BranchCache) Fingerprint() uint64 {
+ const fnvOffset uint64 = 14695981039346656037
+ const fnvPrime uint64 = 1099511628211
+ dataHash := func(data []byte) uint64 {
+ h := fnvOffset
+ for _, b := range data {
+ h ^= uint64(b)
+ h *= fnvPrime
+ }
+ return h
+ }
+ mix := func(keyHash uint64, data []byte) uint64 {
+ // Combine key and data hashes into one entry hash; xor-fold across
+ // entries below so the per-cache result is insertion-order
+ // independent.
+ return keyHash ^ (dataHash(data) * fnvPrime)
+ }
+ var fp uint64
+ if e := c.root.Load(); e != nil && len(e.data) > 0 {
+ // Pinned-root key is the constant 1-byte prefix 0x00; use a
+ // distinct sentinel hash so the root contribution can't collide
+ // with a tail entry hashed to zero.
+ fp ^= mix(0xdeadbeefcafe0001, e.data)
+ }
+ c.tail.Range(func(h uint64, e *branchCacheEntry) bool {
+ if len(e.data) > 0 {
+ fp ^= mix(h, e.data)
+ }
+ return true
+ })
+ return fp
+}
+
+// Stats returns a one-line summary of root-tier and tail-tier hit/miss
+// counters plus bytes served. Format mirrors WarmupCache.Stats() so
+// per-Process log lines can compose them.
+func (c *BranchCache) Stats() string {
+ rh, rm := c.rootHits.Load(), c.rootMisses.Load()
+ ph, pm := c.pinnedHits.Load(), c.pinnedMisses.Load()
+ th, tm := c.tailHits.Load(), c.tailMisses.Load()
+ bb := c.bytesServed.Load()
+ pct := func(hit, miss uint64) float64 {
+ total := hit + miss
+ if total == 0 {
+ return 0
+ }
+ return 100.0 * float64(hit) / float64(total)
+ }
+ return fmt.Sprintf(
+ "branch-cache root hit=%d miss=%d (%.1f%%) | pin hit=%d miss=%d (%.1f%%) entries=%d | tail hit=%d miss=%d (%.1f%%) entries=%d | served %.1f MiB | divergences=%d",
+ rh, rm, pct(rh, rm),
+ ph, pm, pct(ph, pm), c.pinned.Len(),
+ th, tm, pct(th, tm), c.tail.Len(),
+ float64(bb)/1024/1024,
+ c.verifyDivergences.Load(),
+ )
+}
+
+// PinnedStats returns the pinned-tier hit/miss/entries counters. Used
+// by the cache-fp log to expose pin effectiveness for the trunk-pin
+// prototype debug.
+func (c *BranchCache) PinnedStats() (hits, misses uint64, entries int) {
+ return c.pinnedHits.Load(), c.pinnedMisses.Load(), c.pinned.Len()
+}
+
+// VerifyDivergences returns the number of cache-vs-canonical divergences
+// recorded since the last Clear. Non-zero indicates a cache lifecycle
+// invariant has been violated (a cached entry no longer matches the
+// canonical store) — read from outside to assert correctness in tests
+// and benches.
+func (c *BranchCache) VerifyDivergences() uint64 {
+ return c.verifyDivergences.Load()
+}
diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go
new file mode 100644
index 00000000000..29e3534e817
--- /dev/null
+++ b/execution/commitment/branch_cache_test.go
@@ -0,0 +1,187 @@
+// 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 .
+
+package commitment
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestBranchCache_RootPinning verifies the root branch lands in the pinned
+// slot (counted as root-hit) and tail entries land in the LRU tier
+// (counted as tail-hit).
+func TestBranchCache_RootPinning(t *testing.T) {
+ c := NewBranchCache(100)
+
+ rootKey := []byte{0x00} // compact-encoded empty nibble path = root branch
+ deepKey := []byte{0x12, 0x34, 0x56}
+ c.Put(rootKey, []byte("root-data"), 0, "test")
+ c.Put(deepKey, []byte("deep-data"), 0, "test")
+
+ // Root reads should increment rootHits, not tailHits
+ got, _, ok := c.Get(rootKey)
+ require.True(t, ok)
+ require.Equal(t, []byte("root-data"), got)
+ require.Equal(t, uint64(1), c.rootHits.Load())
+ require.Equal(t, uint64(0), c.tailHits.Load())
+
+ // Deep reads should increment tailHits, not rootHits
+ got, _, ok = c.Get(deepKey)
+ require.True(t, ok)
+ require.Equal(t, []byte("deep-data"), got)
+ require.Equal(t, uint64(1), c.rootHits.Load())
+ require.Equal(t, uint64(1), c.tailHits.Load())
+}
+
+// TestBranchCache_RootSurvivesEvictionPressure verifies that pinned root
+// entry is not subject to LRU eviction even if the tail fills past
+// capacity many times over.
+func TestBranchCache_RootSurvivesEvictionPressure(t *testing.T) {
+ c := NewBranchCache(10) // very small tail
+ rootKey := []byte{0x00}
+ c.Put(rootKey, []byte("ROOT-PERSISTS"), 0, "test")
+
+ // Stuff the tail well past capacity
+ for i := 0; i < 100; i++ {
+ c.Put([]byte{byte(i), byte(i)}, []byte{byte(i)}, 0, "test")
+ }
+
+ // Root must still be there
+ got, _, ok := c.Get(rootKey)
+ require.True(t, ok, "root should never be evicted from pinned slot")
+ require.Equal(t, []byte("ROOT-PERSISTS"), got)
+
+ // Tail at capacity (10), not 100
+ require.LessOrEqual(t, c.tail.Len(), 10, "tail should respect LRU capacity")
+}
+
+// TestBranchCache_DirtyFlag verifies PutIfClean refuses overwrite of a
+// dirty entry, while Put unconditionally replaces (and the new entry
+// starts clean).
+func TestBranchCache_DirtyFlag(t *testing.T) {
+ c := NewBranchCache(100)
+ key := []byte{0x12, 0x34}
+
+ require.True(t, c.PutIfClean(key, []byte("v1"), 0, "test"))
+ c.MarkDirty(key)
+
+ require.False(t, c.PutIfClean(key, []byte("v2"), 0, "test"), "PutIfClean must refuse dirty entry")
+ got, _, _ := c.Get(key)
+ require.Equal(t, []byte("v1"), got, "dirty entry's data preserved")
+
+ // Unconditional Put replaces
+ c.Put(key, []byte("v3"), 0, "test")
+ got, _, _ = c.Get(key)
+ require.Equal(t, []byte("v3"), got)
+
+ // New entry is clean
+ require.True(t, c.PutIfClean(key, []byte("v4"), 0, "test"))
+}
+
+// TestBranchCache_GetDecoded verifies the lazy-decode read path for a
+// real encoded branch (round-trip with BranchEncoder).
+func TestBranchCache_GetDecoded(t *testing.T) {
+ c := NewBranchCache(100)
+
+ row, bm := generateCellRow(t, 16)
+ be := NewBranchEncoder(1024)
+ cellData := generateCellEncodeDataRow(t, row, bm)
+ enc, err := be.EncodeBranch(bm, bm, bm, &cellData)
+ require.NoError(t, err)
+
+ prefix := []byte{0x12, 0x34}
+ c.Put(prefix, enc, 0, "test")
+
+ // First decoded-read decodes lazily
+ bitmap, cells, ok := c.GetDecoded(prefix)
+ require.True(t, ok)
+ require.Equal(t, bm, bitmap)
+ require.NotNil(t, cells)
+
+ // Second call returns same cells pointer
+ _, cells2, ok := c.GetDecoded(prefix)
+ require.True(t, ok)
+ require.Same(t, cells, cells2, "decoded form cached and reused")
+
+ // Encoded form unchanged after decoded reads
+ encGot, _, ok := c.Get(prefix)
+ require.True(t, ok)
+ require.Equal(t, []byte(enc), encGot)
+}
+
+// TestBranchCache_Invalidate removes entries from both tiers.
+func TestBranchCache_Invalidate(t *testing.T) {
+ c := NewBranchCache(100)
+ rootKey := []byte{0x00}
+ deepKey := []byte{0x12, 0x34}
+ c.Put(rootKey, []byte("r"), 0, "test")
+ c.Put(deepKey, []byte("d"), 0, "test")
+
+ c.Invalidate(rootKey)
+ _, _, ok := c.Get(rootKey)
+ require.False(t, ok, "root invalidated")
+
+ c.Invalidate(deepKey)
+ _, _, ok = c.Get(deepKey)
+ require.False(t, ok, "deep invalidated")
+}
+
+// TestBranchCache_Clear empties everything and resets stats.
+func TestBranchCache_Clear(t *testing.T) {
+ c := NewBranchCache(100)
+ c.Put([]byte{0x00}, []byte("r"), 0, "test")
+ c.Put([]byte{0x12}, []byte("d"), 0, "test")
+ _, _, _ = c.Get([]byte{0x00})
+ _, _, _ = c.Get([]byte{0x12})
+
+ require.Equal(t, uint64(1), c.rootHits.Load())
+ require.Equal(t, uint64(1), c.tailHits.Load())
+
+ c.Clear()
+ require.Equal(t, uint64(0), c.rootHits.Load())
+ require.Equal(t, uint64(0), c.tailHits.Load())
+ _, _, ok := c.Get([]byte{0x00})
+ require.False(t, ok)
+ _, _, ok = c.Get([]byte{0x12})
+ require.False(t, ok)
+}
+
+// TestBranchCache_Stats verifies the format of the stats string is
+// deterministic and contains the expected per-tier counts.
+func TestBranchCache_Stats(t *testing.T) {
+ c := NewBranchCache(100)
+ c.Put([]byte{0x00}, []byte("rrr"), 0, "test")
+ c.Put([]byte{0x12, 0x34}, []byte("ddd"), 0, "test")
+ _, _, _ = c.Get([]byte{0x00})
+ _, _, _ = c.Get([]byte{0x12, 0x34})
+ _, _, _ = c.Get([]byte{0xff}) // tail miss
+
+ s := c.Stats()
+ for _, want := range []string{
+ "root hit=1 miss=0",
+ "tail hit=1 miss=1",
+ // Pinned tier added; tail still has 1 entry (the deep Put),
+ // pinned tier remains empty in this test.
+ "tail hit=1 miss=1 (50.0%) entries=1",
+ } {
+ require.Contains(t, s, want, "Stats output: %s", s)
+ }
+ // Sanity: format doesn't blow up if we read it
+ require.True(t, strings.HasPrefix(s, "branch-cache "))
+}
diff --git a/execution/commitment/branch_decode.go b/execution/commitment/branch_decode.go
new file mode 100644
index 00000000000..10093d92111
--- /dev/null
+++ b/execution/commitment/branch_decode.go
@@ -0,0 +1,76 @@
+// 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 .
+
+package commitment
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math/bits"
+)
+
+// BranchMaps captures the three branch-level bitmasks decoded alongside
+// the per-cell payload.
+type BranchMaps struct {
+ Bitmap uint16 // present-children bitmap (canonical encoded map)
+ TouchMap uint16 // children touched in this branch (per the deleted flag)
+ AfterMap uint16 // children present after this commitment step
+}
+
+// DecodeBranchInto parses the on-disk encoded form of a branch into cells.
+// branchData must already have the leading 2-byte touch-map prefix stripped.
+//
+// deleted=true → touchMap=bitmap, afterMap=0 (touched-but-not-present-after).
+// deleted=false → touchMap=0, afterMap=bitmap (present-after).
+//
+// Pure decode — does NOT call deriveHashedKeys on the cells. Trie callers do
+// that themselves (they have the keccak scratch buffer); cache callers can
+// defer it until the cell is consumed by the trie.
+func DecodeBranchInto(
+ branchData []byte,
+ deleted bool,
+ cells *[16]cell,
+) (BranchMaps, error) {
+ if len(branchData) < 2 {
+ return BranchMaps{}, fmt.Errorf("branch data too short for bitmap: %d bytes", len(branchData))
+ }
+ bitmap := binary.BigEndian.Uint16(branchData[0:])
+ maps := BranchMaps{Bitmap: bitmap}
+ if deleted {
+ maps.TouchMap, maps.AfterMap = bitmap, 0
+ } else {
+ maps.TouchMap, maps.AfterMap = 0, bitmap
+ }
+
+ pos := 2
+ for bitset := bitmap; bitset != 0; {
+ bit := bitset & -bitset
+ nibble := bits.TrailingZeros16(bit)
+ c := &cells[nibble]
+ if pos >= len(branchData) {
+ return BranchMaps{}, fmt.Errorf("branch data truncated before cell at nibble %d", nibble)
+ }
+ fieldBits := branchData[pos]
+ pos++
+ newPos, err := c.fillFromFields(branchData, pos, cellFields(fieldBits))
+ if err != nil {
+ return BranchMaps{}, fmt.Errorf("fillFromFields nibble %d: %w", nibble, err)
+ }
+ pos = newPos
+ bitset ^= bit
+ }
+ return maps, nil
+}
diff --git a/execution/commitment/branch_decode_test.go b/execution/commitment/branch_decode_test.go
new file mode 100644
index 00000000000..abe80e0b25f
--- /dev/null
+++ b/execution/commitment/branch_decode_test.go
@@ -0,0 +1,106 @@
+// 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 .
+
+package commitment
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// TestDecodeBranchInto_RoundTrip asserts that DecodeBranchInto recovers
+// the cells encoded by BranchEncoder.EncodeBranch — the property test that
+// keeps the canonical decoder consistent with the canonical encoder.
+func TestDecodeBranchInto_RoundTrip(t *testing.T) {
+ t.Parallel()
+ row, bm := generateCellRow(t, 16)
+
+ be := NewBranchEncoder(1024)
+ cellData := generateCellEncodeDataRow(t, row, bm)
+ enc, err := be.EncodeBranch(bm, bm, bm, &cellData)
+ require.NoError(t, err)
+ require.NotEmpty(t, enc)
+
+ // EncodeBranch produces bytes WITH the 2-byte touch-map prefix; the
+ // decoder consumes the bytes WITHOUT it (matching the unfoldBranchNode
+ // call pattern, which strips the touch-map prefix before decoding).
+ branchData := []byte(enc)[2:]
+
+ var cells [16]cell
+ maps, err := DecodeBranchInto(branchData, false /* not deleted */, &cells)
+ require.NoError(t, err)
+
+ // Bitmap should match what was encoded
+ require.Equal(t, bm, maps.Bitmap, "decoded bitmap mismatch")
+ require.Equal(t, uint16(0), maps.TouchMap, "expected empty touchMap when deleted=false")
+ require.Equal(t, bm, maps.AfterMap, "afterMap should equal bitmap when deleted=false")
+
+ // Each present cell should match the original on the fields that
+ // survive encode→decode (extension, account/storage addr, hash).
+ // hashedExtension etc. are set by deriveHashedKeys (separate step) and
+ // are not part of the decoder's responsibility.
+ for i, orig := range row {
+ decoded := &cells[i]
+ require.Equal(t, orig.extLen, decoded.extLen, "cell %d extLen", i)
+ require.Equal(t, orig.extension[:orig.extLen], decoded.extension[:decoded.extLen], "cell %d extension", i)
+ require.Equal(t, orig.accountAddrLen, decoded.accountAddrLen, "cell %d accountAddrLen", i)
+ require.Equal(t, orig.accountAddr[:orig.accountAddrLen], decoded.accountAddr[:decoded.accountAddrLen], "cell %d accountAddr", i)
+ require.Equal(t, orig.storageAddrLen, decoded.storageAddrLen, "cell %d storageAddrLen", i)
+ require.Equal(t, orig.storageAddr[:orig.storageAddrLen], decoded.storageAddr[:decoded.storageAddrLen], "cell %d storageAddr", i)
+ require.Equal(t, orig.hashLen, decoded.hashLen, "cell %d hashLen", i)
+ require.Equal(t, orig.hash[:orig.hashLen], decoded.hash[:decoded.hashLen], "cell %d hash", i)
+ }
+}
+
+// TestDecodeBranchInto_DeletedFlag verifies the touchMap/afterMap convention
+// flips correctly with the deleted parameter.
+func TestDecodeBranchInto_DeletedFlag(t *testing.T) {
+ t.Parallel()
+ row, bm := generateCellRow(t, 16)
+
+ be := NewBranchEncoder(1024)
+ cellData := generateCellEncodeDataRow(t, row, bm)
+ enc, err := be.EncodeBranch(bm, bm, bm, &cellData)
+ require.NoError(t, err)
+ branchData := []byte(enc)[2:]
+
+ var cells [16]cell
+ maps, err := DecodeBranchInto(branchData, true, &cells)
+ require.NoError(t, err)
+ require.Equal(t, bm, maps.Bitmap)
+ require.Equal(t, bm, maps.TouchMap, "deleted=true → touchMap = bitmap")
+ require.Equal(t, uint16(0), maps.AfterMap, "deleted=true → afterMap = 0")
+}
+
+// TestDecodeBranchInto_TruncatedInput asserts the decoder fails cleanly on
+// truncated branch data instead of panicking.
+func TestDecodeBranchInto_TruncatedInput(t *testing.T) {
+ t.Parallel()
+ var cells [16]cell
+
+ // Empty data — should fail at bitmap read.
+ _, err := DecodeBranchInto(nil, false, &cells)
+ require.Error(t, err)
+
+ // Just bitmap, no cells — should be fine if bitmap is 0.
+ _, err = DecodeBranchInto([]byte{0x00, 0x00}, false, &cells)
+ require.NoError(t, err, "bitmap=0 with no cell data should decode cleanly")
+
+ // Bitmap claims one cell but data missing — should fail.
+ _, err = DecodeBranchInto([]byte{0x00, 0x01}, false, &cells)
+ require.Error(t, err, "bitmap with set bit but no cell data should error")
+}
diff --git a/execution/commitment/commitment.go b/execution/commitment/commitment.go
index f0be8ccc4b1..a6293d03dcb 100644
--- a/execution/commitment/commitment.go
+++ b/execution/commitment/commitment.go
@@ -100,8 +100,10 @@ type Trie interface {
SetCapture(capture []string)
GetCapture(truncate bool) []string
EnableCsvMetrics(filePathPrefix string)
- // EnableWarmupCache enables/disables warmup cache during Process (false by default)
- EnableWarmupCache(bool)
+ // SetBranchCache attaches the shared cache used by trie + branchEncoder.
+ // ConcurrentPatriciaHashed propagates the same instance to all mounts —
+ // see branch_cache.go for the concurrency contract.
+ SetBranchCache(*BranchCache)
// Variant returns commitment trie variant
Variant() TrieVariant
@@ -150,6 +152,9 @@ const (
VariantConcurrentHexPatricia TrieVariant = "hex-concurrent-patricia-hashed"
)
+// InitializeTrieAndUpdates constructs the trie + updates buffer from cfg. The
+// aggregator-scope BranchCache is attached separately via Trie.SetBranchCache
+// by the caller (wired from SharedDomains via the BranchCacheProvider lookup).
func InitializeTrieAndUpdates(mode Mode, tmpdir string, cfg TrieConfig) (Trie, *Updates) {
switch cfg.Variant {
case VariantConcurrentHexPatricia:
@@ -350,7 +355,7 @@ type BranchEncoder struct {
maxDeferredUpdates int // flush threshold; 0 = use DefaultMaxDeferredUpdates from config
deferred []*DeferredBranchUpdate
pendingPrefixes *maphash.NonConcurrentMap[struct{}] // tracks pending prefixes to detect duplicates
- cache *WarmupCache
+ branchCache *BranchCache // set via HexPatriciaHashed.SetBranchCache (cross-block, aggregator-scope)
}
func NewBranchEncoder(sz uint64) *BranchEncoder {
@@ -557,10 +562,6 @@ func (be *BranchEncoder) setMetrics(metrics *Metrics) {
be.metrics = metrics
}
-func (be *BranchEncoder) SetCache(cache *WarmupCache) {
- be.cache = cache
-}
-
func (be *BranchEncoder) CollectUpdate(
ctx PatriciaContext,
prefix []byte,
@@ -568,20 +569,18 @@ func (be *BranchEncoder) CollectUpdate(
cells *[16]cellEncodeData,
) error {
var prev []byte
- var foundInCache bool
var err error
- if be.cache != nil {
- prev, foundInCache = be.cache.GetAndEvictBranch(prefix)
- if foundInCache && be.metrics != nil {
- be.metrics.cacheBranch.Add(1)
- }
+ // MarkDirty BEFORE encode so any concurrent PutIfClean for this prefix
+ // skips — preventing a stale read from overwriting our canonical write.
+ // See branch_cache.go's Concurrency Contract.
+ if be.branchCache != nil {
+ be.branchCache.MarkDirty(prefix)
}
- if !foundInCache {
- prev, _, err = ctx.Branch(prefix)
- if err != nil {
- return err
- }
+
+ prev, _, err = ctx.Branch(prefix)
+ if err != nil {
+ return err
}
update, err := be.EncodeBranch(bitmap, touchMap, afterMap, cells)
@@ -604,9 +603,9 @@ func (be *BranchEncoder) CollectUpdate(
if err = ctx.PutBranch(prefixCopy, updateCopy, prev); err != nil {
return err
}
- if be.cache != nil {
- be.cache.PutBranch(prefixCopy, updateCopy)
- }
+ // No cache Put here: ctx.PutBranch writes to sd.mem which masks this
+ // prefix for the rest of the block; BranchCache is invalidated and
+ // repopulated at SD.Flush.
if be.metrics != nil {
be.metrics.updateBranch.Add(1)
}
@@ -642,22 +641,14 @@ func (be *BranchEncoder) CollectDeferredUpdate(
be.ClearDeferred()
}
- // try to get previous data from cache
- var (
- prev []byte
- foundInCache bool
- err error
- )
-
- if be.cache != nil {
- prev, foundInCache = be.cache.GetAndEvictBranch(prefix)
- if foundInCache && be.metrics != nil {
- be.metrics.cacheBranch.Add(1)
- }
- }
- if !foundInCache {
- prev, _, err = ctx.Branch(prefix)
+ // MarkDirty as in CollectUpdate — the deferred write happens later via
+ // ApplyDeferredBranchUpdates but the cache must skip concurrent
+ // PutIfClean writers in the interim.
+ if be.branchCache != nil {
+ be.branchCache.MarkDirty(prefix)
}
+
+ prev, _, err := ctx.Branch(prefix)
if err != nil {
return err
}
@@ -1820,9 +1811,6 @@ func (t *Updates) HashSort(ctx context.Context, warmuper *Warmuper, fn func(hk,
var prevKey []byte
err := t.etl.Load(nil, "", func(k, v []byte, table etl.CurrentTableReader, next etl.LoadNextFunc) error {
- if warmuper != nil && warmuper.Cache() != nil {
- warmuper.Cache().EvictPlainKey(v)
- }
// Copy into arena since ETL may reuse buffers
hk := t.arenaAlloc(k)
pk := t.arenaAlloc(v)
diff --git a/execution/commitment/commitmentdb/commitment_context.go b/execution/commitment/commitmentdb/commitment_context.go
index ed8f931f2f7..ea64b9e2270 100644
--- a/execution/commitment/commitmentdb/commitment_context.go
+++ b/execution/commitment/commitmentdb/commitment_context.go
@@ -21,6 +21,7 @@ import (
"github.com/erigontech/erigon/db/etl"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/rawdbv3"
+ "github.com/erigontech/erigon/db/state/changeset"
"github.com/erigontech/erigon/diagnostics/metrics"
"github.com/erigontech/erigon/execution/commitment"
"github.com/erigontech/erigon/execution/commitment/nibbles"
@@ -38,9 +39,18 @@ type sd interface {
SetTxNum(blockNum uint64)
AsGetter(tx kv.TemporalTx) kv.TemporalGetter
AsPutDel(tx kv.TemporalTx) kv.TemporalPutDel
+ // MergeMetrics folds a finished worker's lock-free metrics accumulator
+ // into the shared metrics (once, not per read).
+ MergeMetrics(wm *changeset.DomainMetrics)
StepSize() uint64
Trace() bool
CommitmentCapture() bool
+
+ // ProbeReadLayers samples sd.mem, parent.mem and tx-direct (MDBX) for one
+ // key — BranchCache divergence-detection probe. Read-only.
+ ProbeReadLayers(domain kv.Domain, tx kv.TemporalTx, key []byte) (mem, parentMem, mdbx []byte, memOk, parentOk bool)
+
+ Metrics() *changeset.DomainMetrics
}
type SharedDomainsCommitmentContext struct {
@@ -170,20 +180,14 @@ func (sdc *SharedDomainsCommitmentContext) SetUpdates(updates *commitment.Update
sdc.updates = updates
}
-// EnableWarmupCache enables/disables warmup cache during commitment processing.
-func (sdc *SharedDomainsCommitmentContext) EnableWarmupCache(enable bool) {
- sdc.patriciaTrie.EnableWarmupCache(enable)
-}
-
-// ClearWarmupCache discards any stale account/storage values held in the active
-// warmup cache. Safe to call at block boundaries between ComputeCommitment calls.
-func (sdc *SharedDomainsCommitmentContext) ClearWarmupCache() {
- if hph, ok := sdc.patriciaTrie.(*commitment.HexPatriciaHashed); ok && hph.Cache() != nil {
- hph.Cache().Clear()
- }
+func (sdc *SharedDomainsCommitmentContext) EnableCsvMetrics(filePathPrefix string) {
+ sdc.patriciaTrie.EnableCsvMetrics(filePathPrefix)
}
-func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir string, cfg commitment.TrieConfig) *SharedDomainsCommitmentContext {
+// NewSharedDomainsCommitmentContext: cfg carries the trie variant + warmup
+// settings; branchCache is the aggregator-scope cross-block branch cache,
+// attached to the trie via SetBranchCache (nil = no cross-block caching).
+func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir string, cfg commitment.TrieConfig, branchCache *commitment.BranchCache) *SharedDomainsCommitmentContext {
ctx := &SharedDomainsCommitmentContext{
sharedDomains: sd,
tmpDir: tmpDir,
@@ -193,21 +197,28 @@ func NewSharedDomainsCommitmentContext(sd sd, mode commitment.Mode, tmpDir strin
},
}
ctx.patriciaTrie, ctx.updates = commitment.InitializeTrieAndUpdates(mode, tmpDir, cfg)
+ ctx.patriciaTrie.SetBranchCache(branchCache)
return ctx
}
-func (sdc *SharedDomainsCommitmentContext) trieContext(tx kv.TemporalTx, blockNum, txNum uint64) *TrieContext {
+// trieContext builds the main (root-fold) trie read context. readCtx carries
+// the per-ComputeCommitment lock-free metrics accumulator (nil-value => no
+// metrics); the main fold is single-goroutine so it owns that accumulator
+// exclusively. Warmup/concurrent-mount readers get their own via the factories.
+func (sdc *SharedDomainsCommitmentContext) trieContext(tx kv.TemporalTx, blockNum, txNum uint64, readCtx context.Context) *TrieContext {
mainTtx := &TrieContext{
getter: sdc.sharedDomains.AsGetter(tx),
putter: sdc.sharedDomains.AsPutDel(tx),
stepSize: sdc.sharedDomains.StepSize(),
txNum: txNum,
blockNum: blockNum,
+ probeSd: sdc.sharedDomains,
+ probeTx: tx,
}
if sdc.stateReader != nil {
- mainTtx.stateReader = sdc.stateReader.Clone(tx)
+ mainTtx.stateReader = sdc.stateReader.CloneForWorker(readCtx, tx)
} else {
- mainTtx.stateReader = NewLatestStateReader(tx, sdc.sharedDomains)
+ mainTtx.stateReader = NewLatestStateReaderForWorker(readCtx, tx, sdc.sharedDomains)
}
sdc.patriciaTrie.ResetContext(mainTtx)
return mainTtx
@@ -343,7 +354,15 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context
}
}
- trieContext := sdc.trieContext(tx, blockNum, txNum)
+ // Per-ComputeCommitment metrics accumulator for the main (root-fold) reads.
+ // The fold is single-goroutine, so this lock-free accumulator is owned
+ // exclusively here; merged into the shared metrics when commitment finishes.
+ // Warmup/concurrent-mount workers accumulate + merge independently.
+ commitMetrics := changeset.NewDomainMetrics()
+ defer sdc.sharedDomains.MergeMetrics(commitMetrics)
+ readCtx := changeset.ContextWithMetrics(ctx, commitMetrics)
+
+ trieContext := sdc.trieContext(tx, blockNum, txNum, readCtx)
// If trie trace is configured, wrap the context with a recorder.
// Block-targeted: when TrieTraceBlock is set, only record that specific block.
@@ -410,13 +429,6 @@ func (sdc *SharedDomainsCommitmentContext) ComputeCommitment(ctx context.Context
}()
}
- if recorder != nil {
- // Disable warmup cache during recording — cache hits bypass the
- // RecordingContext, producing incomplete traces for replay.
- sdc.patriciaTrie.EnableWarmupCache(false)
- defer sdc.patriciaTrie.EnableWarmupCache(true)
- }
-
var warmupConfig commitment.WarmupConfig
var drainCollectors func() []*etl.Collector
if sdc.paraTrieDB != nil {
@@ -501,6 +513,14 @@ func (sdc *SharedDomainsCommitmentContext) trieContextFactory(ctx context.Contex
if err != nil {
return &errorTrieContext{err: err}, func() {}
}
+ // Warmup workers run concurrently with the main commitment goroutine.
+ // Each gets its own lock-free metrics accumulator carried in a per-worker
+ // context, so reads neither share metrics state with the main goroutine
+ // (a data race) nor take the global metrics write lock (the prior hard
+ // serialization point). The accumulator is folded into the shared metrics
+ // once at teardown.
+ wm := changeset.NewDomainMetrics()
+ workerCtx := changeset.ContextWithMetrics(ctx, wm)
warmupCtx := &TrieContext{
getter: sdc.sharedDomains.AsGetter(roTx),
putter: sdc.sharedDomains.AsPutDel(roTx),
@@ -508,11 +528,12 @@ func (sdc *SharedDomainsCommitmentContext) trieContextFactory(ctx context.Contex
txNum: txNum,
}
if sdc.stateReader != nil {
- warmupCtx.stateReader = sdc.stateReader.Clone(roTx)
+ warmupCtx.stateReader = sdc.stateReader.CloneForWorker(workerCtx, roTx)
} else {
- warmupCtx.stateReader = NewLatestStateReader(roTx, sdc.sharedDomains)
+ warmupCtx.stateReader = NewLatestStateReaderForWorker(workerCtx, roTx, sdc.sharedDomains)
}
cleanup := func() {
+ sdc.sharedDomains.MergeMetrics(wm)
roTx.Rollback()
}
return warmupCtx, cleanup
@@ -540,6 +561,11 @@ func (sdc *SharedDomainsCommitmentContext) concurrentTrieContextFactory(ctx cont
collectors = append(collectors, collector)
mu.Unlock()
+ // Concurrent mounts run in parallel; each gets its own lock-free metrics
+ // accumulator via a per-worker context so they don't share metrics state
+ // (a race) or take the global metrics lock. Folded in at teardown.
+ wm := changeset.NewDomainMetrics()
+ workerCtx := changeset.ContextWithMetrics(ctx, wm)
warmupCtx := &TrieContext{
getter: sdc.sharedDomains.AsGetter(roTx),
putter: sdc.sharedDomains.AsPutDel(roTx),
@@ -548,11 +574,12 @@ func (sdc *SharedDomainsCommitmentContext) concurrentTrieContextFactory(ctx cont
localCollector: collector,
}
if sdc.stateReader != nil {
- warmupCtx.stateReader = sdc.stateReader.Clone(roTx)
+ warmupCtx.stateReader = sdc.stateReader.CloneForWorker(workerCtx, roTx)
} else {
- warmupCtx.stateReader = NewLatestStateReader(roTx, sdc.sharedDomains)
+ warmupCtx.stateReader = NewLatestStateReaderForWorker(workerCtx, roTx, sdc.sharedDomains)
}
cleanup := func() {
+ sdc.sharedDomains.MergeMetrics(wm)
roTx.Rollback()
}
return warmupCtx, cleanup
@@ -591,10 +618,9 @@ func (e *errorTrieContext) Storage(plainKey []byte) (*commitment.Update, error)
return nil, e.err
}
-// by that key stored latest root hash and tree state
-const keyCommitmentStateS = "state"
-
-var KeyCommitmentState = []byte(keyCommitmentStateS)
+// KeyCommitmentState aliases commitment.KeyCommitmentState — single source of
+// truth so BranchCache can exclude it by construction.
+var KeyCommitmentState = commitment.KeyCommitmentState
var ErrBehindCommitment = errors.New("behind commitment")
@@ -642,7 +668,7 @@ func (sdc *SharedDomainsCommitmentContext) enableConcurrentCommitmentIfPossible(
// SeekCommitment searches for last encoded state from DomainCommitted
// and if state found, sets it up to current domain
func (sdc *SharedDomainsCommitmentContext) SeekCommitment(ctx context.Context, tx kv.TemporalTx) (txNum, blockNum uint64, err error) {
- trieContext := sdc.trieContext(tx, 0, 0) // blockNum/txNum not yet known; trieContext only used for reading here
+ trieContext := sdc.trieContext(tx, 0, 0, ctx) // blockNum/txNum not yet known; trieContext only used for reading here
_, _, state, err := sdc.LatestCommitmentState(trieContext)
if err != nil {
@@ -796,6 +822,10 @@ type TrieContext struct {
trace bool
stateReader StateReader
localCollector *etl.Collector // per-goroutine collector for concurrent PutBranch
+
+ // Diagnostics-only — both nil for read-only / test contexts.
+ probeSd sd
+ probeTx kv.TemporalTx
}
// NewTrieContextRo creates a read-only TrieContext suitable for TrieReader lookups.
@@ -817,6 +847,22 @@ func (sdc *TrieContext) Branch(pref []byte) ([]byte, kv.Step, error) {
return common.Copy(enc), step, nil
}
+// ProbeStateLayers samples sd.mem, parent.mem and tx-direct (MDBX) for one
+// key — divergence diagnostics. Returns empty / not-ok when constructed
+// without a probe-capable SharedDomains (e.g. NewTrieContextRo).
+func (sdc *TrieContext) ProbeStateLayers(domain kv.Domain, key []byte) (mem, parentMem, mdbx []byte, memOk, parentOk bool) {
+ if sdc.probeSd == nil {
+ return
+ }
+ return sdc.probeSd.ProbeReadLayers(domain, sdc.probeTx, key)
+}
+
+// SiteIdentity tags cache entries with the SD lineage that produced them so
+// divergence diagnostics can tell parent-SD writes from fork-SD writes.
+func (sdc *TrieContext) SiteIdentity() string {
+ return fmt.Sprintf("sd=%p", sdc.probeSd)
+}
+
func (sdc *TrieContext) PutBranch(prefix []byte, data []byte, prevData []byte) error {
if sdc.stateReader.WithHistory() { // do not store branches if explicitly operate on history
return nil
diff --git a/execution/commitment/commitmentdb/commitment_context_test.go b/execution/commitment/commitmentdb/commitment_context_test.go
index 868443018e1..11d04181f11 100644
--- a/execution/commitment/commitmentdb/commitment_context_test.go
+++ b/execution/commitment/commitmentdb/commitment_context_test.go
@@ -1,6 +1,7 @@
package commitmentdb
import (
+ "context"
"math/rand"
"testing"
@@ -55,6 +56,8 @@ func (r *testStateReader) Read(d kv.Domain, key []byte, stepSize uint64) ([]byte
func (r *testStateReader) Clone(kv.TemporalTx) StateReader { return r }
+func (r *testStateReader) CloneForWorker(context.Context, kv.TemporalTx) StateReader { return r }
+
func Test_TrieContext_BranchCopiesData(t *testing.T) {
t.Parallel()
diff --git a/execution/commitment/commitmentdb/reader.go b/execution/commitment/commitmentdb/reader.go
index d2950323f53..fc9500c424d 100644
--- a/execution/commitment/commitmentdb/reader.go
+++ b/execution/commitment/commitmentdb/reader.go
@@ -1,6 +1,7 @@
package commitmentdb
import (
+ "context"
"fmt"
"github.com/erigontech/erigon/db/kv"
@@ -11,15 +12,41 @@ type StateReader interface {
CheckDataAvailable(d kv.Domain, step kv.Step) error
Read(d kv.Domain, plainKey []byte, stepSize uint64) (enc []byte, step kv.Step, err error)
Clone(tx kv.TemporalTx) StateReader
+ // CloneForWorker clones the reader for a concurrent worker (trie-warmup /
+ // concurrent-commitment mount). Behaves like Clone except reads are metered
+ // into the per-worker accumulator carried by workerCtx, so concurrent
+ // workers never touch the main goroutine's lock-free accumulator (a race)
+ // or take the global metrics lock.
+ CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) StateReader
+}
+
+// ctxGetter is the optional context-aware read method (see
+// temporalGetter.GetLatestContext). Worker readers type-assert for it so reads
+// meter into the per-worker accumulator carried by the worker context.
+type ctxGetter interface {
+ GetLatestContext(ctx context.Context, name kv.Domain, k []byte) (v []byte, step kv.Step, err error)
}
type LatestStateReader struct {
sharedDomains sd
getter kv.TemporalGetter
+ srcTx kv.TemporalTx
+ // workerCtx, when non-nil, carries this worker's lock-free metrics
+ // accumulator; reads route through getter.GetLatestContext(workerCtx, …) so
+ // concurrent workers don't share the main accumulator. Nil on the main
+ // reader, which meters into sd.mainWM via the plain GetLatest.
+ workerCtx context.Context
}
func NewLatestStateReader(tx kv.TemporalTx, sd sd) *LatestStateReader {
- return &LatestStateReader{sharedDomains: sd, getter: sd.AsGetter(tx)}
+ return &LatestStateReader{sharedDomains: sd, getter: sd.AsGetter(tx), srcTx: tx}
+}
+
+// NewLatestStateReaderForWorker is like NewLatestStateReader but reads meter
+// into the per-worker accumulator carried by workerCtx (for concurrent
+// workers). See LatestStateReader.workerCtx.
+func NewLatestStateReaderForWorker(workerCtx context.Context, tx kv.TemporalTx, sd sd) *LatestStateReader {
+ return &LatestStateReader{sharedDomains: sd, getter: sd.AsGetter(tx), srcTx: tx, workerCtx: workerCtx}
}
func (r *LatestStateReader) WithHistory() bool {
@@ -35,6 +62,15 @@ func (r *LatestStateReader) CheckDataAvailable(d kv.Domain, step kv.Step) error
}
func (r *LatestStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64) (enc []byte, step kv.Step, err error) {
+ if r.workerCtx != nil {
+ if cg, ok := r.getter.(ctxGetter); ok {
+ enc, step, err = cg.GetLatestContext(r.workerCtx, d, plainKey)
+ if err != nil {
+ return nil, 0, fmt.Errorf("LatestStateReader(GetLatestContext) %q: %w", d, err)
+ }
+ return enc, step, nil
+ }
+ }
enc, step, err = r.getter.GetLatest(d, plainKey)
if err != nil {
return nil, 0, fmt.Errorf("LatestStateReader(GetLatest) %q: %w", d, err)
@@ -43,7 +79,20 @@ func (r *LatestStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64)
}
func (r *LatestStateReader) Clone(tx kv.TemporalTx) StateReader {
- return NewLatestStateReader(tx, r.sharedDomains)
+ // Keep reading the source this reader was bound to. The tx passed by
+ // clone/warmup callers targets the *compute* database, which may differ
+ // from this reader's source — e.g. recomputing commitment in an empty db
+ // while reading committed state from the source db (TouchChangedKeysFromHistory).
+ // Before flush drained sd.mem this was masked because the in-memory batch
+ // still held the source values; rebinding sd.AsGetter to the foreign compute
+ // tx reads the wrong database and yields empty state (wrong root).
+ return &LatestStateReader{sharedDomains: r.sharedDomains, getter: r.sharedDomains.AsGetter(r.srcTx), srcTx: r.srcTx, workerCtx: r.workerCtx}
+}
+
+// CloneForWorker clones into a worker reader that meters into workerCtx's
+// per-worker accumulator. Source tx preserved, same as Clone.
+func (r *LatestStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) StateReader {
+ return NewLatestStateReaderForWorker(workerCtx, r.srcTx, r.sharedDomains)
}
// HistoryStateReader reads *full* historical state at specified txNum.
@@ -80,6 +129,12 @@ func (r *HistoryStateReader) Clone(tx kv.TemporalTx) StateReader {
return NewHistoryStateReader(tx, r.limitReadAsOfTxNum)
}
+// CloneForWorker: history reads go straight to roTx.GetAsOf (no shared metrics
+// accumulator), so it's identical to Clone.
+func (r *HistoryStateReader) CloneForWorker(_ context.Context, tx kv.TemporalTx) StateReader {
+ return NewHistoryStateReader(tx, r.limitReadAsOfTxNum)
+}
+
// FilesOnlyStateReader reads from .kv files only, capped at limitTxNum.
// On miss (key not present in any frozen .kv file ≤ limitTxNum), returns nil
// without any fallback. This is the right semantic for integrity checks and
@@ -115,6 +170,12 @@ func (r *FilesOnlyStateReader) Clone(tx kv.TemporalTx) StateReader {
return NewFilesOnlyStateReader(tx, r.limitTxNum)
}
+// CloneForWorker: files-only reads go straight to the tx (no shared metrics
+// accumulator), so it's identical to Clone.
+func (r *FilesOnlyStateReader) CloneForWorker(_ context.Context, tx kv.TemporalTx) StateReader {
+ return NewFilesOnlyStateReader(tx, r.limitTxNum)
+}
+
// SplitStateReader implements commitmentdb.StateReader using (potentially) different state readers for commitment
// data and account/storage/code data.
type SplitStateReader struct {
@@ -155,6 +216,13 @@ func (r *SplitStateReader) Clone(tx kv.TemporalTx) StateReader {
return NewCommitmentSplitStateReader(r.commitmentReader.Clone(tx), r.plainStateReader.Clone(tx), r.withHistory)
}
+// CloneForWorker propagates the worker clone to sub-readers so an embedded
+// LatestStateReader (the commitment reader) meters into the per-worker
+// accumulator instead of the shared one.
+func (r *SplitStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) StateReader {
+ return NewCommitmentSplitStateReader(r.commitmentReader.CloneForWorker(workerCtx, tx), r.plainStateReader.CloneForWorker(workerCtx, tx), r.withHistory)
+}
+
func NewCommitmentSplitStateReader(commitmentReader StateReader, plainStateReader StateReader, withHistory bool) *SplitStateReader {
return &SplitStateReader{
commitmentReader: commitmentReader,
@@ -189,6 +257,18 @@ func (crsr *CommitmentReplayStateReader) Clone(tx kv.TemporalTx) StateReader {
}
}
+// CloneForWorker mirrors Clone but meters the commitment (Latest) reader into
+// the per-worker accumulator carried by workerCtx.
+func (crsr *CommitmentReplayStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) StateReader {
+ return &CommitmentReplayStateReader{
+ SplitStateReader: NewCommitmentSplitStateReader(
+ crsr.commitmentReader.CloneForWorker(workerCtx, tx),
+ crsr.plainStateReader,
+ false,
+ ),
+ }
+}
+
// RebuildStateReader creates a StateReader for building commitment from scratch, block-by-block.
// Commitment is read from SharedDomains' in-memory batch (LatestStateReader) because we are generating
// it incrementally - prior commitment state lives in the MemBatch, not yet on disk.
@@ -233,3 +313,14 @@ func (r *RebuildStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64)
func (r *RebuildStateReader) Clone(tx kv.TemporalTx) StateReader {
return NewRebuildStateReader(tx, r.sd, r.plainStateAsOf)
}
+
+// CloneForWorker mirrors Clone but the commitment (Latest) reader meters into
+// the per-worker accumulator carried by workerCtx.
+func (r *RebuildStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) StateReader {
+ return &RebuildStateReader{
+ commitmentReader: NewLatestStateReaderForWorker(workerCtx, tx, r.sd),
+ plainStateReader: NewHistoryStateReader(tx, r.plainStateAsOf),
+ plainStateAsOf: r.plainStateAsOf,
+ sd: r.sd,
+ }
+}
diff --git a/execution/commitment/config_test.go b/execution/commitment/config_test.go
index b411c53d47b..20dd95fd5c4 100644
--- a/execution/commitment/config_test.go
+++ b/execution/commitment/config_test.go
@@ -103,9 +103,6 @@ func TestTrieConfig_PropagationToHPH(t *testing.T) {
if !hph.leaveDeferredForCaller {
t.Error("leaveDeferredForCaller should be true")
}
- if !hph.enableWarmupCache {
- t.Error("enableWarmupCache should be true")
- }
if !hph.memoizationOff {
t.Error("memoizationOff should be true")
}
@@ -163,15 +160,4 @@ func TestTrieConfig_ConcurrentPatriciaHashedPropagation(t *testing.T) {
t.Errorf("mount[%d] should inherit MemoizationOff=true", i)
}
}
-
- // Runtime EnableWarmupCache should propagate to root and all mounts
- cph.EnableWarmupCache(true)
- if !cph.root.cfg.EnableWarmupCache {
- t.Error("EnableWarmupCache should update root config")
- }
- for i, mount := range cph.mounts {
- if !mount.cfg.EnableWarmupCache {
- t.Errorf("mount[%d] cfg.EnableWarmupCache should be true after EnableWarmupCache(true)", i)
- }
- }
}
diff --git a/execution/commitment/hex_concurrent_patricia_hashed.go b/execution/commitment/hex_concurrent_patricia_hashed.go
index 1768923ff48..83f8f5cf3de 100644
--- a/execution/commitment/hex_concurrent_patricia_hashed.go
+++ b/execution/commitment/hex_concurrent_patricia_hashed.go
@@ -171,10 +171,16 @@ func (p *ConcurrentPatriciaHashed) SetTraceDomain(b bool) {
p.mounts[i].SetTraceDomain(b)
}
}
-func (p *ConcurrentPatriciaHashed) EnableWarmupCache(b bool) {
- p.root.EnableWarmupCache(b)
+
+// SetBranchCache attaches the same BranchCache instance to the root trie
+// and all 16 mounts. Sharing one cache across mounts is correct under the
+// concurrency contract (branch_cache.go): mounts partition the prefix
+// space by first nibble, so cross-mount writes always target distinct
+// cache keys.
+func (p *ConcurrentPatriciaHashed) SetBranchCache(c *BranchCache) {
+ p.root.SetBranchCache(c)
for i := range p.mounts {
- p.mounts[i].EnableWarmupCache(b)
+ p.mounts[i].SetBranchCache(c)
}
}
func (p *ConcurrentPatriciaHashed) GetCapture(truncate bool) []string {
diff --git a/execution/commitment/hex_patricia_hashed.go b/execution/commitment/hex_patricia_hashed.go
index 8da75bc089d..abe9b1e4420 100644
--- a/execution/commitment/hex_patricia_hashed.go
+++ b/execution/commitment/hex_patricia_hashed.go
@@ -99,9 +99,11 @@ type HexPatriciaHashed struct {
//temp buffers
accValBuf rlp.RlpEncodedBytes
- // Warmup cache for serving reads from pre-warmed data
- cache *WarmupCache
- enableWarmupCache bool // if true, enables warmup cache during Process (false by default)
+ // branchCache is the BranchCache instance attached via SetBranchCache.
+ // Production wires the aggregator-scope instance through
+ // InitializeTrieAndUpdates; tests/benchmarks may pass a per-init
+ // fallback. See branch_cache.go's concurrency contract.
+ branchCache *BranchCache
// leaveDeferredForCaller when true, Process() leaves deferred updates on the branchEncoder
// for the caller to handle via TakeDeferredUpdates(). When false (default), Process()
@@ -150,7 +152,6 @@ func (hph *HexPatriciaHashed) applyConfig(cfg TrieConfig) {
hph.branchEncoder.setDeferUpdates(cfg.DeferBranchUpdates)
hph.branchEncoder.maxDeferredUpdates = DefaultMaxDeferredUpdates
hph.leaveDeferredForCaller = cfg.LeaveDeferredForCaller
- hph.enableWarmupCache = cfg.EnableWarmupCache
hph.memoizationOff = cfg.MemoizationOff
hph.metrics.SetCsvMetrics(cfg.CsvMetricsFilePrefix)
}
@@ -205,10 +206,6 @@ func (hph *HexPatriciaHashed) resetForReuse() {
hph.mounted = false
hph.mountedNib = 0
- // warmup cache
- hph.cache = nil
- hph.enableWarmupCache = false
-
// tracing / capture
hph.capture = nil
hph.trace = false
@@ -222,10 +219,9 @@ func (hph *HexPatriciaHashed) resetForReuse() {
// auxiliary buffer
hph.auxBuffer.Reset()
- // branch encoder: clear deferred updates, reset buffer, nil cache
+ // branch encoder: clear deferred updates, reset buffer
hph.branchEncoder.ClearDeferred()
hph.branchEncoder.buf.Reset()
- hph.branchEncoder.cache = nil
hph.branchEncoder.setDeferUpdates(false) // will be re-set by applyConfig
// reset config to zero — caller sets via applyConfig after pool get
@@ -597,117 +593,6 @@ func skipCellFields(data []byte, pos int, fieldBits byte) int {
return pos
}
-// extractBranchCellAddresses extracts account/storage addresses from branch data.
-// pathNibble is the nibble on the update path (-1 if none) - its addresses are always
-// extracted since that cell will have stateHash cleared during update.
-// Sibling cells with stateHash (memoized) are skipped.
-func extractBranchCellAddresses(branchData []byte, pathNibble int) (accountAddrs [][]byte, storageAddrs [][]byte) {
- if len(branchData) < 4 {
- return nil, nil
- }
- // Skip touch map (first 2 bytes)
- branchData = branchData[2:]
- bitmap := binary.BigEndian.Uint16(branchData[0:2])
- pos := 2
-
- // Iterate over all cells in the branch
- for bitset := bitmap; bitset != 0; {
- bit := bitset & -bitset
- nibble := bits.TrailingZeros16(bit)
- bitset ^= bit
-
- if pos >= len(branchData) {
- break
- }
- fieldBits := branchData[pos]
- pos++
-
- // Cell on update path will have stateHash cleared - always extract its addresses.
- // Sibling cells with stateHash (bit 4) are memoized - skip them.
- isOnPath := nibble == pathNibble
- hasMemoizedHash := fieldBits&16 != 0
- shouldExtract := isOnPath || !hasMemoizedHash
-
- // Parse each field
- // extension (bit 0)
- if fieldBits&1 != 0 {
- if pos >= len(branchData) {
- break
- }
- l, n := binary.Uvarint(branchData[pos:])
- if n <= 0 {
- break
- }
- pos += n + int(l)
- }
-
- // accountAddr (bit 1)
- if fieldBits&2 != 0 {
- if pos >= len(branchData) {
- break
- }
- l, n := binary.Uvarint(branchData[pos:])
- if n <= 0 {
- break
- }
- pos += n
- if l > 0 && pos+int(l) <= len(branchData) {
- if shouldExtract {
- addr := make([]byte, l)
- copy(addr, branchData[pos:pos+int(l)])
- accountAddrs = append(accountAddrs, addr)
- }
- pos += int(l)
- }
- }
-
- // storageAddr (bit 2)
- if fieldBits&4 != 0 {
- if pos >= len(branchData) {
- break
- }
- l, n := binary.Uvarint(branchData[pos:])
- if n <= 0 {
- break
- }
- pos += n
- if l > 0 && pos+int(l) <= len(branchData) {
- if shouldExtract {
- addr := make([]byte, l)
- copy(addr, branchData[pos:pos+int(l)])
- storageAddrs = append(storageAddrs, addr)
- }
- pos += int(l)
- }
- }
-
- // hash (bit 3)
- if fieldBits&8 != 0 {
- if pos >= len(branchData) {
- break
- }
- l, n := binary.Uvarint(branchData[pos:])
- if n <= 0 {
- break
- }
- pos += n + int(l)
- }
-
- // stateHash (bit 4)
- if fieldBits&16 != 0 {
- if pos >= len(branchData) {
- break
- }
- l, n := binary.Uvarint(branchData[pos:])
- if n <= 0 {
- break
- }
- pos += n + int(l)
- }
- }
- return accountAddrs, storageAddrs
-}
-
func (cell *cell) accountForHashing(buffer []byte, storageRootHash common.Hash) int {
balanceBytes := 0
if !cell.Balance.LtUint64(128) {
@@ -1015,6 +900,7 @@ func (hph *HexPatriciaHashed) witnessComputeCellHashWithStorage(cell *cell, dept
} else {
if !cell.loaded.storage() {
hph.metrics.StorageLoad(cell.storageAddr[:cell.storageAddrLen])
+ diskLoadStorage.Add(1)
update, err := hph.storageFromCacheOrDB(cell.storageAddr[:cell.storageAddrLen])
if err != nil {
return nil, storageRootHashIsSet, nil, err
@@ -1101,6 +987,7 @@ func (hph *HexPatriciaHashed) witnessComputeCellHashWithStorage(cell *cell, dept
}
// storage root update or extension update could invalidate older stateHash, so we need to reload state
hph.metrics.AccountLoad(cell.accountAddr[:cell.accountAddrLen])
+ diskLoadAccount.Add(1)
update, err := hph.accountFromCacheOrDB(cell.accountAddr[:cell.accountAddrLen])
if err != nil {
return nil, storageRootHashIsSet, storageRootHash[:], err
@@ -1255,6 +1142,7 @@ func (hph *HexPatriciaHashed) computeCellHash(cell *cell, depth int16, buf []byt
}
// storage root update or extension update could invalidate older stateHash, so we need to reload state
hph.metrics.AccountLoad(cell.accountAddr[:cell.accountAddrLen])
+ diskLoadAccount.Add(1)
update, err := hph.accountFromCacheOrDB(cell.accountAddr[:cell.accountAddrLen])
if err != nil {
return nil, err
@@ -1763,28 +1651,19 @@ func (hph *HexPatriciaHashed) unfoldBranchNode(row int, depth int16, deleted boo
return fmt.Errorf("empty branch data read during unfold, compact prefix %x nibbles %x", key, hph.currentKey[:hph.currentKeyLen])
}
hph.branchBefore[row] = true
- bitmap := binary.BigEndian.Uint16(branchData[0:])
- pos := 2
- if deleted { // All cells come as deleted (touched but not present after)
- hph.touchMap[row], hph.afterMap[row] = bitmap, 0
- } else {
- hph.touchMap[row], hph.afterMap[row] = 0, bitmap
+ maps, err := DecodeBranchInto(branchData, deleted, &hph.grid[row])
+ if err != nil {
+ return fmt.Errorf("prefix [%x] branchData[%x]: %w", hph.currentKey[:hph.currentKeyLen], branchData, err)
}
- //fmt.Printf("unfoldBranchNode prefix '%x' [%x], afterMap = [%016b], touchMap = [%016b]\n", key, branchData, hph.afterMap[row], hph.touchMap[row])
- // Loop iterating over the set bits of modMask
- for bitset, j := bitmap, 0; bitset != 0; j++ {
+ hph.touchMap[row] = maps.TouchMap
+ hph.afterMap[row] = maps.AfterMap
+ for bitset := maps.Bitmap; bitset != 0; {
bit := bitset & -bitset
nibble := bits.TrailingZeros16(bit)
cell := &hph.grid[row][nibble]
- fieldBits := branchData[pos]
- pos++
- if pos, err = cell.fillFromFields(branchData, pos, cellFields(fieldBits)); err != nil {
- return fmt.Errorf("prefix [%x] branchData[%x]: %w", hph.currentKey[:hph.currentKeyLen], branchData, err)
- }
if hph.trace {
fmt.Printf("cell (%d, %x, depth=%d) %s\n", row, nibble, depth, cell.FullString())
}
-
// relies on plain account/storage key so need to be dereferenced before hashing
if err = cell.deriveHashedKeys(depth, hph.keccak, hph.accountKeyLen, hph.cellHashBuf[:]); err != nil {
return err
@@ -1874,11 +1753,88 @@ func (hph *HexPatriciaHashed) needFolding(hashedKey []byte) bool {
}
var (
- hadToLoad atomic.Uint64
- skippedLoad atomic.Uint64
- hadToReset atomic.Uint64
+ hadToLoad atomic.Uint64
+ skippedLoad atomic.Uint64
+ hadToReset atomic.Uint64
+ diskLoadStorage atomic.Uint64
+ diskLoadAccount atomic.Uint64
+ // hasStorageMiss tracks IBS.HasStorage calls that fell through to
+ // stateReader.HasStorage (i.e. could not be answered from in-memory
+ // state) and therefore hit kv.HasPrefix on the storage domain. On
+ // snapshot-backed storage that scan walks the .bt index, paging the
+ // .bt into RAM. On the original MDBX-only layout the same call was
+ // a cursor seek; the cost equation changed when storage moved to
+ // snapshots and was never re-priced. Used to quantify the cost of
+ // EIP-684 CREATE collision checks on the snapshot layout.
+ hasStorageMiss atomic.Uint64
+
+ // SSTORE classification counters. Hosted here (commitment) rather
+ // than in execution/state because state→commitment is the existing
+ // import direction; the inverse would create a cycle through
+ // db/rawdb/rawtemporaldb. Measurement scaffolding to size the value
+ // of pushing insert/update/delete down to the warmer / trie compute.
+ // INSERT (prev==0, value!=0) — branch path won't exist below the
+ // divergence point.
+ // UPDATE (prev!=0, value!=0, different) — full path exists.
+ // DELETE (prev!=0, value==0) — read-side identical to UPDATE.
+ // NOOP (prev==value) — never reaches the trie.
+ sstoreInsert atomic.Uint64
+ sstoreUpdate atomic.Uint64
+ sstoreDelete atomic.Uint64
+ sstoreNoop atomic.Uint64
)
+// RecordSstoreInsert / Update / Delete / Noop are called from
+// stateObject.SetState to classify each SSTORE for measurement. See the
+// var block above for semantics. Process-cumulative; for per-block
+// deltas, snapshot via SstoreClassificationCounts before/after.
+func RecordSstoreInsert() { sstoreInsert.Add(1) }
+func RecordSstoreUpdate() { sstoreUpdate.Add(1) }
+func RecordSstoreDelete() { sstoreDelete.Add(1) }
+func RecordSstoreNoop() { sstoreNoop.Add(1) }
+
+// SstoreClassificationCounts returns the cumulative process-wide counts
+// of SSTORE classifications (insert, update, delete, noop).
+func SstoreClassificationCounts() (insert, update, delete, noop uint64) {
+ return sstoreInsert.Load(), sstoreUpdate.Load(),
+ sstoreDelete.Load(), sstoreNoop.Load()
+}
+
+// RecordHasStorageMiss is incremented by IntraBlockState.HasStorage when
+// the in-memory checks miss and we fall through to stateReader.HasStorage
+// (the kv.HasPrefix path). See the comment on hasStorageMiss for context.
+func RecordHasStorageMiss() { hasStorageMiss.Add(1) }
+
+// HasStorageMissCount returns the cumulative process-wide count of
+// IBS.HasStorage calls that fell through to a kv.HasPrefix scan.
+func HasStorageMissCount() uint64 { return hasStorageMiss.Load() }
+
+// SkipLoadResetCounters returns the cumulative process-wide counts of:
+// - hadToLoad: computeCellHash had no memoized stateHash and had
+// to fetch the underlying value from cache/DB to compute the leaf
+// hash. NOTE: setFromUpdate increments this on EVERY call, so it
+// conflates trie disk-fetches with EVM-write plumbing into cells.
+// Use diskLoadStorage / diskLoadAccount to distinguish.
+// - skippedLoad: the cell had a memoized stateHash so the fetch was
+// skipped entirely (mxTrieStateSkipRate also counts these).
+// - hadToReset: the cell HAD a memoized stateHash but it was
+// invalidated (extension fold path, depth change). Implies a
+// fresh hash compute and may imply a load.
+// - diskLoadStorage / diskLoadAccount: incremented ONLY at the
+// actual storageFromCacheOrDB / accountFromCacheOrDB call sites
+// inside computeCellHash. This is the unambiguous "trie went to
+// fetch state it didn't have" count, separate from the EVM-write
+// plumbing path that also flows through setFromUpdate.
+//
+// Counters are atomic and process-cumulative. To get per-block
+// deltas, snapshot before/after the block. Used by the perf-equivalence
+// investigation to measure whether memoization is doing its job or
+// whether we're paying for fetches that should have been skipped.
+func SkipLoadResetCounters() (load, skipped, reset, diskStorage, diskAccount uint64) {
+ return hadToLoad.Load(), skippedLoad.Load(), hadToReset.Load(),
+ diskLoadStorage.Load(), diskLoadAccount.Load()
+}
+
type skipStat struct {
accLoaded, accSkipped, accReset, storReset, storLoaded, storSkipped uint64
}
@@ -2234,9 +2190,6 @@ func (hph *HexPatriciaHashed) collectDeleteUpdate(updateKey []byte, row int, evi
if err := hph.branchEncoder.CollectUpdate(hph.ctx, updateKey, 0, hph.touchMap[row], 0, nil); err != nil {
return fmt.Errorf("failed to encode leaf node update: %w", err)
}
- if evictCache && hph.cache != nil {
- hph.cache.EvictBranch(updateKey)
- }
}
return nil
}
@@ -2512,6 +2465,40 @@ func (hph *HexPatriciaHashed) RootHash() ([]byte, error) {
return rootHash[1:], nil // first byte is 128+hash_len=160
}
+// unfoldKeyPath drives hph.unfold in a loop until the trie is unfolded
+// far enough that the cell at hashedKey can be updated — i.e. until
+// needUnfolding returns 0. This is the per-key traversal primitive
+// that follows the fold step in followAndUpdate.
+//
+// Extracted as a primitive so future orchestrators can drive
+// unfold-only traversals (e.g. cache populators that walk a touched-key
+// path to fill cell state without doing a fold/update). HashSort's
+// per-key followAndUpdate is the sequential orchestrator over this
+// primitive today; future concurrent / parallel orchestrators (each
+// with their own HexPatriciaHashed instance) can reuse it.
+//
+// plainKey is used only for per-key metrics labelling (StartUnfolding).
+// Pass an empty/nil slice if no metric attribution is needed.
+func (hph *HexPatriciaHashed) unfoldKeyPath(hashedKey, plainKey []byte) error {
+ for unfolding := hph.needUnfolding(hashedKey); unfolding > 0; unfolding = hph.needUnfolding(hashedKey) {
+ printLater := hph.currentKeyLen == 0 && hph.mounted && hph.trace
+ var unfoldDone func()
+ if dbg.KVReadLevelledMetrics {
+ unfoldDone = hph.metrics.StartUnfolding(plainKey)
+ }
+ if err := hph.unfold(hashedKey, unfolding); err != nil {
+ return fmt.Errorf("unfold: %w", err)
+ }
+ if unfoldDone != nil {
+ unfoldDone()
+ }
+ if printLater {
+ fmt.Printf("[%x] subtrie pref '%x' d=%d\n", hph.mountedNib, hph.currentKey[:hph.currentKeyLen], hph.depths[max(0, hph.activeRows-1)])
+ }
+ }
+ return nil
+}
+
func (hph *HexPatriciaHashed) followAndUpdate(hashedKey, plainKey []byte, stateUpdate *Update) (err error) {
//if hph.trace {
// fmt.Printf("mnt: %0x current: %x path %x\n", hph.mountedNib, hph.currentKey[:hph.currentKeyLen], hashedKey)
@@ -2529,23 +2516,9 @@ func (hph *HexPatriciaHashed) followAndUpdate(hashedKey, plainKey []byte, stateU
foldDone()
}
}
- // Now unfold until we step on an empty cell
- for unfolding := hph.needUnfolding(hashedKey); unfolding > 0; unfolding = hph.needUnfolding(hashedKey) {
- printLater := hph.currentKeyLen == 0 && hph.mounted && hph.trace
- var unfoldDone func()
- if dbg.KVReadLevelledMetrics {
- unfoldDone = hph.metrics.StartUnfolding(plainKey)
- }
- if err := hph.unfold(hashedKey, unfolding); err != nil {
- return fmt.Errorf("unfold: %w", err)
- }
- if unfoldDone != nil {
- unfoldDone()
- }
- if printLater {
- fmt.Printf("[%x] subtrie pref '%x' d=%d\n", hph.mountedNib, hph.currentKey[:hph.currentKeyLen], hph.depths[max(0, hph.activeRows-1)])
- }
- // fmt.Printf("mnt: %0x current: %x path %x\n", hph.mountedNib, hph.currentKey[:hph.currentKeyLen], hashedKey)
+ // Now unfold the path so the cell at hashedKey is reachable.
+ if err := hph.unfoldKeyPath(hashedKey, plainKey); err != nil {
+ return err
}
if stateUpdate == nil {
@@ -2791,22 +2764,9 @@ func (hph *HexPatriciaHashed) Process(ctx context.Context, updates *Updates, log
// Setup warmup if configured
var warmuper *Warmuper
if warmup.Enabled {
- warmup.EnableWarmupCache = hph.enableWarmupCache
warmuper = NewWarmuper(ctx, warmup)
warmuper.Start()
defer warmuper.CloseAndWait()
- // Set cache on trie if warmup cache is enabled
- if warmup.EnableWarmupCache {
- hph.cache = warmuper.Cache()
- hph.branchEncoder.SetCache(hph.cache)
- defer func() {
- hph.cache = nil
- hph.branchEncoder.SetCache(nil)
- }()
- } else {
- hph.cache = nil
- hph.branchEncoder.SetCache(nil)
- }
}
err = updates.HashSort(ctx, warmuper, func(hashedKey, plainKey []byte, stateUpdate *Update) error {
@@ -2943,11 +2903,21 @@ func (hph *HexPatriciaHashed) Process(ctx context.Context, updates *Updates, log
func (hph *HexPatriciaHashed) SetTrace(trace bool) { hph.trace = trace }
func (hph *HexPatriciaHashed) SetTraceDomain(trace bool) { hph.traceDomain = trace }
-func (hph *HexPatriciaHashed) EnableWarmupCache(enable bool) {
- hph.enableWarmupCache = enable
- hph.cfg.EnableWarmupCache = enable
+
+// SetBranchCache attaches a BranchCache for branch read-through and
+// write-through. Also propagates to the trie's BranchEncoder so encoder
+// writes update the cache via mark-dirty-then-Put (see CollectUpdate).
+//
+// nil disables — both this trie's branchFromCacheOrDB Level-2 and the
+// encoder's cache write-back become no-ops.
+func (hph *HexPatriciaHashed) SetBranchCache(c *BranchCache) {
+ hph.branchCache = c
+ hph.branchEncoder.branchCache = c
}
+// BranchCache returns the attached BranchCache, or nil if none is set.
+func (hph *HexPatriciaHashed) BranchCache() *BranchCache { return hph.branchCache }
+
func (hph *HexPatriciaHashed) GetCapture(truncate bool) []string {
capture := hph.capture
if truncate {
@@ -2997,7 +2967,9 @@ func (hph *HexPatriciaHashed) SetLeaveDeferredForCaller(leave bool) {
hph.leaveDeferredForCaller = leave
}
-// Reset allows HexPatriciaHashed instance to be reused for the new commitment calculation
+// Reset allows HexPatriciaHashed instance to be reused for the new commitment calculation.
+// The aggregator-scope BranchCache is intentionally not cleared here;
+// SharedDomains.Unwind handles correctness via txN-tagged eviction.
func (hph *HexPatriciaHashed) Reset() {
hph.root.reset()
hph.rootTouched = false
@@ -3009,57 +2981,27 @@ func (hph *HexPatriciaHashed) ResetContext(ctx PatriciaContext) {
hph.ctx = ctx
}
-// Cache returns the active warmup cache, or nil if none is set.
-func (hph *HexPatriciaHashed) Cache() *WarmupCache {
- return hph.cache
-}
-
-// branchFromCacheOrDB reads branch data from cache if available, otherwise from DB.
+// branchFromCacheOrDB reads branch data via ctx.Branch, which goes
+// through sd.mem -> sd.parent.mem -> aggregator-scope BranchCache ->
+// MDBX. The HPH-side warmup cache layer that used to sit above this
+// has been removed (step 2b of WarmupCache deletion); BranchCache is
+// the only branch cache.
func (hph *HexPatriciaHashed) branchFromCacheOrDB(key []byte) ([]byte, error) {
- if hph.cache != nil {
- if data, found := hph.cache.GetBranch(key); found {
- if hph.metrics != nil {
- hph.metrics.cacheBranch.Add(1)
- }
- return data, nil
- }
- if hph.metrics != nil {
- hph.metrics.missBranch.Add(1)
- }
- }
data, _, err := hph.ctx.Branch(key)
return data, err
}
-// accountFromCacheOrDB reads account data from cache if available, otherwise from DB.
+// accountFromCacheOrDB reads account data via ctx.Account. No Go-side
+// caching layer (the warmup cache layer was removed in step 2b of
+// WarmupCache deletion); accounts go straight to the BTree-backed
+// AccountsDomain via SD, with OS page cache as the only caching layer.
func (hph *HexPatriciaHashed) accountFromCacheOrDB(plainKey []byte) (*Update, error) {
- if hph.cache != nil {
- if update, found := hph.cache.GetAccount(plainKey); found {
- if hph.metrics != nil {
- hph.metrics.cacheAccount.Add(1)
- }
- return update, nil
- }
- if hph.metrics != nil {
- hph.metrics.missAccount.Add(1)
- }
- }
return hph.ctx.Account(plainKey)
}
-// storageFromCacheOrDB reads storage data from cache if available, otherwise from DB.
+// storageFromCacheOrDB reads storage data via ctx.Storage. No Go-side
+// caching layer (see accountFromCacheOrDB).
func (hph *HexPatriciaHashed) storageFromCacheOrDB(plainKey []byte) (*Update, error) {
- if hph.cache != nil {
- if update, found := hph.cache.GetStorage(plainKey); found {
- if hph.metrics != nil {
- hph.metrics.cacheStorage.Add(1)
- }
- return update, nil
- }
- if hph.metrics != nil {
- hph.metrics.missStorage.Add(1)
- }
- }
return hph.ctx.Storage(plainKey)
}
diff --git a/execution/commitment/preload.go b/execution/commitment/preload.go
new file mode 100644
index 00000000000..7b60d8a3daa
--- /dev/null
+++ b/execution/commitment/preload.go
@@ -0,0 +1,181 @@
+// 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.
+
+package commitment
+
+import (
+ "encoding/binary"
+ "fmt"
+
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/execution/commitment/nibbles"
+)
+
+// CommitmentReader does GetLatest on the CommitmentDomain. Decoupled from
+// tx/aggregator types to keep this package free of db/state imports.
+type CommitmentReader func(prefix []byte) (v []byte, step uint64, found bool, err error)
+
+// estimatedEntryOverheadBytes is the per-entry RAM cost beyond the encoded
+// value itself: branchCacheEntry (~80 B), maphash slot + hash (~40 B),
+// prefix slice (~24 B header + content), value slice header (~24 B).
+const estimatedEntryOverheadBytes = 168
+
+type pathDepth struct {
+ path []byte
+ depth int
+}
+
+// ContractTrunkPreload holds the resumable state of a BFS preload for one
+// contract's storage subtree. Not goroutine-safe.
+type ContractTrunkPreload struct {
+ contractHash []byte
+ queue []pathDepth
+ pinnedPrefixes [][]byte
+ pinned int
+ usedBytes int
+ maxDepthReached int
+}
+
+// NewContractTrunkPreload seeds a preload state at depth 64 (storage
+// subtree root: keccak256(address), 32 bytes / 64 nibbles).
+func NewContractTrunkPreload(contractHash []byte) (*ContractTrunkPreload, error) {
+ if len(contractHash) != 32 {
+ return nil, fmt.Errorf("NewContractTrunkPreload: contractHash must be 32 bytes, got %d", len(contractHash))
+ }
+ contractNibbles := make([]byte, 64)
+ for i, b := range contractHash {
+ contractNibbles[2*i] = b >> 4
+ contractNibbles[2*i+1] = b & 0x0f
+ }
+ return &ContractTrunkPreload{
+ contractHash: contractHash,
+ queue: []pathDepth{{path: contractNibbles, depth: 64}},
+ maxDepthReached: 64,
+ }, nil
+}
+
+// Run advances the BFS one chunk, pinning branches until additionalBudgetBytes
+// is exhausted or the queue is empty. Returns entries pinned THIS call, whether
+// the preload is now complete, and any reader error. On error the partial pin
+// set and queue position are preserved for a retry on the next Run.
+func (p *ContractTrunkPreload) Run(
+ additionalBudgetBytes int,
+ reader CommitmentReader,
+ cache *BranchCache,
+ logger log.Logger,
+) (newlyPinned int, queueEmpty bool, err error) {
+ if cache == nil {
+ return 0, false, fmt.Errorf("ContractTrunkPreload.Run: cache is nil")
+ }
+ if additionalBudgetBytes <= 0 {
+ return 0, len(p.queue) == 0, nil
+ }
+
+ chunkUsedBytes := 0
+ chunkPinned := 0
+
+ for len(p.queue) > 0 {
+ head := p.queue[0]
+ p.queue = p.queue[1:]
+
+ prefix := nibbles.HexToCompact(head.path)
+ v, step, found, rerr := reader(prefix)
+ if rerr != nil {
+ return chunkPinned, false, fmt.Errorf("preload at depth %d: %w", head.depth, rerr)
+ }
+ if !found {
+ continue
+ }
+
+ entryCost := estimatedEntryOverheadBytes + len(prefix) + len(v)
+ if chunkUsedBytes+entryCost > additionalBudgetBytes {
+ p.queue = append([]pathDepth{head}, p.queue...)
+ break
+ }
+
+ // Trunk preload reads frozen .kv files only (below any unwind point),
+ // so the bytes can never go stale on a reorg — stamp txNum 0 ("frozen,
+ // always valid") so the tx-aware Get keeps them warm across unwinds.
+ _ = step
+ cache.PinEntry(prefix, v, 0, "preload-trunk")
+ // Copy prefix because nibbles.HexToCompact may return a slice
+ // over a reused buffer; we need a stable handle for later
+ // Invalidate on demotion.
+ prefixCopy := make([]byte, len(prefix))
+ copy(prefixCopy, prefix)
+ p.pinnedPrefixes = append(p.pinnedPrefixes, prefixCopy)
+ chunkUsedBytes += entryCost
+ chunkPinned++
+ if head.depth > p.maxDepthReached {
+ p.maxDepthReached = head.depth
+ }
+ if logger != nil && (p.pinned+chunkPinned)%5000 == 0 {
+ logger.Info("[trunk-preload] progress",
+ "pinned", p.pinned+chunkPinned, "depth", head.depth,
+ "used_mb", (p.usedBytes+chunkUsedBytes)/(1<<20))
+ }
+
+ // Branch encoding: 2-byte touchMap || 2-byte bitmap || per-child data.
+ if len(v) < 4 {
+ continue
+ }
+ bitmap := binary.BigEndian.Uint16(v[2:4])
+ for n := 0; n < 16; n++ {
+ if bitmap&(1<= 64; value may be empty).
+// Bounds a wave's file fetch so the budget is guaranteed exhausted inside it.
+const minEntryBytes = estimatedEntryOverheadBytes + 33
+
+// maxStorageTrunkDepth: 64 (account path) + 64 (keccak256(slot)) = 128.
+const maxStorageTrunkDepth = 128
+
+type pathKey struct {
+ path []byte // nibble path (1 byte / nibble)
+ key []byte // HexToCompact(path)
+}
+
+func toPathKey(path []byte) pathKey {
+ k := nibbles.HexToCompact(path)
+ kc := make([]byte, len(k))
+ copy(kc, k) // HexToCompact result may alias a reused buffer
+ return pathKey{path: path, key: kc}
+}
+
+// ContractTrunkPreloadParallel is the wave-BFS analogue of ContractTrunkPreload.
+// It walks one depth-level per wave and resolves missing branches through a
+// batched, file-only BatchBranchResolver (no MDBX in the hot path). Each Run
+// advances zero or more waves bounded by stepBudgetBytes; partial waves are
+// truncated to fit the budget and resumed on the next Run.
+//
+// dbBranches shadows file values for the same key — DB is authoritative for
+// steps not yet flushed to files. Pass nil for cold-snapshot / file-only mode.
+//
+// Not goroutine-safe. The resolver is passed per-Run (not held) so callers
+// can supply a fresh tx-scoped resolver each block.
+type ContractTrunkPreloadParallel struct {
+ contractHash []byte
+ frontier []pathKey // paths to process at depth = nextDepth
+ pendingChildren []pathKey // accumulated children of pinned items at depth = nextDepth+1
+ nextDepth int // depth of the next wave (starts at 64)
+ pinnedPrefixes [][]byte
+ pinned int
+ usedBytes int
+ maxDepthReached int
+ dbHitsPinned int
+}
+
+// NewContractTrunkPreloadParallel seeds a preload at depth 64 (storage subtree root).
+func NewContractTrunkPreloadParallel(contractHash []byte) (*ContractTrunkPreloadParallel, error) {
+ if len(contractHash) != 32 {
+ return nil, fmt.Errorf("NewContractTrunkPreloadParallel: contractHash must be 32 bytes, got %d", len(contractHash))
+ }
+ contractHashCopy := make([]byte, len(contractHash))
+ copy(contractHashCopy, contractHash)
+ return &ContractTrunkPreloadParallel{
+ contractHash: contractHashCopy,
+ frontier: []pathKey{toPathKey(ContractNibbles(contractHashCopy))},
+ nextDepth: 64,
+ maxDepthReached: 64,
+ }, nil
+}
+
+// Run advances the wave-BFS until stepBudgetBytes is exhausted, the frontier
+// is empty, or maxStorageTrunkDepth is reached. On resolver error the partial
+// pin set and wave position survive for retry on the next Run.
+func (p *ContractTrunkPreloadParallel) Run(
+ stepBudgetBytes int,
+ dbBranches map[string][]byte,
+ resolve BatchBranchResolver,
+ cache *BranchCache,
+ logger log.Logger,
+) (newlyPinned int, queueEmpty bool, err error) {
+ if cache == nil {
+ return 0, false, fmt.Errorf("ContractTrunkPreloadParallel.Run: cache is nil")
+ }
+ if resolve == nil {
+ return 0, false, fmt.Errorf("ContractTrunkPreloadParallel.Run: resolver is nil")
+ }
+ if stepBudgetBytes <= 0 {
+ return 0, len(p.frontier) == 0, nil
+ }
+
+ stepCap := p.usedBytes + stepBudgetBytes
+ chunkPinned := 0
+ budgetHit := false
+
+ // pin records the entry and queues its children. Returns false on budget hit.
+ pin := func(pk pathKey, v []byte, depth int, next *[]pathKey) bool {
+ cost := estimatedEntryCost(pk.key, v)
+ if p.usedBytes+cost > stepCap {
+ budgetHit = true
+ return false
+ }
+ cache.PinEntry(pk.key, v, 0, "preload-trunk-parallel-resumable")
+ kc := make([]byte, len(pk.key))
+ copy(kc, pk.key)
+ p.pinnedPrefixes = append(p.pinnedPrefixes, kc)
+ p.usedBytes += cost
+ p.pinned++
+ chunkPinned++
+ if depth > p.maxDepthReached {
+ p.maxDepthReached = depth
+ }
+ if logger != nil && p.pinned%5000 == 0 {
+ logger.Info("[trunk-preload-parallel] progress",
+ "pinned", p.pinned, "depth", depth, "used_mb", p.usedBytes/(1<<20))
+ }
+ if len(v) >= 4 { // 2-byte touchMap || 2-byte afterMap || per-child data
+ bitmap := binary.BigEndian.Uint16(v[2:4])
+ for n := 0; n < 16; n++ {
+ if bitmap&(1< 0 {
+ depth := p.nextDepth
+ // Ascending key order so the file-batch partition is contiguous-in-file.
+ sort.Slice(p.frontier, func(i, j int) bool { return bytes.Compare(p.frontier[i].key, p.frontier[j].key) < 0 })
+
+ var dbHits []pathKey
+ var dbVals [][]byte
+ var fileMiss []pathKey
+ dbHitsBytes := 0
+ for _, pk := range p.frontier {
+ if v, ok := dbBranches[string(pk.key)]; ok {
+ dbHits = append(dbHits, pk)
+ dbVals = append(dbVals, v)
+ dbHitsBytes += estimatedEntryCost(pk.key, v)
+ } else {
+ fileMiss = append(fileMiss, pk)
+ }
+ }
+
+ // Cap the file fetch by what the budget can absorb after dbHits.
+ var fileMissDeferred []pathKey
+ if fileBudget := stepCap - p.usedBytes - dbHitsBytes; fileBudget <= 0 {
+ fileMissDeferred = fileMiss
+ fileMiss = nil
+ } else if maxFileFetch := fileBudget/minEntryBytes + 1; maxFileFetch < len(fileMiss) {
+ fileMissDeferred = fileMiss[maxFileFetch:]
+ fileMiss = fileMiss[:maxFileFetch]
+ }
+
+ var fileVals [][]byte
+ if len(fileMiss) > 0 {
+ keys := make([][]byte, len(fileMiss))
+ for i := range fileMiss {
+ keys[i] = fileMiss[i].key
+ }
+ fileVals, err = resolve(keys)
+ if err != nil {
+ return chunkPinned, false, fmt.Errorf("preload at depth %d: %w", depth, err)
+ }
+ if len(fileVals) != len(keys) {
+ return chunkPinned, false, fmt.Errorf("preload at depth %d: resolver returned %d vals for %d keys", depth, len(fileVals), len(keys))
+ }
+ }
+
+ dbHitStop := len(dbHits)
+ for i, pk := range dbHits {
+ if !pin(pk, dbVals[i], depth, &p.pendingChildren) {
+ dbHitStop = i
+ break
+ }
+ p.dbHitsPinned++
+ }
+ fileMissStop := len(fileMiss)
+ if !budgetHit {
+ for i, pk := range fileMiss {
+ v := fileVals[i]
+ if v == nil {
+ continue
+ }
+ if !pin(pk, v, depth, &p.pendingChildren) {
+ fileMissStop = i
+ break
+ }
+ }
+ }
+
+ if budgetHit {
+ // Preserve un-pinned items at current depth; pendingChildren stays
+ // at depth+1 for when this depth is drained on a future Run.
+ rest := make([]pathKey, 0, len(dbHits)-dbHitStop+len(fileMiss)-fileMissStop+len(fileMissDeferred))
+ rest = append(rest, dbHits[dbHitStop:]...)
+ rest = append(rest, fileMiss[fileMissStop:]...)
+ rest = append(rest, fileMissDeferred...)
+ p.frontier = rest
+ break
+ }
+
+ if len(fileMissDeferred) > 0 {
+ // Defensive: !budgetHit should mean no truncation. Re-queue at current depth.
+ p.frontier = fileMissDeferred
+ } else {
+ p.frontier = p.pendingChildren
+ p.pendingChildren = nil
+ p.nextDepth++
+ }
+ }
+
+ queueEmpty = (len(p.frontier) == 0 && len(p.pendingChildren) == 0) || p.nextDepth > maxStorageTrunkDepth
+ if logger != nil && (chunkPinned > 0 || queueEmpty) {
+ logger.Info("[trunk-preload-parallel] step",
+ "contract_hash", fmt.Sprintf("%x", p.contractHash),
+ "step_budget_mb", stepBudgetBytes/(1<<20),
+ "used_mb_total", p.usedBytes/(1<<20),
+ "pinned_this_step", chunkPinned,
+ "pinned_total", p.pinned,
+ "db_hits_total", p.dbHitsPinned,
+ "max_depth_reached", p.maxDepthReached,
+ "queue_empty", queueEmpty,
+ "next_depth", p.nextDepth,
+ "frontier_size", len(p.frontier))
+ }
+ return chunkPinned, queueEmpty, nil
+}
+
+func (p *ContractTrunkPreloadParallel) PinnedTotal() int { return p.pinned }
+func (p *ContractTrunkPreloadParallel) UsedBytes() int { return p.usedBytes }
+func (p *ContractTrunkPreloadParallel) MaxDepthReached() int { return p.maxDepthReached }
+func (p *ContractTrunkPreloadParallel) DbHitsPinned() int { return p.dbHitsPinned }
+func (p *ContractTrunkPreloadParallel) ContractHash() []byte { return p.contractHash }
+
+func (p *ContractTrunkPreloadParallel) QueueRemaining() int {
+ return len(p.frontier) + len(p.pendingChildren)
+}
+
+// PinnedPrefixes returns slices aliasing internal storage — do not mutate.
+func (p *ContractTrunkPreloadParallel) PinnedPrefixes() [][]byte { return p.pinnedPrefixes }
+
+// PreloadContractTrunkParallel is the one-shot wrapper around
+// NewContractTrunkPreloadParallel + Run.
+func PreloadContractTrunkParallel(
+ contractHash []byte,
+ ramBudgetBytes int,
+ dbBranches map[string][]byte,
+ resolve BatchBranchResolver,
+ cache *BranchCache,
+ logger log.Logger,
+) (pinned int, err error) {
+ if ramBudgetBytes <= 0 {
+ return 0, fmt.Errorf("PreloadContractTrunkParallel: ramBudgetBytes must be positive, got %d", ramBudgetBytes)
+ }
+ p, err := NewContractTrunkPreloadParallel(contractHash)
+ if err != nil {
+ return 0, err
+ }
+ if resolve == nil {
+ return 0, fmt.Errorf("PreloadContractTrunkParallel: resolver is nil")
+ }
+ pinned, queueEmpty, err := p.Run(ramBudgetBytes, dbBranches, resolve, cache, logger)
+ if logger != nil {
+ logger.Info("[trunk-preload-parallel] complete",
+ "contract_hash", fmt.Sprintf("%x", contractHash),
+ "ram_budget_mb", ramBudgetBytes/(1<<20),
+ "used_mb", p.UsedBytes()/(1<<20),
+ "pinned", pinned,
+ "db_hits_pinned", p.DbHitsPinned(),
+ "max_depth_reached", p.MaxDepthReached(),
+ "budget_exhausted", !queueEmpty,
+ "cache_pinned_total", cache.PinnedCount())
+ }
+ return pinned, err
+}
diff --git a/execution/commitment/preload_parallel_test.go b/execution/commitment/preload_parallel_test.go
new file mode 100644
index 00000000000..6cd3fe75e77
--- /dev/null
+++ b/execution/commitment/preload_parallel_test.go
@@ -0,0 +1,735 @@
+// 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.
+
+package commitment
+
+import (
+ "bytes"
+ "encoding/binary"
+ "errors"
+ "sort"
+ "testing"
+
+ "github.com/erigontech/erigon/execution/commitment/nibbles"
+)
+
+func hexNibbles(b []byte) []byte {
+ out := make([]byte, len(b)*2)
+ for i, x := range b {
+ out[2*i] = x >> 4
+ out[2*i+1] = x & 0x0f
+ }
+ return out
+}
+
+// branchVal builds a synthetic branch-node value: 2-byte touchMap (0) ||
+// 2-byte afterMap (the child bitmap) || zero-padding to size sz (>= 4).
+func branchVal(afterMap uint16, sz int) []byte {
+ if sz < 4 {
+ sz = 4
+ }
+ v := make([]byte, sz)
+ binary.BigEndian.PutUint16(v[2:4], afterMap)
+ return v
+}
+
+// syntheticTree describes a contract storage subtree: path-string -> afterMap.
+// Root is the 64-nibble path of the contract hash; a node R is present iff R is
+// a key here, and R's children are R||n for each set bit n in afterMap[R].
+type syntheticTree map[string]uint16
+
+func buildSyntheticTree(t *testing.T) (hash []byte, tree syntheticTree, allPaths [][]byte) {
+ t.Helper()
+ hash = make([]byte, 32)
+ for i := range hash {
+ hash[i] = 0x42
+ }
+ root := string(hexNibbles(hash))
+ // R(64) -> {1,2} ; R1(65) -> {3} ; R2(65) -> {4,5} ;
+ // R1.3(66) leaf ; R2.4(66) leaf ; R2.5(66) -> {6} ; R2.5.6(67) leaf.
+ r := []byte(root)
+ p := func(suffix ...byte) []byte { return append(append([]byte{}, r...), suffix...) }
+ tree = syntheticTree{
+ string(p()): 0b110, // bits 1,2
+ string(p(1)): 0b1000, // bit 3
+ string(p(2)): 0b110000, // bits 4,5
+ string(p(1, 3)): 0,
+ string(p(2, 4)): 0,
+ string(p(2, 5)): 0b1000000, // bit 6
+ string(p(2, 5, 6)): 0,
+ }
+ for k := range tree {
+ allPaths = append(allPaths, []byte(k))
+ }
+ return hash, tree, allPaths
+}
+
+// fakeResolver returns a BatchBranchResolver backed by the synthetic tree.
+// notFound (path-strings) are treated as absent from the file layer.
+// valSz is the branch value size. If failOnKey is non-empty, the resolver
+// returns an error when that key is requested.
+func fakeResolver(tree syntheticTree, notFound map[string]bool, valSz int, failOnPath string) BatchBranchResolver {
+ return func(keys [][]byte) ([][]byte, error) {
+ // keys must be sorted ascending (the contract of BatchBranchResolver).
+ for i := 1; i < len(keys); i++ {
+ if bytes.Compare(keys[i-1], keys[i]) >= 0 {
+ return nil, errors.New("resolver got unsorted keys")
+ }
+ }
+ vals := make([][]byte, len(keys))
+ for i, k := range keys {
+ path := string(nibbles.CompactToHex(k))
+ if failOnPath != "" && path == failOnPath {
+ return nil, errors.New("synthetic resolver failure")
+ }
+ am, ok := tree[path]
+ if !ok || notFound[path] {
+ continue // nil
+ }
+ vals[i] = branchVal(am, valSz)
+ }
+ return vals, nil
+ }
+}
+
+// breadthFirstOrder returns the synthetic tree's paths in the order
+// PreloadContractTrunkParallel pins them: by depth, then by compact-key.
+func breadthFirstOrder(tree syntheticTree, exclude map[string]bool) []string {
+ type pk struct {
+ path string
+ key []byte
+ }
+ var pks []pk
+ // reachability from root, honoring exclude (an excluded node stops descent —
+ // it and its subtree become unreachable)
+ root := ""
+ for p := range tree {
+ if root == "" || len(p) < len(root) {
+ root = p
+ }
+ }
+ reach := map[string]bool{}
+ var dfs func(p string)
+ dfs = func(p string) {
+ if exclude[p] {
+ return
+ }
+ am, ok := tree[p]
+ if !ok {
+ return
+ }
+ reach[p] = true
+ for n := 0; n < 16; n++ {
+ if am&(1< maxBatch {
+ maxBatch = len(keys)
+ }
+ return base(keys)
+ }
+ c := NewBranchCache(64)
+ n, err := PreloadContractTrunkParallel(hash, budget, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n < 1 || n > 3 {
+ t.Fatalf("pinned %d, expected 1..3 for a ~3-entry budget", n)
+ }
+ if maxBatch > 6 {
+ t.Fatalf("depth-65 wave (width 16) should have been capped to ~remaining/minEntryBytes; resolver saw a batch of %d", maxBatch)
+ }
+}
+
+func TestPreloadParallel_DbHitsShadowFiles(t *testing.T) {
+ // A branch present in both dbBranches (fresh) and the file layer (stale)
+ // must resolve to the DB value — and the DB value's child bitmap must drive
+ // the descent. Tree: as buildSyntheticTree but with an extra leaf R1.7; the
+ // file value for R1 has bitmap {3} (so R1.7 unreachable via files), the DB
+ // value for R1 has bitmap {3,7} — so R1.7 should get pinned iff the DB value
+ // is the one used.
+ hash, tree, _ := buildSyntheticTree(t)
+ root := ""
+ for p := range tree {
+ if root == "" || len(p) < len(root) {
+ root = p
+ }
+ }
+ r1 := root + string([]byte{1})
+ tree[r1+string([]byte{7})] = 0 // R1.7 leaf, present in the file layer
+ const valSz = 100
+
+ freshR1 := branchVal(0b10001000, valSz) // bits 3 and 7
+ freshR1[4] = 0xAB // a marker so we can assert the exact bytes were pinned
+ dbBranches := map[string][]byte{string(nibbles.HexToCompact([]byte(r1))): freshR1}
+
+ c := NewBranchCache(64)
+ n, err := PreloadContractTrunkParallel(hash, 1<<20, dbBranches, fakeResolver(tree, nil, valSz, ""), c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != len(tree) {
+ t.Fatalf("pinned %d, want %d (whole tree reachable via the fresh R1 bitmap)", n, len(tree))
+ }
+ // R1's cached value is the DB one, not the stale file one.
+ gotR1, _, ok := c.Get(nibbles.HexToCompact([]byte(r1)))
+ if !ok {
+ t.Fatal("R1 not pinned")
+ }
+ if !bytes.Equal(gotR1, freshR1) {
+ t.Fatalf("R1 cached value is not the DB value: got %x want %x", gotR1, freshR1)
+ }
+ // R1.7 is reachable only because the DB bitmap has bit 7 — it must be pinned.
+ if _, _, ok := c.Get(nibbles.HexToCompact([]byte(r1 + string([]byte{7})))); !ok {
+ t.Fatal("R1.7 should be pinned (the DB value of R1 has it as a child); the stale file bitmap was used instead")
+ }
+}
+
+func TestNextSubtree(t *testing.T) {
+ cases := []struct{ in, want []byte }{
+ {[]byte{0x01, 0x02}, []byte{0x01, 0x03}},
+ {[]byte{0x01, 0xff}, []byte{0x02}},
+ {[]byte{0x00}, []byte{0x01}},
+ }
+ for _, c := range cases {
+ if got := NextSubtree(c.in); !bytes.Equal(got, c.want) {
+ t.Fatalf("NextSubtree(%x) = %x, want %x", c.in, got, c.want)
+ }
+ }
+ if NextSubtree([]byte{0xff, 0xff}) != nil {
+ t.Fatalf("NextSubtree(0xffff) should be nil")
+ }
+}
+
+func TestContractTrunkKeyRanges(t *testing.T) {
+ hashA := make([]byte, 32)
+ for i := range hashA {
+ hashA[i] = byte(7*i + 3)
+ }
+ hashB := make([]byte, 32)
+ for i := range hashB {
+ hashB[i] = byte(251 - 3*i)
+ }
+ nibA := ContractNibbles(hashA)
+ nibB := ContractNibbles(hashB)
+ evenFrom, evenTo, oddFrom, oddTo := ContractTrunkKeyRanges(nibA)
+ inRange := func(k, from, to []byte) bool {
+ return bytes.Compare(k, from) >= 0 && (to == nil || bytes.Compare(k, to) < 0)
+ }
+ keyOf := func(contractNibbles, slotPath []byte) []byte {
+ return nibbles.HexToCompact(append(append([]byte{}, contractNibbles...), slotPath...))
+ }
+ slotPaths := [][]byte{
+ {}, // 64 — subtree root (even)
+ {0x0}, {0xf}, // 65 (odd)
+ {0x1, 0x2}, {0xf, 0xf}, // 66 (even)
+ {0x3, 0x4, 0x5}, // 67 (odd)
+ {0x6, 0x7, 0x8, 0x9}, // 68 (even)
+ {0xa, 0xb, 0xc, 0xd, 0xe}, // 69 (odd)
+ make([]byte, 64), // 128 (even) — deepest
+ }
+ for _, sp := range slotPaths {
+ k := keyOf(nibA, sp)
+ total := 64 + len(sp)
+ if total%2 == 0 {
+ if !inRange(k, evenFrom, evenTo) || inRange(k, oddFrom, oddTo) {
+ t.Fatalf("depth %d (even) branch %x: must be in [%x,%x), not in [%x,%x)", total, k, evenFrom, evenTo, oddFrom, oddTo)
+ }
+ } else {
+ if !inRange(k, oddFrom, oddTo) || inRange(k, evenFrom, evenTo) {
+ t.Fatalf("depth %d (odd) branch %x: must be in [%x,%x), not in [%x,%x)", total, k, oddFrom, oddTo, evenFrom, evenTo)
+ }
+ }
+ if got := nibbles.CompactToHex(k); !bytes.Equal(got, append(append([]byte{}, nibA...), sp...)) {
+ t.Fatalf("CompactToHex round-trip mismatch for slot %x", sp)
+ }
+ }
+ // A different contract's branches must be in neither of A's ranges.
+ for _, sp := range slotPaths[:6] {
+ k := keyOf(nibB, sp)
+ if inRange(k, evenFrom, evenTo) || inRange(k, oddFrom, oddTo) {
+ t.Fatalf("foreign-contract branch %x leaked into A's ranges", k)
+ }
+ }
+}
+
+func TestPreloadParallel_ResolverError(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ root := ""
+ for p := range tree {
+ if root == "" || len(p) < len(root) {
+ root = p
+ }
+ }
+ c := NewBranchCache(64)
+ // Fail when R||1 (depth 65) is requested -> root pinned at depth 64, then error.
+ _, err := PreloadContractTrunkParallel(hash, 1<<20, nil, fakeResolver(tree, nil, 100, root+string([]byte{1})), c, nil)
+ if err == nil {
+ t.Fatal("expected error from the resolver")
+ }
+ if c.PinnedCount() == 0 {
+ t.Fatal("the depth-64 root should have been pinned before the depth-65 failure")
+ }
+}
+
+// --- Resumable ContractTrunkPreloadParallel tests (Run-by-Run) ---
+
+// TestContractTrunkPreloadParallel_ResumeAcrossSteps confirms that splitting a
+// full preload into multiple Run calls yields the same pinned set as a
+// one-shot run with the equivalent total budget.
+func TestContractTrunkPreloadParallel_ResumeAcrossSteps(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ const valSz = 100
+ resolve := fakeResolver(tree, nil, valSz, "")
+
+ // Reference: one-shot.
+ cRef := NewBranchCache(64)
+ if _, err := PreloadContractTrunkParallel(hash, 1<<20, nil, resolve, cRef, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ // Step-by-step: budget exactly one entry per Run (the budget is checked
+ // before the entry is pinned; with overhead we need at least one entry's
+ // worth per step to make progress).
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Each entry is ~estimatedEntryOverheadBytes + 33 + valSz; over-allocate a
+ // bit per step so we always pin at least one new entry.
+ perStep := 2 * (estimatedEntryOverheadBytes + 33 + valSz)
+ const maxSteps = 50
+ var steps int
+ for ; steps < maxSteps; steps++ {
+ _, done, err := p.Run(perStep, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatalf("step %d: %v", steps, err)
+ }
+ if done {
+ break
+ }
+ }
+ if steps >= maxSteps {
+ t.Fatalf("preload did not complete in %d steps; pinned=%d", maxSteps, p.PinnedTotal())
+ }
+ if p.PinnedTotal() != len(tree) {
+ t.Fatalf("step-by-step pinned %d, want %d", p.PinnedTotal(), len(tree))
+ }
+ if c.PinnedCount() != cRef.PinnedCount() {
+ t.Fatalf("step-by-step cache pinned %d != one-shot cache pinned %d", c.PinnedCount(), cRef.PinnedCount())
+ }
+ // Spot-check: every path in the reference is in the step-by-step cache.
+ for path := range tree {
+ key := nibbles.HexToCompact([]byte(path))
+ vRef, _, okRef := cRef.Get(key)
+ v, _, ok := c.Get(key)
+ if !okRef || !ok {
+ t.Fatalf("path %x: ref ok=%v, step ok=%v", path, okRef, ok)
+ }
+ if !bytes.Equal(v, vRef) {
+ t.Fatalf("path %x: step value differs from ref", path)
+ }
+ }
+}
+
+// TestContractTrunkPreloadParallel_RunAfterCompleteIsNoOp confirms that once
+// the BFS reaches an empty frontier, further Run calls are no-ops.
+func TestContractTrunkPreloadParallel_RunAfterCompleteIsNoOp(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ const valSz = 100
+ resolve := fakeResolver(tree, nil, valSz, "")
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n1, done1, err := p.Run(1<<20, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !done1 {
+ t.Fatalf("expected done after full budget, got done=false (queue=%d)", p.QueueRemaining())
+ }
+ if n1 != len(tree) {
+ t.Fatalf("first Run pinned %d, want %d", n1, len(tree))
+ }
+ prevPinned := c.PinnedCount()
+ n2, done2, err := p.Run(1<<20, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !done2 {
+ t.Fatal("expected done on second Run")
+ }
+ if n2 != 0 {
+ t.Fatalf("second Run pinned %d new entries, want 0", n2)
+ }
+ if c.PinnedCount() != prevPinned {
+ t.Fatalf("cache pinned count changed across no-op Run: %d -> %d", prevPinned, c.PinnedCount())
+ }
+}
+
+// TestContractTrunkPreloadParallel_StepBudgetCaps confirms that a small step
+// budget stops the BFS even when the frontier has more work — and the saved
+// state has the queue position preserved for the next call.
+func TestContractTrunkPreloadParallel_StepBudgetCaps(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ const valSz = 100
+ resolve := fakeResolver(tree, nil, valSz, "")
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Budget for ~3 entries (depth-64 root + 2 depth-65 children).
+ rootKey := nibbles.HexToCompact(hexNibbles(hash))
+ entry := estimatedEntryOverheadBytes + len(rootKey) + valSz
+ smallBudget := 3*entry + 10
+ n1, done1, err := p.Run(smallBudget, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if done1 {
+ t.Fatalf("expected NOT done after a 3-entry budget; got done=true (pinned=%d)", n1)
+ }
+ if n1 < 1 || n1 > 5 {
+ t.Fatalf("expected ~3 pinned this step, got %d", n1)
+ }
+ if p.QueueRemaining() == 0 {
+ t.Fatal("expected frontier to be non-empty after small-budget step")
+ }
+ // Now exhaust with a full follow-on budget.
+ _, done2, err := p.Run(1<<20, nil, resolve, c, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !done2 {
+ t.Fatalf("expected done after large follow-on budget; queue=%d", p.QueueRemaining())
+ }
+ // In our synthetic tree only 3 of 7 paths sit at depths 64-65; the rest
+ // require descending past the truncated wave. The cap-per-wave logic
+ // drops the truncated wave's tail (BFS-wise) but the children of the
+ // pinned ones progress on the next call. Cumulative pinned should be
+ // >= the step-1 count.
+ if p.PinnedTotal() <= n1 {
+ t.Fatalf("follow-on Run made no progress: pinned still %d (step-1 was %d)", p.PinnedTotal(), n1)
+ }
+}
+
+// TestContractTrunkPreloadParallel_ResumeAfterResolverError confirms that a
+// resolver error preserves the partial state — a retry on the next Run picks
+// up from the same wave once the resolver is healthy again.
+func TestContractTrunkPreloadParallel_ResumeAfterResolverError(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ root := ""
+ for p := range tree {
+ if root == "" || len(p) < len(root) {
+ root = p
+ }
+ }
+ const valSz = 100
+ // Fail when R||1 is requested (a depth-65 key) — depth-64 wave succeeds.
+ failingResolve := fakeResolver(tree, nil, valSz, root+string([]byte{1}))
+ healthyResolve := fakeResolver(tree, nil, valSz, "")
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, done, err := p.Run(1<<20, nil, failingResolve, c, nil)
+ if err == nil {
+ t.Fatal("expected resolver error")
+ }
+ if done {
+ t.Fatal("Run with resolver error should return done=false")
+ }
+ // Depth-64 root should still be pinned (it was the previous wave).
+ if c.PinnedCount() == 0 {
+ t.Fatal("expected the depth-64 root pinned before the depth-65 wave failed")
+ }
+ preErrPinned := p.PinnedTotal()
+ // Retry with a healthy resolver — should pick up where we left off and
+ // finish. The previous partial wave will be re-attempted in the new
+ // call (the failing wave's frontier WAS advanced past the depth where
+ // the error fired — the error path returns before updating
+ // p.frontier/p.nextDepth, so retry sees the same depth's frontier).
+ n, done, err := p.Run(1<<20, nil, healthyResolve, c, nil)
+ if err != nil {
+ t.Fatalf("retry failed: %v", err)
+ }
+ if !done {
+ t.Fatalf("retry should complete the preload; queue=%d", p.QueueRemaining())
+ }
+ if p.PinnedTotal()-preErrPinned != n {
+ t.Fatalf("PinnedTotal delta %d != Run pinned %d", p.PinnedTotal()-preErrPinned, n)
+ }
+ // Whole tree should be pinned by now.
+ if p.PinnedTotal() != len(tree) {
+ t.Fatalf("after retry pinned %d, want %d", p.PinnedTotal(), len(tree))
+ }
+}
+
+// TestContractTrunkPreloadParallel_DbBranchesPerStep confirms that dbBranches
+// can change between Run calls (caller may pass a freshly-prefetched overlay
+// per block) and that the freshest values shadow file values per call.
+func TestContractTrunkPreloadParallel_DbBranchesPerStep(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ root := ""
+ for p := range tree {
+ if root == "" || len(p) < len(root) {
+ root = p
+ }
+ }
+ const valSz = 100
+ resolve := fakeResolver(tree, nil, valSz, "")
+
+ // On the first wave (depth 64) supply a fresh R value via dbBranches.
+ freshRoot := branchVal(tree[root], valSz)
+ freshRoot[4] = 0xAB
+ dbWave0 := map[string][]byte{string(nibbles.HexToCompact([]byte(root))): freshRoot}
+
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Wave 0: pin the root using dbBranches (one entry budget).
+ rootKey := nibbles.HexToCompact([]byte(root))
+ stepBudget := estimatedEntryOverheadBytes + len(rootKey) + valSz + 10
+ if _, _, err := p.Run(stepBudget, dbWave0, resolve, c, nil); err != nil {
+ t.Fatal(err)
+ }
+ if p.DbHitsPinned() != 1 {
+ t.Fatalf("wave 0: expected 1 db-hit pinned, got %d", p.DbHitsPinned())
+ }
+ gotRoot, _, ok := c.Get(rootKey)
+ if !ok {
+ t.Fatal("root not pinned after wave 0")
+ }
+ if !bytes.Equal(gotRoot, freshRoot) {
+ t.Fatalf("wave 0: root pinned with stale file value, expected fresh dbBranches value")
+ }
+
+ // Wave 1: depth 65. Pass an empty dbBranches (file-only); resolver
+ // supplies stale-bitmap values.
+ if _, done, err := p.Run(1<<20, nil, resolve, c, nil); err != nil {
+ t.Fatal(err)
+ } else if !done {
+ t.Fatalf("expected done after large budget; queue=%d", p.QueueRemaining())
+ }
+ if p.DbHitsPinned() != 1 {
+ t.Fatalf("expected db-hit count to remain 1, got %d", p.DbHitsPinned())
+ }
+ if p.PinnedTotal() != len(tree) {
+ t.Fatalf("after wave 1 pinned %d, want %d", p.PinnedTotal(), len(tree))
+ }
+}
+
+// TestContractTrunkPreloadParallel_PinnedPrefixesAccumulate confirms that
+// PinnedPrefixes() accumulates across Run calls (needed for demote-time
+// cache invalidation in the adaptive controller).
+func TestContractTrunkPreloadParallel_PinnedPrefixesAccumulate(t *testing.T) {
+ hash, tree, _ := buildSyntheticTree(t)
+ const valSz = 100
+ resolve := fakeResolver(tree, nil, valSz, "")
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rootKey := nibbles.HexToCompact(hexNibbles(hash))
+ entry := estimatedEntryOverheadBytes + len(rootKey) + valSz
+ // Two small steps then one big step.
+ for i := 0; i < 2; i++ {
+ if _, _, err := p.Run(2*entry+10, nil, resolve, c, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, done, err := p.Run(1<<20, nil, resolve, c, nil); err != nil {
+ t.Fatal(err)
+ } else if !done {
+ t.Fatal("expected done after large step")
+ }
+ prefixes := p.PinnedPrefixes()
+ if len(prefixes) != p.PinnedTotal() {
+ t.Fatalf("PinnedPrefixes len %d != PinnedTotal %d", len(prefixes), p.PinnedTotal())
+ }
+ // Every prefix must be in the cache.
+ for _, pf := range prefixes {
+ if _, _, ok := c.Get(pf); !ok {
+ t.Fatalf("prefix %x in PinnedPrefixes but not in cache", pf)
+ }
+ }
+ // All prefixes are unique.
+ seen := map[string]bool{}
+ for _, pf := range prefixes {
+ if seen[string(pf)] {
+ t.Fatalf("duplicate prefix %x in PinnedPrefixes", pf)
+ }
+ seen[string(pf)] = true
+ }
+}
+
+// TestContractTrunkPreloadParallel_NilCacheError + NilResolverError confirm
+// the input-validation guards.
+func TestContractTrunkPreloadParallel_NilCacheError(t *testing.T) {
+ hash := make([]byte, 32)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resolve := func(keys [][]byte) ([][]byte, error) { return make([][]byte, len(keys)), nil }
+ if _, _, err := p.Run(1<<20, nil, resolve, nil, nil); err == nil {
+ t.Fatal("expected error when cache is nil")
+ }
+}
+
+func TestContractTrunkPreloadParallel_NilResolverError(t *testing.T) {
+ hash := make([]byte, 32)
+ c := NewBranchCache(64)
+ p, err := NewContractTrunkPreloadParallel(hash)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := p.Run(1<<20, nil, nil, c, nil); err == nil {
+ t.Fatal("expected error when resolver is nil")
+ }
+}
+
+func TestContractTrunkPreloadParallel_BadHashLengthError(t *testing.T) {
+ if _, err := NewContractTrunkPreloadParallel(make([]byte, 31)); err == nil {
+ t.Fatal("expected error for 31-byte hash")
+ }
+ if _, err := NewContractTrunkPreloadParallel(make([]byte, 33)); err == nil {
+ t.Fatal("expected error for 33-byte hash")
+ }
+}
diff --git a/execution/commitment/preload_ranges.go b/execution/commitment/preload_ranges.go
new file mode 100644
index 00000000000..3e2f9b8406d
--- /dev/null
+++ b/execution/commitment/preload_ranges.go
@@ -0,0 +1,59 @@
+// 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.
+
+package commitment
+
+import "github.com/erigontech/erigon/execution/commitment/nibbles"
+
+// NextSubtree: exclusive upper bound of a prefix-range scan over `in`. Returns
+// nil if `in` is all 0xff. Mirrors db/kv.NextSubtree (inlined to keep imports minimal).
+func NextSubtree(in []byte) []byte {
+ r := make([]byte, len(in))
+ copy(r, in)
+ for i := len(r) - 1; i >= 0; i-- {
+ if r[i] != 0xff {
+ r[i]++
+ return r[:i+1]
+ }
+ }
+ return nil
+}
+
+// ContractTrunkKeyRanges returns the two CommitmentDomain key ranges that
+// together cover every branch node of a contract's storage subtree.
+//
+// The commitment domain keys branches by HexToCompact(nibblePath); HexToCompact's
+// flag byte differs by the parity of the path length, so a contract's storage
+// branches (path = keccak256(addr)'s 64 nibbles ++ k slot-path nibbles, total
+// 64+k) split into two non-adjacent byte ranges:
+// - even total length (k even, incl. the depth-64 subtree root):
+// key = 0x00 || H || ⇒ prefix 0x00||H (33 bytes)
+// - odd total length (k odd):
+// all such keys lie in [HexToCompact(H||0), NextSubtree(HexToCompact(H||15))).
+func ContractTrunkKeyRanges(contractNibbles []byte) (evenFrom, evenTo, oddFrom, oddTo []byte) {
+ evenFrom = nibbles.HexToCompact(contractNibbles) // 0x00 || H, 33 bytes
+ evenTo = NextSubtree(evenFrom)
+
+ odd0 := make([]byte, 0, len(contractNibbles)+1)
+ odd0 = append(append(odd0, contractNibbles...), 0)
+ oddF := make([]byte, 0, len(contractNibbles)+1)
+ oddF = append(append(oddF, contractNibbles...), 15)
+ oddFrom = nibbles.HexToCompact(odd0)
+ oddTo = NextSubtree(nibbles.HexToCompact(oddF))
+ return evenFrom, evenTo, oddFrom, oddTo
+}
+
+// ContractNibbles expands a 32-byte hash to its 64-nibble path (high nibble first).
+func ContractNibbles(contractHash []byte) []byte {
+ out := make([]byte, len(contractHash)*2)
+ for i, b := range contractHash {
+ out[2*i] = b >> 4
+ out[2*i+1] = b & 0x0f
+ }
+ return out
+}
diff --git a/execution/commitment/trunk_pin_metrics.go b/execution/commitment/trunk_pin_metrics.go
new file mode 100644
index 00000000000..b16996278ee
--- /dev/null
+++ b/execution/commitment/trunk_pin_metrics.go
@@ -0,0 +1,44 @@
+// 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.
+
+package commitment
+
+import (
+ "github.com/erigontech/erigon/diagnostics/metrics"
+)
+
+// Per-contract labels omitted to keep cardinality bounded; per-contract
+// detail is in the [adaptive-pin] structured log line.
+
+var (
+ mxPinnedHits = metrics.GetOrCreateCounter("commitment_branchcache_pinned_hits_total")
+ mxPinnedMisses = metrics.GetOrCreateCounter("commitment_branchcache_pinned_misses_total")
+ mxPinnedEntries = metrics.GetOrCreateGauge("commitment_branchcache_pinned_entries")
+
+ mxAdaptivePromoted = metrics.GetOrCreateCounter("commitment_adaptive_pin_promoted_total")
+ mxAdaptiveExtended = metrics.GetOrCreateCounter("commitment_adaptive_pin_extended_total")
+ mxAdaptiveDemoted = metrics.GetOrCreateCounter("commitment_adaptive_pin_demoted_total")
+ mxAdaptiveActive = metrics.GetOrCreateGauge("commitment_adaptive_pin_active_contracts")
+
+ mxPreloadDurationSecondsTotal = metrics.GetOrCreateCounter("commitment_trunk_preload_duration_seconds_total")
+ mxPreloadBytesTotal = metrics.GetOrCreateCounter("commitment_trunk_preload_bytes_total")
+)
+
+// PublishMetrics emits counter deltas (last-published tracked internally) and
+// sets gauges absolute. Call once per SD.Flush — once-per-batch avoids hot-path cost.
+func (c *BranchCache) PublishMetrics() {
+ hits := c.pinnedHits.Load()
+ misses := c.pinnedMisses.Load()
+ if delta := hits - c.lastPublishedPinnedHits.Swap(hits); delta > 0 {
+ mxPinnedHits.AddUint64(delta)
+ }
+ if delta := misses - c.lastPublishedPinnedMisses.Swap(misses); delta > 0 {
+ mxPinnedMisses.AddUint64(delta)
+ }
+ mxPinnedEntries.SetUint64(uint64(c.pinned.Len()))
+}
diff --git a/execution/commitment/trunk_pin_test.go b/execution/commitment/trunk_pin_test.go
new file mode 100644
index 00000000000..e1c58b2dc35
--- /dev/null
+++ b/execution/commitment/trunk_pin_test.go
@@ -0,0 +1,475 @@
+// 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.
+
+package commitment
+
+import (
+ "context"
+ "encoding/binary"
+ "sync/atomic"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+// fakeReader returns a deterministic branch for any prefix that has a
+// 16-bit bitmap encoded after the 2-byte touchMap. The bitmap is
+// derived from the path so children are reproducible across runs.
+func fakeReader(saturated bool) CommitmentReader {
+ return func(prefix []byte) ([]byte, uint64, bool, error) {
+ // Synthesize a branch: 2 B touchMap (zero) || 2 B bitmap || nothing.
+ // bitmap: full (0xFFFF) when saturated; sparse (single child idx 0)
+ // otherwise — produces a narrow trunk that exhausts the BFS quickly.
+ var bitmap uint16
+ if saturated {
+ bitmap = 0xFFFF
+ } else {
+ bitmap = 0x0001
+ }
+ buf := make([]byte, 4)
+ binary.BigEndian.PutUint16(buf[2:4], bitmap)
+ return buf, 1, true, nil
+ }
+}
+
+// TestPinEntry_AndPinnedCount confirms basic pin-tier mechanics.
+func TestPinEntry_AndPinnedCount(t *testing.T) {
+ c := NewBranchCache(8)
+ require.Equal(t, 0, c.PinnedCount())
+ c.PinEntry([]byte{0x12, 0x34, 0x56}, []byte{0xab, 0xcd}, 1, "test")
+ require.Equal(t, 1, c.PinnedCount())
+
+ // Pinned entry is hit, not the tail.
+ data, _, ok := c.Get([]byte{0x12, 0x34, 0x56})
+ require.True(t, ok)
+ require.Equal(t, []byte{0xab, 0xcd}, data)
+ hits, _, _ := c.PinnedStats()
+ require.Equal(t, uint64(1), hits)
+}
+
+// TestInvalidate_RemovesFromPinned ensures Invalidate (now pin-aware)
+// deletes a pinned entry, not just LRU/root.
+func TestInvalidate_RemovesFromPinned(t *testing.T) {
+ c := NewBranchCache(8)
+ c.PinEntry([]byte{0x12, 0x34}, []byte{0x99}, 1, "test")
+ require.Equal(t, 1, c.PinnedCount())
+
+ c.Invalidate([]byte{0x12, 0x34})
+ require.Equal(t, 0, c.PinnedCount())
+ _, _, ok := c.Get([]byte{0x12, 0x34})
+ require.False(t, ok)
+}
+
+// TestGetWithOrigin_ChecksPinnedTier ensures GetWithOrigin reads
+// pinned entries (was a correctness gap pre-fix).
+func TestGetWithOrigin_ChecksPinnedTier(t *testing.T) {
+ c := NewBranchCache(8)
+ c.PinEntry([]byte{0x12, 0x34}, []byte{0xab}, 5, "preload")
+ data, origin, _, _, ok := c.GetWithOrigin([]byte{0x12, 0x34})
+ require.True(t, ok)
+ require.Equal(t, []byte{0xab}, data)
+ require.Equal(t, "preload", origin)
+}
+
+// TestClear_ResetsPinnedTier ensures Clear empties pinned + resets
+// pinned-stats counters (was a correctness gap pre-fix).
+func TestClear_ResetsPinnedTier(t *testing.T) {
+ c := NewBranchCache(8)
+ c.PinEntry([]byte{0x12, 0x34}, []byte{0xab}, 1, "test")
+ _, _, _ = c.Get([]byte{0x12, 0x34}) // bump pinned hit
+ c.Clear()
+ require.Equal(t, 0, c.PinnedCount())
+ hits, misses, _ := c.PinnedStats()
+ require.Equal(t, uint64(0), hits)
+ require.Equal(t, uint64(0), misses)
+}
+
+// TestMissCallback_FiresOnTripleMiss ensures the callback fires only
+// when ALL three tiers (root, pinned, LRU) miss — not on cache hits.
+func TestMissCallback_FiresOnTripleMiss(t *testing.T) {
+ c := NewBranchCache(8)
+ var fired atomic.Uint64
+ c.SetMissCallback(func(prefix []byte) {
+ fired.Add(1)
+ })
+
+ // Hit (pinned): callback should NOT fire.
+ c.PinEntry([]byte{0x12, 0x34}, []byte{0xff}, 1, "test")
+ _, _, _ = c.Get([]byte{0x12, 0x34})
+ require.Equal(t, uint64(0), fired.Load(), "pinned hit must not fire callback")
+
+ // Miss: callback fires.
+ _, _, _ = c.Get([]byte{0x99, 0x88, 0x77})
+ require.Equal(t, uint64(1), fired.Load(), "triple-miss must fire callback")
+
+ // Unbind: subsequent miss does not fire.
+ c.SetMissCallback(nil)
+ _, _, _ = c.Get([]byte{0xaa, 0xbb})
+ require.Equal(t, uint64(1), fired.Load(), "unbound callback must not fire")
+}
+
+// TestContractHashFromPrefix_DecodesStorageTrunk ensures the helper
+// extracts the contract hash from a 33+ B prefix and rejects shorter
+// account-trie prefixes.
+func TestContractHashFromPrefix_DecodesStorageTrunk(t *testing.T) {
+ // 33-byte prefix: 1 HP flag + 32 contract bytes.
+ prefix := make([]byte, 33)
+ prefix[0] = 0x00 // HP flag
+ for i := 1; i <= 32; i++ {
+ prefix[i] = byte(i)
+ }
+ hash, ok := ContractHashFromPrefix(prefix)
+ require.True(t, ok)
+ for i := 0; i < 32; i++ {
+ require.Equal(t, byte(i+1), hash[i])
+ }
+
+ // Short prefix: account-trie, must be rejected.
+ _, ok = ContractHashFromPrefix([]byte{0x00, 0x12, 0x34})
+ require.False(t, ok)
+}
+
+// TestContractTrunkPreload_PhasedRun confirms initial+extension semantics:
+// first Run consumes its budget, second Run continues from where it left.
+func TestContractTrunkPreload_PhasedRun(t *testing.T) {
+ c := NewBranchCache(64)
+ hash := make([]byte, 32)
+ for i := range hash {
+ hash[i] = byte(i)
+ }
+ p, err := NewContractTrunkPreload(hash)
+ require.NoError(t, err)
+
+ reader := fakeReader(false /* sparse */)
+
+ // Initial Run with tiny budget — pins the root and stops.
+ n1, queueEmpty, err := p.Run(1<<10, reader, c, nil)
+ require.NoError(t, err)
+ require.Greater(t, n1, 0, "initial Run must pin at least the root")
+ require.False(t, queueEmpty, "sparse trie shouldn't exhaust on tiny budget")
+ totalAfterInitial := p.PinnedTotal()
+ require.Equal(t, n1, totalAfterInitial)
+
+ // Extension Run continues from where it stopped.
+ n2, _, err := p.Run(1<<10, reader, c, nil)
+ require.NoError(t, err)
+ require.GreaterOrEqual(t, n2, 0)
+ require.Equal(t, totalAfterInitial+n2, p.PinnedTotal())
+
+ // Pinned prefixes are tracked for demotion.
+ require.Equal(t, p.PinnedTotal(), len(p.PinnedPrefixes()))
+}
+
+// TestAdaptivePinController_PromoteOnThreshold confirms a contract
+// crossing PromoteThresholdMisses gets promoted on the next
+// OnBlockComplete.
+func TestAdaptivePinController_PromoteOnThreshold(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 3
+ cfg.InitialViewBudgetBytes = 1 << 14 // 16 KiB; small but enough for sparse trie
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ // Forge a 33-byte prefix that decodes to a known contract hash.
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 1)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+
+ // Trigger 3 cache-misses for the same contract.
+ for i := 0; i < 3; i++ {
+ _, _, _ = c.Get(prefix)
+ }
+
+ // OnBlockComplete should promote the contract.
+ ctrl.OnBlockComplete(context.Background(), 1, fakeReader(false))
+ require.Len(t, ctrl.PromotedContracts(), 1)
+ require.Equal(t, contractHash, ctrl.PromotedContracts()[0])
+ require.Greater(t, c.PinnedCount(), 0)
+}
+
+// TestAdaptivePinController_DemoteOnCold confirms a promoted contract
+// gets demoted after DemoteCooldownBlocks consecutive blocks with no
+// misses.
+func TestAdaptivePinController_DemoteOnCold(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 1
+ cfg.DemoteCooldownBlocks = 2
+ cfg.InitialViewBudgetBytes = 1 << 14
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 7)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+
+ // Hot block: triggers promotion.
+ _, _, _ = c.Get(prefix)
+ ctrl.OnBlockComplete(context.Background(), 1, fakeReader(false))
+ require.Len(t, ctrl.PromotedContracts(), 1)
+ pinnedAfterPromote := c.PinnedCount()
+ require.Greater(t, pinnedAfterPromote, 0)
+
+ // Cold blocks: no misses for the contract. After cooldown the
+ // controller demotes and invalidates the pin set.
+ ctrl.OnBlockComplete(context.Background(), 2, fakeReader(false))
+ require.Len(t, ctrl.PromotedContracts(), 1, "still pinned after 1 cold block")
+
+ ctrl.OnBlockComplete(context.Background(), 3, fakeReader(false))
+ require.Len(t, ctrl.PromotedContracts(), 0, "demoted after 2 cold blocks")
+ require.Equal(t, 0, c.PinnedCount(), "pin set invalidated on demotion")
+}
+
+// --- Parallel-mode adaptive controller tests ---
+
+// makeParallelResolver returns a BatchBranchResolver paired with a usage
+// counter so tests can assert it was actually invoked.
+func makeParallelResolver(saturated bool, calls *int32) BatchBranchResolver {
+ return func(keys [][]byte) ([][]byte, error) {
+ atomic.AddInt32(calls, int32(len(keys)))
+ vals := make([][]byte, len(keys))
+ var bitmap uint16
+ if saturated {
+ bitmap = 0xFFFF
+ } else {
+ bitmap = 0x0001
+ }
+ for i := range keys {
+ buf := make([]byte, 4)
+ binary.BigEndian.PutUint16(buf[2:4], bitmap)
+ vals[i] = buf
+ }
+ return vals, nil
+ }
+}
+
+// TestAdaptivePinController_ParallelMode_PromoteUsesParallelResolver
+// confirms that with parallel mode installed, a freshly-promoted contract
+// uses the BatchBranchResolver (not the serial CommitmentReader). The
+// serial reader is wired to panic so we know if it's called.
+func TestAdaptivePinController_ParallelMode_PromoteUsesParallelResolver(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 3
+ cfg.InitialViewBudgetBytes = 1 << 14
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ var resolverCalls int32
+ resolver := makeParallelResolver(false, &resolverCalls)
+
+ dbBranchesCalls := 0
+ provider := func(contractHash []byte) map[string][]byte {
+ dbBranchesCalls++
+ return nil // file-only — let the resolver supply everything
+ }
+
+ factoryCalls := 0
+ factory := func() (BatchBranchResolver, func(), error) {
+ factoryCalls++
+ return resolver, nil, nil
+ }
+ ctrl.SetParallelMode(factory, provider)
+
+ // Forge a 33-byte prefix that decodes to a known contract hash.
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 1)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+
+ // Trigger 3 cache-misses for the same contract.
+ for i := 0; i < 3; i++ {
+ _, _, _ = c.Get(prefix)
+ }
+
+ // Wire a reader that fails the test if called.
+ panicReader := func(prefix []byte) ([]byte, uint64, bool, error) {
+ t.Fatalf("parallel mode must not fall back to serial reader; called with prefix=%x", prefix)
+ return nil, 0, false, nil
+ }
+
+ ctrl.OnBlockComplete(context.Background(), 1, panicReader)
+ require.Len(t, ctrl.PromotedContracts(), 1)
+ require.Equal(t, contractHash, ctrl.PromotedContracts()[0])
+ require.Greater(t, c.PinnedCount(), 0)
+ require.Equal(t, 1, factoryCalls, "factory should be called exactly once per OnBlockComplete")
+ require.Greater(t, atomic.LoadInt32(&resolverCalls), int32(0), "resolver should be called for the new contract")
+ require.GreaterOrEqual(t, dbBranchesCalls, 1, "dbBranchesProvider should be consulted for the new contract")
+}
+
+// TestAdaptivePinController_ParallelMode_ExtendUsesResumableState confirms
+// that the per-block extension reuses the same parallel state across calls
+// (state.parallel persists; PinnedTotal grows monotonically). To trigger
+// an extension miss for an already-promoted contract we Get a *deeper*
+// prefix than what the initial promotion pinned, otherwise the lookup
+// would hit the pinned tier and never invoke the miss callback.
+func TestAdaptivePinController_ParallelMode_ExtendUsesResumableState(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 1
+ cfg.InitialViewBudgetBytes = 1 << 12 // 4 KiB — small initial view
+ cfg.ExtensionBudgetBytes = 1 << 12 // 4 KiB per extension step
+ cfg.PerContractMaxBudgetBytes = 1 << 20
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ var resolverCalls int32
+ resolver := makeParallelResolver(true /* full bitmap — many children */, &resolverCalls)
+ factory := func() (BatchBranchResolver, func(), error) {
+ return resolver, nil, nil
+ }
+ ctrl.SetParallelMode(factory, nil)
+
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 13)
+ }
+ rootPrefix := append([]byte{0x00}, contractHash[:]...)
+ // deepPrefix has the contract hash in bytes 1..33 (so the miss
+ // callback attributes it to our contract) plus extra trailing bytes
+ // so it's not the same key as anything the preload could pin.
+ makeDeepPrefix := func(seed byte) []byte {
+ p := make([]byte, 50)
+ p[0] = 0x10 // HP flag for odd-depth path
+ copy(p[1:33], contractHash[:])
+ for i := 33; i < 50; i++ {
+ p[i] = byte(i*7) ^ seed
+ }
+ return p
+ }
+
+ // Block 1: trigger miss on the root path, promote.
+ _, _, _ = c.Get(rootPrefix)
+ ctrl.OnBlockComplete(context.Background(), 1, nil)
+ require.Len(t, ctrl.PromotedContracts(), 1)
+ pinnedAfterPromote := c.PinnedCount()
+ require.Greater(t, pinnedAfterPromote, 0)
+
+ // Block 2: miss on a DEEPER prefix in the same contract's subtree —
+ // not yet pinned, so the miss callback fires. Extension fires.
+ _, _, _ = c.Get(makeDeepPrefix(0x11))
+ ctrl.OnBlockComplete(context.Background(), 2, nil)
+ pinnedAfterExt1 := c.PinnedCount()
+ require.GreaterOrEqual(t, pinnedAfterExt1, pinnedAfterPromote, "extension should not shrink the pin set")
+ require.Greater(t, pinnedAfterExt1, pinnedAfterPromote, "extension should add at least one entry under a saturated trie")
+
+ // Block 3: another extension via a different deep prefix.
+ _, _, _ = c.Get(makeDeepPrefix(0x22))
+ ctrl.OnBlockComplete(context.Background(), 3, nil)
+ pinnedAfterExt2 := c.PinnedCount()
+ require.GreaterOrEqual(t, pinnedAfterExt2, pinnedAfterExt1)
+}
+
+// TestAdaptivePinController_ParallelMode_FactoryErrorFallsBackToSerial
+// confirms that when the parallel factory returns an error, the controller
+// falls back to the serial CommitmentReader path without crashing.
+func TestAdaptivePinController_ParallelMode_FactoryErrorFallsBackToSerial(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 1
+ cfg.InitialViewBudgetBytes = 1 << 14
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ factory := func() (BatchBranchResolver, func(), error) {
+ return nil, nil, errFakeFactory
+ }
+ ctrl.SetParallelMode(factory, nil)
+
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 21)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+ _, _, _ = c.Get(prefix)
+
+ ctrl.OnBlockComplete(context.Background(), 1, fakeReader(false))
+ require.Len(t, ctrl.PromotedContracts(), 1, "should still promote via serial fallback")
+}
+
+// TestAdaptivePinController_ParallelMode_DemoteInvalidates confirms that
+// demotion invalidates the parallel-state pin set the same as serial.
+func TestAdaptivePinController_ParallelMode_DemoteInvalidates(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 1
+ cfg.DemoteCooldownBlocks = 2
+ cfg.InitialViewBudgetBytes = 1 << 14
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ var resolverCalls int32
+ resolver := makeParallelResolver(false, &resolverCalls)
+ factory := func() (BatchBranchResolver, func(), error) { return resolver, nil, nil }
+ ctrl.SetParallelMode(factory, nil)
+
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 29)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+
+ // Promote.
+ _, _, _ = c.Get(prefix)
+ ctrl.OnBlockComplete(context.Background(), 1, nil)
+ require.Len(t, ctrl.PromotedContracts(), 1)
+ require.Greater(t, c.PinnedCount(), 0)
+
+ // Cold blocks — demote on 2nd.
+ ctrl.OnBlockComplete(context.Background(), 2, nil)
+ ctrl.OnBlockComplete(context.Background(), 3, nil)
+ require.Len(t, ctrl.PromotedContracts(), 0, "demoted after 2 cold blocks")
+ require.Equal(t, 0, c.PinnedCount(), "parallel-state pin set invalidated on demotion")
+}
+
+// TestAdaptivePinController_ParallelMode_ReleaseCallbackInvoked confirms
+// that a non-nil release callback returned by the factory is called once
+// per OnBlockComplete (so tx-scoped resources can be cleaned up).
+func TestAdaptivePinController_ParallelMode_ReleaseCallbackInvoked(t *testing.T) {
+ c := NewBranchCache(64)
+ cfg := DefaultAdaptivePinControllerConfig()
+ cfg.PromoteThresholdMisses = 1
+ cfg.InitialViewBudgetBytes = 1 << 14
+ ctrl := NewAdaptivePinController(c, cfg, nil)
+ ctrl.Bind()
+
+ var resolverCalls int32
+ resolver := makeParallelResolver(false, &resolverCalls)
+ var releaseCount int32
+ factory := func() (BatchBranchResolver, func(), error) {
+ return resolver, func() { atomic.AddInt32(&releaseCount, 1) }, nil
+ }
+ ctrl.SetParallelMode(factory, nil)
+
+ contractHash := [32]byte{}
+ for i := range contractHash {
+ contractHash[i] = byte(i + 33)
+ }
+ prefix := append([]byte{0x00}, contractHash[:]...)
+ _, _, _ = c.Get(prefix)
+
+ ctrl.OnBlockComplete(context.Background(), 1, nil)
+ require.Equal(t, int32(1), atomic.LoadInt32(&releaseCount), "release callback called once per OnBlockComplete")
+
+ // No misses next block — release still called (factory is invoked per block
+ // regardless of whether the controller pins anything).
+ ctrl.OnBlockComplete(context.Background(), 2, nil)
+ require.Equal(t, int32(2), atomic.LoadInt32(&releaseCount))
+}
+
+var errFakeFactory = stringError("fake factory failure")
+
+type stringError string
+
+func (s stringError) Error() string { return string(s) }
diff --git a/execution/commitment/warmup_cache.go b/execution/commitment/warmup_cache.go
deleted file mode 100644
index d6389a350d9..00000000000
--- a/execution/commitment/warmup_cache.go
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright 2024 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 .
-
-package commitment
-
-import (
- "sync/atomic"
-
- "github.com/erigontech/erigon/common/maphash"
-)
-
-type branchEntry struct {
- data []byte
- isEvicted atomic.Bool
-}
-
-type accountEntry struct {
- update *Update
- isEvicted atomic.Bool
-}
-
-type storageEntry struct {
- update *Update
- isEvicted atomic.Bool
-}
-
-// WarmupCache stores pre-fetched data from the warmup phase to avoid
-// repeated DB reads during trie processing. Uses maphash.Map for efficient
-// byte slice key lookups without string allocations.
-type WarmupCache struct {
- branches *maphash.Map[*branchEntry]
- accounts *maphash.Map[*accountEntry]
- storage *maphash.Map[*storageEntry]
-}
-
-// NewWarmupCache creates a new warmup cache instance.
-func NewWarmupCache() *WarmupCache {
- return &WarmupCache{
- branches: maphash.NewMap[*branchEntry](),
- accounts: maphash.NewMap[*accountEntry](),
- storage: maphash.NewMap[*storageEntry](),
- }
-}
-
-// PutBranch stores branch data in the cache.
-func (c *WarmupCache) PutBranch(prefix []byte, data []byte) {
- // Make a copy of the data to avoid issues with buffer reuse
- dataCopy := make([]byte, len(data))
- copy(dataCopy, data)
-
- c.branches.Set(prefix, &branchEntry{data: dataCopy})
-}
-
-// GetBranch retrieves branch data from the cache.
-func (c *WarmupCache) GetBranch(prefix []byte) ([]byte, bool) {
- entry, found := c.branches.Get(prefix)
- if !found || entry.isEvicted.Load() {
- return nil, false
- }
- return entry.data, true
-}
-
-// GetAndEvictBranch retrieves branch data and marks the entry as evicted in one operation.
-func (c *WarmupCache) GetAndEvictBranch(prefix []byte) ([]byte, bool) {
- entry, found := c.branches.Get(prefix)
- if !found || entry.isEvicted.Load() {
- return nil, false
- }
- entry.isEvicted.Store(true)
- return entry.data, true
-}
-
-// EvictBranch marks a branch entry as evicted without retrieving it.
-func (c *WarmupCache) EvictBranch(prefix []byte) {
- entry, found := c.branches.Get(prefix)
- if found {
- entry.isEvicted.Store(true)
- }
-}
-
-// PutAccount stores account data in the cache.
-func (c *WarmupCache) PutAccount(plainKey []byte, update *Update) {
- var updateCopy *Update
- if update != nil {
- updateCopy = update.Copy()
- }
-
- c.accounts.Set(plainKey, &accountEntry{update: updateCopy})
-}
-
-// GetAccount retrieves account data from the cache.
-func (c *WarmupCache) GetAccount(plainKey []byte) (*Update, bool) {
- entry, found := c.accounts.Get(plainKey)
- if !found || entry.isEvicted.Load() {
- return nil, false
- }
- return entry.update, true
-}
-
-// GetAndEvictAccount retrieves account data and marks the entry as evicted in one operation.
-// Returns the entry pointer allowing the caller to read the data before it's considered evicted.
-func (c *WarmupCache) GetAndEvictAccount(plainKey []byte) *accountEntry {
- entry, found := c.accounts.Get(plainKey)
- if !found || entry.isEvicted.Load() {
- return nil
- }
- entry.isEvicted.Store(true)
- return entry
-}
-
-// EvictAccount marks an account entry as evicted without retrieving it.
-func (c *WarmupCache) EvictAccount(plainKey []byte) {
- entry, found := c.accounts.Get(plainKey)
- if found {
- entry.isEvicted.Store(true)
- }
-}
-
-// PutStorage stores storage data in the cache.
-func (c *WarmupCache) PutStorage(plainKey []byte, update *Update) {
- var updateCopy *Update
- if update != nil {
- updateCopy = update.Copy()
- }
-
- c.storage.Set(plainKey, &storageEntry{update: updateCopy})
-}
-
-// GetStorage retrieves storage data from the cache.
-func (c *WarmupCache) GetStorage(plainKey []byte) (*Update, bool) {
- entry, found := c.storage.Get(plainKey)
- if !found || entry.isEvicted.Load() {
- return nil, false
- }
- return entry.update, true
-}
-
-// GetAndEvictStorage retrieves storage data and marks the entry as evicted in one operation.
-// Returns the entry pointer allowing the caller to read the data before it's considered evicted.
-func (c *WarmupCache) GetAndEvictStorage(plainKey []byte) *storageEntry {
- entry, found := c.storage.Get(plainKey)
- if !found || entry.isEvicted.Load() {
- return nil
- }
- entry.isEvicted.Store(true)
- return entry
-}
-
-// EvictStorage marks a storage entry as evicted without retrieving it.
-func (c *WarmupCache) EvictStorage(plainKey []byte) {
- entry, found := c.storage.Get(plainKey)
- if found {
- entry.isEvicted.Store(true)
- }
-}
-
-// EvictPlainKey evicts a key from both accounts and storage caches.
-// Use this when you don't know if the key is an account or storage key.
-func (c *WarmupCache) EvictPlainKey(plainKey []byte) {
- if entry, found := c.accounts.Get(plainKey); found {
- entry.isEvicted.Store(true)
- }
- if entry, found := c.storage.Get(plainKey); found {
- entry.isEvicted.Store(true)
- }
-}
-
-// Clear clears all cached data.
-func (c *WarmupCache) Clear() {
- c.branches = maphash.NewMap[*branchEntry]()
- c.accounts = maphash.NewMap[*accountEntry]()
- c.storage = maphash.NewMap[*storageEntry]()
-}
diff --git a/execution/commitment/warmup_cache_test.go b/execution/commitment/warmup_cache_test.go
deleted file mode 100644
index 43de177ee06..00000000000
--- a/execution/commitment/warmup_cache_test.go
+++ /dev/null
@@ -1,303 +0,0 @@
-// 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 .
-
-package commitment
-
-import (
- "bytes"
- "crypto/rand"
- "testing"
-)
-
-// TestWarmupCache_Basic tests basic put/get operations
-func TestWarmupCache_Basic(t *testing.T) {
- cache := NewWarmupCache()
-
- // Test branch operations
- branchKey := []byte("test-branch-key-12345678901234567890")
- branchData := []byte("branch-data-content")
- cache.PutBranch(branchKey, branchData)
-
- gotData, found := cache.GetBranch(branchKey)
- if !found {
- t.Fatal("expected to find branch")
- }
- if !bytes.Equal(gotData, branchData) {
- t.Errorf("branch data mismatch: got %x, want %x", gotData, branchData)
- }
-
- // Test account operations
- accountKey := []byte("12345678901234567890") // 20 bytes
- accountUpdate := &Update{Flags: BalanceUpdate}
- cache.PutAccount(accountKey, accountUpdate)
-
- gotUpdate, found := cache.GetAccount(accountKey)
- if !found {
- t.Fatal("expected to find account")
- }
- if gotUpdate.Flags != BalanceUpdate {
- t.Errorf("account flags mismatch: got %v, want %v", gotUpdate.Flags, BalanceUpdate)
- }
-
- // Test storage operations
- storageKey := make([]byte, 52)
- rand.Read(storageKey)
- storageUpdate := &Update{Flags: StorageUpdate, StorageLen: 5}
- copy(storageUpdate.Storage[:], "hello")
- cache.PutStorage(storageKey, storageUpdate)
-
- gotStorage, found := cache.GetStorage(storageKey)
- if !found {
- t.Fatal("expected to find storage")
- }
- if gotStorage.Flags != StorageUpdate {
- t.Errorf("storage flags mismatch")
- }
- if !bytes.Equal(gotStorage.Storage[:gotStorage.StorageLen], []byte("hello")) {
- t.Errorf("storage data mismatch")
- }
-}
-
-// TestWarmupCache_NotFound tests cache misses
-func TestWarmupCache_NotFound(t *testing.T) {
- cache := NewWarmupCache()
-
- _, found := cache.GetBranch([]byte("nonexistent"))
- if found {
- t.Error("expected not to find nonexistent branch")
- }
-
- _, found = cache.GetAccount([]byte("12345678901234567890"))
- if found {
- t.Error("expected not to find nonexistent account")
- }
-
- _, found = cache.GetStorage(make([]byte, 52))
- if found {
- t.Error("expected not to find nonexistent storage")
- }
-}
-
-// TestWarmupCache_Eviction tests key eviction
-func TestWarmupCache_Eviction(t *testing.T) {
- cache := NewWarmupCache()
-
- key := []byte("12345678901234567890")
- cache.PutAccount(key, &Update{Flags: BalanceUpdate})
-
- // Should find before eviction
- _, found := cache.GetAccount(key)
- if !found {
- t.Fatal("expected to find account before eviction")
- }
-
- // Evict the key
- cache.EvictAccount(key)
-
- // Should not find after eviction
- _, found = cache.GetAccount(key)
- if found {
- t.Error("expected not to find account after eviction")
- }
-}
-
-// TestWarmupCache_Clear tests clearing the cache
-func TestWarmupCache_Clear(t *testing.T) {
- cache := NewWarmupCache()
-
- // Add some data
- cache.PutAccount([]byte("12345678901234567890"), &Update{})
- cache.PutStorage(make([]byte, 52), &Update{})
- cache.PutBranch([]byte("branch"), []byte("data"))
-
- // Clear
- cache.Clear()
-
- // Should not find anything
- _, found := cache.GetAccount([]byte("12345678901234567890"))
- if found {
- t.Error("expected not to find account after clear")
- }
-}
-
-// TestWarmupCache_KeyPadding tests that shorter keys work correctly
-func TestWarmupCache_KeyPadding(t *testing.T) {
- cache := NewWarmupCache()
-
- // Test with a key shorter than the fixed size
- shortKey := []byte("short")
- cache.PutBranch(shortKey, []byte("data"))
-
- gotData, found := cache.GetBranch(shortKey)
- if !found {
- t.Fatal("expected to find branch with short key")
- }
- if !bytes.Equal(gotData, []byte("data")) {
- t.Errorf("data mismatch")
- }
-
- // Ensure different short keys don't collide
- shortKey2 := []byte("other")
- cache.PutBranch(shortKey2, []byte("data2"))
-
- gotData, found = cache.GetBranch(shortKey)
- if !found || !bytes.Equal(gotData, []byte("data")) {
- t.Error("first key affected by second key")
- }
-
- gotData2, found := cache.GetBranch(shortKey2)
- if !found || !bytes.Equal(gotData2, []byte("data2")) {
- t.Error("second key not found or wrong data")
- }
-}
-
-// generateTestKeys creates random keys of the given size
-func generateTestKeys(n int, size int) [][]byte {
- keys := make([][]byte, n)
- for i := 0; i < n; i++ {
- keys[i] = make([]byte, size)
- rand.Read(keys[i])
- }
- return keys
-}
-
-// BenchmarkWarmupCache_Branch benchmarks branch key operations
-func BenchmarkWarmupCache_Branch(b *testing.B) {
- cache := NewWarmupCache()
-
- // Pre-populate with some data
- keys := generateTestKeys(10000, 52)
- data := make([]byte, 100)
- rand.Read(data)
-
- for _, key := range keys {
- cache.PutBranch(key, data)
- }
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- key := keys[i%len(keys)]
- cache.GetBranch(key)
- }
-}
-
-// BenchmarkWarmupCache_Branch_Put benchmarks Put operations
-func BenchmarkWarmupCache_Branch_Put(b *testing.B) {
- cache := NewWarmupCache()
- const keyCount = 10000
- keys := generateTestKeys(keyCount, 52)
- data := make([]byte, 100)
- rand.Read(data)
-
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- cache.PutBranch(keys[i%keyCount], data)
- }
-}
-
-// BenchmarkWarmupCache_Account benchmarks account key operations (20 bytes)
-func BenchmarkWarmupCache_Account(b *testing.B) {
- cache := NewWarmupCache()
- keys := generateTestKeys(10000, 20)
- update := &Update{}
-
- for _, key := range keys {
- cache.PutAccount(key, update)
- }
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- key := keys[i%len(keys)]
- cache.GetAccount(key)
- }
-}
-
-// BenchmarkWarmupCache_Storage benchmarks storage key operations (52 bytes)
-func BenchmarkWarmupCache_Storage(b *testing.B) {
- cache := NewWarmupCache()
- keys := generateTestKeys(10000, 52)
- update := &Update{}
-
- for _, key := range keys {
- cache.PutStorage(key, update)
- }
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- key := keys[i%len(keys)]
- cache.GetStorage(key)
- }
-}
-
-// BenchmarkWarmupCache_Mixed simulates realistic mixed workload
-func BenchmarkWarmupCache_Mixed(b *testing.B) {
- cache := NewWarmupCache()
- accountKeys := generateTestKeys(1000, 20)
- storageKeys := generateTestKeys(5000, 52)
- branchKeys := generateTestKeys(2000, 32)
- update := &Update{}
- data := make([]byte, 100)
-
- // Pre-populate
- for _, key := range accountKeys {
- cache.PutAccount(key, update)
- }
- for _, key := range storageKeys {
- cache.PutStorage(key, update)
- }
- for _, key := range branchKeys {
- cache.PutBranch(key, data)
- }
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- switch i % 3 {
- case 0:
- cache.GetAccount(accountKeys[i%len(accountKeys)])
- case 1:
- cache.GetStorage(storageKeys[i%len(storageKeys)])
- case 2:
- cache.GetBranch(branchKeys[i%len(branchKeys)])
- }
- }
-}
-
-// BenchmarkComparison_Map_100k benchmarks map with 100k entries
-func BenchmarkComparison_Map_100k(b *testing.B) {
- cache := NewWarmupCache()
- keys := generateTestKeys(100000, 52)
- update := &Update{}
-
- for _, key := range keys {
- cache.PutStorage(key, update)
- }
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; b.Loop(); i++ {
- cache.GetStorage(keys[i%len(keys)])
- }
-}
diff --git a/execution/commitment/warmuper.go b/execution/commitment/warmuper.go
index ee25cfb3cc1..c0660d1df93 100644
--- a/execution/commitment/warmuper.go
+++ b/execution/commitment/warmuper.go
@@ -30,18 +30,31 @@ import (
"github.com/erigontech/erigon/execution/commitment/nibbles"
)
+// Warmer branch-read outcome counters. Hit: branchFromCacheOrDB returned
+// >= 4 bytes; Empty: returned nothing or unparseable. Used to size the
+// value of bypassing the xorfilter in this call path.
+var (
+ warmerBranchHitCount atomic.Uint64
+ warmerBranchEmptyCount atomic.Uint64
+)
+
+// WarmerBranchOutcomeStats returns process-cumulative counts. Snapshot
+// before/after for per-block deltas.
+func WarmerBranchOutcomeStats() (hit, empty uint64) {
+ return warmerBranchHitCount.Load(), warmerBranchEmptyCount.Load()
+}
+
// TrieContextFactory creates new PatriciaContext instances for parallel warmup.
type TrieContextFactory func() (PatriciaContext, func())
// WarmupConfig contains configuration for pre-warming MDBX page cache
// during commitment processing.
type WarmupConfig struct {
- Enabled bool
- EnableWarmupCache bool // If true, cache warmed data for use during trie processing
- CtxFactory TrieContextFactory
- NumWorkers int
- MaxDepth int
- LogPrefix string
+ Enabled bool
+ CtxFactory TrieContextFactory
+ NumWorkers int
+ MaxDepth int
+ LogPrefix string
}
const WarmupMaxDepth = 128 // covers full key paths for both account keys (64 nibbles) and storage keys (128 nibbles)
@@ -66,9 +79,6 @@ type Warmuper struct {
// Worker group
g *errgroup.Group
- // Cache for storing warmed data to be used during trie processing
- cache *WarmupCache
-
// Stats
keysProcessed atomic.Uint64
startTime time.Time
@@ -86,7 +96,7 @@ type warmupWorkItem struct {
// NewWarmuper creates a new Warmuper instance.
func NewWarmuper(ctx context.Context, cfg WarmupConfig) *Warmuper {
ctx, cancel := context.WithCancel(ctx)
- w := &Warmuper{
+ return &Warmuper{
ctx: ctx,
cancel: cancel,
ctxFactory: cfg.CtxFactory,
@@ -94,66 +104,11 @@ func NewWarmuper(ctx context.Context, cfg WarmupConfig) *Warmuper {
numWorkers: cfg.NumWorkers,
logPrefix: cfg.LogPrefix,
}
- if cfg.EnableWarmupCache {
- w.cache = NewWarmupCache()
- }
- return w
-}
-
-// Cache returns the warmup cache, or nil if caching is disabled.
-func (w *Warmuper) Cache() *WarmupCache {
- return w.cache
}
-// branchFromCacheOrDB reads branch data from cache if available, otherwise from DB and caches it.
func (w *Warmuper) branchFromCacheOrDB(trieCtx PatriciaContext, prefix []byte) ([]byte, error) {
- if w.cache != nil {
- if data, found := w.cache.GetBranch(prefix); found {
- return data, nil
- }
- }
branchData, _, err := trieCtx.Branch(prefix)
- if err != nil {
- return nil, err
- }
- if w.cache != nil && len(branchData) > 0 {
- w.cache.PutBranch(prefix, branchData)
- }
- return branchData, nil
-}
-
-// accountFromCacheOrDB reads account data from cache if available, otherwise from DB and caches it.
-func (w *Warmuper) accountFromCacheOrDB(trieCtx PatriciaContext, plainKey []byte) (*Update, error) {
- if w.cache != nil {
- if update, found := w.cache.GetAccount(plainKey); found {
- return update, nil
- }
- }
- update, err := trieCtx.Account(plainKey)
- if err != nil {
- return nil, err
- }
- if w.cache != nil {
- w.cache.PutAccount(plainKey, update)
- }
- return update, nil
-}
-
-// storageFromCacheOrDB reads storage data from cache if available, otherwise from DB and caches it.
-func (w *Warmuper) storageFromCacheOrDB(trieCtx PatriciaContext, plainKey []byte) (*Update, error) {
- if w.cache != nil {
- if update, found := w.cache.GetStorage(plainKey); found {
- return update, nil
- }
- }
- update, err := trieCtx.Storage(plainKey)
- if err != nil {
- return nil, err
- }
- if w.cache != nil {
- w.cache.PutStorage(plainKey, update)
- }
- return update, nil
+ return branchData, err
}
// Start initializes and starts the warmup workers.
@@ -207,23 +162,16 @@ func (w *Warmuper) warmupKey(trieCtx PatriciaContext, hashedKey []byte, startDep
// Branch data format: 2-byte touch map + 2-byte bitmap + per-child data
if len(branchData) < 4 {
+ warmerBranchEmptyCount.Add(1)
break
}
+ warmerBranchHitCount.Add(1)
if depth >= len(hashedKey) {
break
}
nextNibble := int(hashedKey[depth])
- // Extract and prefetch account/storage addresses to warm page cache
- cellAccounts, cellStorages := extractBranchCellAddresses(branchData, nextNibble)
- for _, addr := range cellAccounts {
- _, _ = w.accountFromCacheOrDB(trieCtx, addr)
- }
- for _, addr := range cellStorages {
- _, _ = w.storageFromCacheOrDB(trieCtx, addr)
- }
-
branchData = branchData[2:] // skip touch map
bitmap := binary.BigEndian.Uint16(branchData[0:2])
@@ -253,6 +201,12 @@ func (w *Warmuper) warmupKey(trieCtx PatriciaContext, hashedKey []byte, startDep
fieldBits := branchData[pos]
pos++
+ // Leaf cell — no branch below. Without this the warmer pays one
+ // extra recsplit + xorfilter lookup per leaf-terminating path.
+ if cellFields(fieldBits)&(fieldAccountAddr|fieldStorageAddr) != 0 {
+ break
+ }
+
// Check if child has extension
hasExtension := (fieldBits & 1) != 0
if hasExtension && pos < len(branchData) {
diff --git a/execution/engineapi/engine_block_downloader/block_downloader.go b/execution/engineapi/engine_block_downloader/block_downloader.go
index d2a124e7a11..7b05688632b 100644
--- a/execution/engineapi/engine_block_downloader/block_downloader.go
+++ b/execution/engineapi/engine_block_downloader/block_downloader.go
@@ -31,6 +31,7 @@ import (
"github.com/erigontech/erigon/common/math"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/execmodule"
"github.com/erigontech/erigon/execution/execmodule/chainreader"
@@ -236,6 +237,17 @@ func (e *EngineBlockDownloader) downloadBlocks(ctx context.Context, req Backward
default:
e.logger.Trace("[EngineBlockDownloader] processing downloaded blocks", progressLogArgs...)
}
+ // Opportunistically ask eth/71 peers for the BALs of the blocks we
+ // just downloaded so they're cached by the time the exec stage
+ // runs (avoids local BAL regeneration). Non-blocking: if no
+ // eth/71 peer is available or the request fails, the cache miss
+ // will fall through to the BALRegenerator later.
+ if fetcher := balcache.GetBALSyncFetcher(); fetcher != nil {
+ hashes, numbers, expected := collectBALFetchRequests(blocks)
+ if len(hashes) > 0 {
+ go fetcher.FetchBALs(ctx, hashes, numbers, expected)
+ }
+ }
err := e.chainRW.InsertBlocksAndWait(ctx, blocks)
if err != nil {
return err
@@ -310,3 +322,20 @@ func (e *EngineBlockDownloader) execDownloadedBatch(ctx context.Context, block *
}
return nil
}
+
+// collectBALFetchRequests pulls (hash, number, expectedBALHash) for every
+// block in the batch whose header commits to a BAL. Pre-Amsterdam blocks
+// (BlockAccessListHash == nil) are skipped. Returns positionally-aligned
+// slices for balcache.BALSyncFetcher.FetchBALs.
+func collectBALFetchRequests(blocks []*types.Block) (hashes []common.Hash, numbers []uint64, expected []common.Hash) {
+ for _, b := range blocks {
+ h := b.HeaderNoCopy()
+ if h == nil || h.BlockAccessListHash == nil {
+ continue
+ }
+ hashes = append(hashes, b.Hash())
+ numbers = append(numbers, b.NumberU64())
+ expected = append(expected, *h.BlockAccessListHash)
+ }
+ return
+}
diff --git a/execution/engineapi/engine_server.go b/execution/engineapi/engine_server.go
index e25ff7fe90e..30557da96a3 100644
--- a/execution/engineapi/engine_server.go
+++ b/execution/engineapi/engine_server.go
@@ -43,6 +43,7 @@ import (
"github.com/erigontech/erigon/db/kv/kvcache"
"github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/builder"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/engineapi/engine_block_downloader"
@@ -930,6 +931,16 @@ func (e *EngineServer) HandleNewPayload(
) (*engine_types.PayloadStatus, error) {
e.engineLogSpamer.RecordRequest()
+ // Cache the incoming BAL bytes so downstream consumers (read-ahead,
+ // validation, BAL-driven workers) can fetch from process memory without
+ // touching MDBX. The bal-cache architecture removes the BAL from MDBX
+ // on the NewPayload critical path entirely; mirrors the design pattern
+ // from mh/perf-bal-cache so the eventual refactor is a wiring move,
+ // not a redesign.
+ if len(blockAccessListBytes) > 0 {
+ balcache.CacheBlockAccessList(block.Hash(), blockAccessListBytes)
+ }
+
header := block.Header()
headerNumber := header.Number.Uint64()
headerHash := block.Hash()
diff --git a/execution/engineapi/engine_server_test.go b/execution/engineapi/engine_server_test.go
index 81f557108f0..df9b8357b2d 100644
--- a/execution/engineapi/engine_server_test.go
+++ b/execution/engineapi/engine_server_test.go
@@ -37,6 +37,7 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/kvcache"
"github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/execmodule/execmoduletester"
"github.com/erigontech/erigon/execution/tests/blockgen"
@@ -302,12 +303,13 @@ func canonicalHashAt(t *testing.T, db kv.TemporalRoDB, blockNum uint64) common.H
return hash
}
-func writeBlockAccessListBytes(t *testing.T, db kv.TemporalRwDB, blockHash common.Hash, blockNum uint64, balBytes []byte) {
+func writeBlockAccessListBytes(t *testing.T, _ kv.TemporalRwDB, blockHash common.Hash, _ uint64, balBytes []byte) {
t.Helper()
- err := db.Update(context.Background(), func(tx kv.RwTx) error {
- return rawdb.WriteBlockAccessListBytes(tx, blockHash, blockNum, balBytes)
- })
- require.NoError(t, err)
+ // BALs are cache-only — the lookup path consults
+ // balcache.BlockAccessListBytes which checks the cache first, then the
+ // regenerator. Writing directly to the cache makes the test see exactly
+ // these bytes without re-executing.
+ balcache.CacheBlockAccessList(blockHash, balBytes)
}
func TestGetPayloadBodiesByHashV2(t *testing.T) {
diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go
index 229b8de1a6c..ea31c7a83ae 100644
--- a/execution/exec/blocks_read_ahead.go
+++ b/execution/exec/blocks_read_ahead.go
@@ -14,8 +14,9 @@ import (
"github.com/erigontech/erigon/common/length"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
- "github.com/erigontech/erigon/db/kv/dbutils"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/balcache"
+ "github.com/erigontech/erigon/execution/cache"
"github.com/erigontech/erigon/execution/protocol/rules"
"github.com/erigontech/erigon/execution/state"
"github.com/erigontech/erigon/execution/types"
@@ -31,6 +32,14 @@ type BlockReadAheader struct {
// this is for warming state
warming atomic.Bool // only one warmBody can run at a time
warmWg sync.WaitGroup
+
+ // stateCache is the process-global state cache that SharedDomains.GetLatest
+ // consults on the EVM hot path. When set, warmBody routes its prefetches
+ // through a cache-populating getter so the same hashmap the EVM probes is
+ // pre-warmed. Without it, prefetches only warm OS page cache + RoTx
+ // cursors — disconnected from the cache layer the EVM actually reads.
+ // Mirrors reth's CachedReads / ExecutionCache "same hashmap" property.
+ stateCache *cache.StateCache
}
func NewBlockReadAheader() *BlockReadAheader {
@@ -53,6 +62,69 @@ func NewBlockReadAheader() *BlockReadAheader {
}
}
+// SetStateCache wires the process-global state cache so warmBody's
+// prefetches land in the same hashmap that SharedDomains.GetLatest probes
+// on the EVM hot path. Without this, prefetches warm OS page cache only —
+// the EVM still pays the file accessor stack on its first per-address read.
+// Idempotent; safe to call before the first AddHeaderAndBody.
+func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) {
+ bra.stateCache = sc
+}
+
+// cachePopulatingGetter wraps a kv.TemporalGetter and writes successful
+// reads through to a cache.StateCache as a side effect. Used by warmBody
+// to make read-ahead prefetches populate the same in-process cache layer
+// that SharedDomains.GetLatest consults — eliminating the file-accessor
+// stack cost on the EVM's first touch of any prefetched address.
+//
+// For the CodeDomain, when the code bytes come back together with the
+// owning account's codeHash (decoded from a preceding AccountsDomain read
+// in the same loop iteration), the wrapper also populates the L2b
+// ethHash→bytes + size-cache layers via PutCodeWithHash. The codeHash
+// hint is provided per-iteration via withCodeHashHint().
+type cachePopulatingGetter struct {
+ g kv.TemporalGetter
+ sc *cache.StateCache
+ stepSize uint64 // for the read txNum upper bound (last txNum of the read's step)
+ codeHashHint []byte // valid only for the next CodeDomain read; cleared after use
+}
+
+func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) {
+ v, step, err := cpg.g.GetLatest(name, k)
+ if err == nil && cpg.sc != nil {
+ if name == kv.CodeDomain && len(cpg.codeHashHint) > 0 {
+ cpg.sc.PutCodeWithHash(k, v, cpg.codeHashHint)
+ cpg.codeHashHint = nil
+ } else {
+ // Cache including nil/empty results: a probe returning no
+ // bytes is a valid negative answer (missing account, empty
+ // storage slot, no code) and caching it lets repeated probes
+ // skip the file accessor stack. Mirrors revm's CacheAccount
+ // { account: None, status: LoadedNotExisting } pattern.
+ // Stamp with an upper bound on the value's write txNum (last txNum
+ // of the step it came from) so unwind invalidation is correct.
+ cpg.sc.Put(name, k, v, (uint64(step)+1)*cpg.stepSize-1)
+ }
+ }
+ return v, step, err
+}
+
+func (cpg *cachePopulatingGetter) HasPrefix(name kv.Domain, prefix []byte) ([]byte, []byte, bool, error) {
+ return cpg.g.HasPrefix(name, prefix)
+}
+
+func (cpg *cachePopulatingGetter) StepsInFiles(entitySet ...kv.Domain) kv.Step {
+ return cpg.g.StepsInFiles(entitySet...)
+}
+
+// withCodeHashHint stashes the codeHash so the next CodeDomain read routes
+// through PutCodeWithHash (populating L2b + size cache) instead of a bare
+// addr-keyed Put. Caller MUST follow this with a single GetLatest(CodeDomain, …)
+// for the matching addr; the hint clears on use.
+func (cpg *cachePopulatingGetter) withCodeHashHint(ethHash []byte) {
+ cpg.codeHashHint = ethHash
+}
+
func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, header *types.Header, body *types.Body) {
blockHash := header.Hash()
bra.headers.Add(blockHash, header)
@@ -109,23 +181,20 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
var wg errgroup.Group
- // If BAL exists in DB, use BAL warming (more complete)
+ // BAL source = the in-memory balcache (populated by
+ // EngineServer.HandleNewPayload on receipt). The chaindata
+ // kv.BlockAccessList table no longer exists; cache miss is a clean
+ // signal that BAL prefetch is not available for this block — fall
+ // through to per-transaction warming below.
var bal types.BlockAccessList
- if header != nil && db != nil {
- tx, err := db.BeginRo(ctx)
- if err != nil {
- log.Warn("[warmBody] failed to open tx for BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err)
- } else {
- data, err := tx.GetOne(kv.BlockAccessList, dbutils.BlockBodyKey(header.Number.Uint64(), header.Hash()))
+ if header != nil {
+ if data, ok := balcache.CachedBlockAccessList(header.Hash()); ok && len(data) > 0 {
+ decoded, err := types.DecodeBlockAccessListBytes(data)
if err != nil {
- log.Warn("[warmBody] failed to read BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err)
- } else if len(data) > 0 {
- bal, err = types.DecodeBlockAccessListBytes(data)
- if err != nil {
- log.Warn("[warmBody] failed to decode BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err)
- }
+ log.Warn("[warmBody] failed to decode BAL", "blockNum", header.Number.Uint64(), "blockHash", header.Hash(), "err", err)
+ } else {
+ bal = decoded
}
- tx.Rollback()
}
}
@@ -157,7 +226,13 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
if !ok {
return nil
}
- stateReader := state.NewReaderV3(ttx)
+ var getter kv.TemporalGetter = ttx
+ var cpg *cachePopulatingGetter
+ if bra.stateCache != nil {
+ cpg = &cachePopulatingGetter{g: ttx, sc: bra.stateCache, stepSize: ttx.Debug().StepSize()}
+ getter = cpg
+ }
+ stateReader := state.NewReaderV3(getter)
for idx := workerStart; idx < workerEnd; idx++ {
select {
@@ -168,8 +243,17 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
acctChanges := bal[idx]
acct, _ := stateReader.ReadAccountData(acctChanges.Address)
- // Warm code if account has code or if there are code changes
- if (acct != nil && !acct.CodeHash.IsEmpty()) || len(acctChanges.CodeChanges) > 0 {
+ // Warm code if account has code or if there are code changes.
+ // When we already know the codeHash from the account read, hint
+ // the cache-populating getter so the code bytes land in the L2b
+ // (ethHash → bytes) + size layers — not just the addr-keyed L1.
+ if acct != nil && !acct.CodeHash.IsEmpty() {
+ if cpg != nil {
+ h := acct.CodeHash.Value()
+ cpg.withCodeHashHint(h[:])
+ }
+ stateReader.ReadAccountCode(acctChanges.Address)
+ } else if len(acctChanges.CodeChanges) > 0 {
stateReader.ReadAccountCode(acctChanges.Address)
}
for _, slotChanges := range acctChanges.StorageChanges {
@@ -221,7 +305,13 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
if !ok {
return nil
}
- stateReader := state.NewReaderV3(ttx)
+ var getter kv.TemporalGetter = ttx
+ var cpg *cachePopulatingGetter
+ if bra.stateCache != nil {
+ cpg = &cachePopulatingGetter{g: ttx, sc: bra.stateCache, stepSize: ttx.Debug().StepSize()}
+ getter = cpg
+ }
+ stateReader := state.NewReaderV3(getter)
for txIdx := workerStart; txIdx < workerEnd; txIdx++ {
select {
@@ -236,6 +326,10 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
if toAddr := txn.GetTo(); toAddr != nil {
to := accounts.InternAddress(*toAddr)
if acct, _ := stateReader.ReadAccountData(to); acct != nil && !acct.CodeHash.IsEmpty() {
+ if cpg != nil {
+ h := acct.CodeHash.Value()
+ cpg.withCodeHashHint(h[:])
+ }
stateReader.ReadAccountCode(to)
}
}
@@ -244,6 +338,10 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t
for _, entry := range txn.GetAccessList() {
addr := accounts.InternAddress(entry.Address)
if acct, _ := stateReader.ReadAccountData(addr); acct != nil && !acct.CodeHash.IsEmpty() {
+ if cpg != nil {
+ h := acct.CodeHash.Value()
+ cpg.withCodeHashHint(h[:])
+ }
stateReader.ReadAccountCode(addr)
}
for _, slot := range entry.StorageKeys {
diff --git a/execution/exec/state.go b/execution/exec/state.go
index 82f2e056f9e..902f1116f90 100644
--- a/execution/exec/state.go
+++ b/execution/exec/state.go
@@ -33,6 +33,7 @@ import (
"github.com/erigontech/erigon/db/datadir"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/db/state/changeset"
"github.com/erigontech/erigon/diagnostics/metrics"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/protocol"
@@ -127,6 +128,11 @@ type Worker struct {
dirs datadir.Dirs
metrics *WorkerMetrics
+ // readMetrics is this worker's private domain read-metrics accumulator
+ // (lock-free, single-owner). The worker's state reader records into it; it
+ // is merged into the shared SharedDomains metrics + reset once per task (a
+ // lock per task, not per read), at the point the result is queued.
+ readMetrics *changeset.DomainMetrics
}
// installWorkerGetHash replaces the EVM's GetHash function with one that
@@ -178,8 +184,9 @@ func NewWorker(ctx context.Context, background bool, metrics *WorkerMetrics, cha
evm: vm.NewEVM(evmtypes.BlockContext{}, evmtypes.TxContext{}, nil, chainConfig, vm.Config{}),
- dirs: dirs,
- metrics: metrics,
+ dirs: dirs,
+ metrics: metrics,
+ readMetrics: changeset.NewDomainMetrics(),
}
w.runnable.Store(true)
w.ibs = state.New(w.stateReader)
@@ -227,7 +234,7 @@ func (rw *Worker) ResetState(rs *state.StateV3Buffered, chainTx kv.TemporalTx, s
} else {
var getter kv.TemporalGetter
if chainTx != nil {
- getter = rs.Domains().AsGetter(chainTx)
+ getter = rs.Domains().AsGetterMetered(chainTx, rw.readMetrics)
}
// Use CachedReaderV3 for parallel workers — caches account data
// on first read per block, providing a stable pre-block committed
@@ -293,7 +300,7 @@ func (rw *Worker) resetTx(chainTx kv.TemporalTx) error {
switch typedReader := rw.stateReader.(type) {
case latest:
- typedReader.SetGetter(rw.rs.Domains().AsGetter(rw.chainTx))
+ typedReader.SetGetter(rw.rs.Domains().AsGetterMetered(rw.chainTx, rw.readMetrics))
case historic:
typedReader.SetTx(rw.chainTx)
default:
@@ -363,6 +370,13 @@ func (rw *Worker) Run() (err error) {
if err := rw.results.Add(rw.ctx, result); err != nil {
return err
}
+ // Fold this task's reads into the shared metrics and reset. Off the hot
+ // path (the result is already queued): a single lock per task replacing
+ // the old lock-per-read. Skipped entirely when read metrics are off.
+ if dbg.KVReadLevelledMetrics && rw.rs != nil {
+ rw.rs.Domains().MergeMetrics(rw.readMetrics)
+ rw.readMetrics.Reset()
+ }
}
return nil
}
@@ -417,7 +431,7 @@ func (rw *Worker) SetReader(reader state.StateReader) {
switch typedReader := rw.stateReader.(type) {
case latest:
- typedReader.SetGetter(rw.rs.Domains().AsGetter(rw.chainTx))
+ typedReader.SetGetter(rw.rs.Domains().AsGetterMetered(rw.chainTx, rw.readMetrics))
case historic:
typedReader.SetTx(rw.chainTx)
}
@@ -449,7 +463,7 @@ func (rw *Worker) RunTxTaskNoLock(txTask Task) *TxResult {
// the coinbase race investigation).
rw.SetReader(state.NewHistoryReaderV3WithSharedDomains(rw.chainTx, rw.rs.Domains(), txTask.Version().TxNum))
} else if !txTask.IsHistoric() && (rw.stateReader == nil || rw.historyMode) {
- rw.SetReader(state.NewCachedReaderV3(rw.rs.Domains().AsGetter(rw.chainTx), nil))
+ rw.SetReader(state.NewCachedReaderV3(rw.rs.Domains().AsGetterMetered(rw.chainTx, rw.readMetrics), nil))
}
// Set the per-block committed state cache from the task.
@@ -535,7 +549,7 @@ func NewWorkersPool(ctx context.Context, accumulator *shards.Accumulator, backgr
reader := stateReader
if reader == nil {
- reader = state.NewReaderV3(rs.Domains().AsGetter(nil))
+ reader = state.NewReaderV3(rs.Domains().AsGetterMetered(nil, reconWorkers[i].readMetrics))
}
if err = reconWorkers[i].ResetState(rs, nil, reader, stateWriter, accumulator); err != nil {
diff --git a/execution/execmodule/balregen.go b/execution/execmodule/balregen.go
new file mode 100644
index 00000000000..304711c1c28
--- /dev/null
+++ b/execution/execmodule/balregen.go
@@ -0,0 +1,207 @@
+// 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 .
+
+package execmodule
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/holiman/uint256"
+
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
+ rawdbv3 "github.com/erigontech/erigon/db/kv/rawdbv3"
+ "github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/chain"
+ "github.com/erigontech/erigon/execution/protocol"
+ "github.com/erigontech/erigon/execution/protocol/rules"
+ "github.com/erigontech/erigon/execution/state"
+ "github.com/erigontech/erigon/execution/types"
+ "github.com/erigontech/erigon/execution/types/accounts"
+ "github.com/erigontech/erigon/execution/vm"
+ "github.com/erigontech/erigon/execution/vm/evmtypes"
+ "github.com/erigontech/erigon/rpc/transactions"
+)
+
+// BALRegeneratorDeps bundles the per-node helpers the regenerator needs to
+// re-execute a historical block: chain config, consensus engine, block reader
+// (for txs/bodies/headers), txNum reader (for state-at-block resolution), and
+// the temporal RO DB.
+type BALRegeneratorDeps struct {
+ DB kv.TemporalRoDB
+ ChainConfig *chain.Config
+ Engine rules.Engine
+ BlockReader services.FullBlockReader
+ TxNumsReader rawdbv3.TxNumsReader
+ Logger log.Logger
+}
+
+// BALRegenerator implements balcache.BALRegenerator by re-executing the requested
+// block against its parent state with VersionMap-enabled IBS read tracking.
+// Uses transactions.ComputeBlockContext to construct a state reader rooted at
+// the parent state — same approach as RPC tracing / receipts generation, so
+// we get the read-tracking layout the BAL hash assumes without duplicating
+// the state-at-block resolution code.
+//
+// Suitable for serving eth/71 GetBlockAccessLists when the local node hasn't
+// cached the BAL (the block was produced before this node started, or the
+// cache window rolled past it). Does NOT modify any persistent state — IBS
+// writes go to state.NewNoopWriter().
+type BALRegenerator struct {
+ deps BALRegeneratorDeps
+}
+
+func NewBALRegenerator(deps BALRegeneratorDeps) *BALRegenerator {
+ return &BALRegenerator{deps: deps}
+}
+
+// RegenerateBlockAccessList re-executes the block at (hash, number) and
+// returns the RLP-encoded BAL. Returns (nil, nil) when the block can't be
+// located, body is pruned, or the chain config doesn't have BAL active at
+// the block's timestamp.
+func (r *BALRegenerator) RegenerateBlockAccessList(ctx context.Context, hash common.Hash, number uint64) ([]byte, error) {
+ tx, err := r.deps.DB.BeginTemporalRo(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("BALRegenerator: BeginTemporalRo: %w", err)
+ }
+ defer tx.Rollback()
+
+ header, err := r.deps.BlockReader.Header(ctx, tx, hash, number)
+ if err != nil {
+ return nil, fmt.Errorf("BALRegenerator: header lookup: %w", err)
+ }
+ if header == nil {
+ return nil, nil
+ }
+ if !r.deps.ChainConfig.IsAmsterdam(header.Time) {
+ // Pre-Amsterdam blocks don't have BALs by spec.
+ return nil, nil
+ }
+ body, err := r.deps.BlockReader.BodyWithTransactions(ctx, tx, hash, number)
+ if err != nil {
+ return nil, fmt.Errorf("BALRegenerator: body lookup: %w", err)
+ }
+ if body == nil {
+ // Body pruned — we can't re-execute.
+ return nil, nil
+ }
+ block := types.NewBlockFromStorage(hash, header, body.Transactions, body.Uncles, body.Withdrawals)
+
+ // ComputeBlockContext at txIndex=0 returns an IBS reading from the
+ // state BEFORE the block's first transaction (= post-state of the
+ // parent block). Same machinery the RPC tracing path uses.
+ ibs, blockCtx, _, vmRules, signer, err := transactions.ComputeBlockContext(ctx, r.deps.Engine, header, r.deps.ChainConfig, r.deps.BlockReader, nil, r.deps.TxNumsReader, tx, 0)
+ if err != nil {
+ return nil, fmt.Errorf("BALRegenerator: ComputeBlockContext: %w", err)
+ }
+ // IBS read-tracking requires a VersionMap — versionedRead is the
+ // only path that populates ibs.versionedReads, which TxIO() reads
+ // to build the BAL.
+ ibs.SetVersionMap(state.NewVersionMap(nil))
+
+ bal, err := replayBlockForBAL(ctx, r.deps.ChainConfig, r.deps.Engine, block, &blockCtx, vmRules, signer, ibs, r.deps.Logger)
+ if err != nil {
+ return nil, fmt.Errorf("BALRegenerator: replay block %d: %w", number, err)
+ }
+ if bal == nil {
+ return nil, nil
+ }
+ return types.EncodeBlockAccessListBytes(bal)
+}
+
+// replayBlockForBAL drives the per-tx loop, merging the IBS's TxIO into a
+// per-block VersionedIO. Mirrors the block-assembler pattern — no parallel
+// exec, no state writer. Returns the accumulated BAL after the last tx.
+func replayBlockForBAL(
+ ctx context.Context,
+ chainConfig *chain.Config,
+ engine rules.Engine,
+ block *types.Block,
+ blockCtx *evmtypes.BlockContext,
+ vmRules *chain.Rules,
+ signer *types.Signer,
+ ibs *state.IntraBlockState,
+ logger log.Logger,
+) (types.BlockAccessList, error) {
+ header := block.HeaderNoCopy()
+ gp := new(protocol.GasPool).AddGas(block.GasLimit()).AddBlobGas(chainConfig.GetMaxBlobGasPerBlock(block.Time()))
+ gasUsed := new(protocol.GasUsed)
+
+ // chainReaderShim only exposes Config() — InitializeBlockExecution's
+ // system-init paths don't reach for parent headers here.
+ chainReader := &chainReaderShim{cfg: chainConfig}
+ if err := protocol.InitializeBlockExecution(engine, chainReader, header, chainConfig, ibs, state.NewNoopWriter(), logger, nil); err != nil {
+ return nil, fmt.Errorf("InitializeBlockExecution: %w", err)
+ }
+
+ var balIO state.VersionedIO
+ balIO = *balIO.Merge(ibs.TxIO())
+ ibs.ResetVersionedIO()
+
+ _ = blockCtx
+ _ = vmRules
+ _ = signer
+
+ blockHashFn := protocol.GetHashFn(header, func(common.Hash, uint64) (*types.Header, error) {
+ // BAL re-execution doesn't need cross-block BLOCKHASH lookups —
+ // the relevant headers are already in blockCtx for this block.
+ return nil, nil
+ })
+
+ for i, txn := range block.Transactions() {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ ibs.SetTxContext(block.NumberU64(), i)
+ _, err := protocol.ApplyTransaction(chainConfig, blockHashFn, engine, accounts.NilAddress, gp, ibs, state.NewNoopWriter(), header, txn, gasUsed, vm.Config{NoReceipts: true})
+ if err != nil {
+ return nil, fmt.Errorf("apply tx %d (%x): %w", i, txn.Hash(), err)
+ }
+ balIO = *balIO.Merge(ibs.TxIO())
+ ibs.ResetVersionedIO()
+ }
+
+ // Finalize-stage system writes (withdrawals, BeaconRoot, etc.) are
+ // captured during InitializeBlockExecution; engine.Finalize signatures
+ // vary by fork and would require receipts + system-call hooks we don't
+ // reconstruct here. If a downstream hash check flags a mismatch on
+ // finalize-touched accounts, that's the signal to add fork-aware
+ // finalize support to this path.
+
+ return balIO.AsBlockAccessList(), nil
+}
+
+// chainReaderShim is a minimal adapter for rules.ChainHeaderReader that
+// protocol.InitializeBlockExecution requires. The header-lookup methods
+// stay nil because the BAL replay path doesn't need parent headers.
+type chainReaderShim struct {
+ cfg *chain.Config
+}
+
+func (s *chainReaderShim) Config() *chain.Config { return s.cfg }
+func (s *chainReaderShim) CurrentHeader() *types.Header { return nil }
+func (s *chainReaderShim) CurrentFinalizedHeader() *types.Header { return nil }
+func (s *chainReaderShim) CurrentSafeHeader() *types.Header { return nil }
+func (s *chainReaderShim) GetHeader(hash common.Hash, number uint64) *types.Header { return nil }
+func (s *chainReaderShim) GetHeaderByNumber(number uint64) *types.Header { return nil }
+func (s *chainReaderShim) GetHeaderByHash(hash common.Hash) *types.Header { return nil }
+func (s *chainReaderShim) GetTd(hash common.Hash, number uint64) *uint256.Int {
+ return uint256.NewInt(0)
+}
+func (s *chainReaderShim) FrozenBlocks() uint64 { return 0 }
+func (s *chainReaderShim) FrozenBorBlocks(align bool) uint64 { return 0 }
diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go
index 0f04c587cc7..95bb2dc9fed 100644
--- a/execution/execmodule/exec_module.go
+++ b/execution/execmodule/exec_module.go
@@ -281,6 +281,13 @@ func NewExecModule(
stopNode: stopNode,
}
+ // Wire the process-global state cache into the read-ahead so its
+ // prefetches populate the same hashmap that SharedDomains.GetLatest
+ // probes on the EVM hot path. Reth's "same hashmap" pattern.
+ if readAheader != nil {
+ readAheader.SetStateCache(domainCache)
+ }
+
if stateCache != nil {
stateCache.execModule = em
}
@@ -499,14 +506,41 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b
}
var tx kv.TemporalRwTx = doms.BlockOverlay()
- // Chain the validation SD to currentContext when the payload extends the
- // canonical head. FCU's MergeExtendingFork leaves the latest state in
- // currentContext.mem; MDBX is committed only later under memory pressure,
- // so between an FCU and the next newPayload this fresh doms would
- // otherwise read stale MDBX and compute a wrong trie root. Head-extending
- // payloads only — a fork payload needs unwindToCommonCanonical to revert
- // doms to the common ancestor, which the parent link would shadow.
- if e.currentContext != nil && header.ParentHash == rawdb.ReadHeadBlockHash(tx) {
+ // DO NOT CHANGE THIS WITHOUT WORKING THROUGH THE UNWIND CACHING SCENARIOS.
+ // The earlier `header.ParentHash == ReadHeadBlockHash(tx)` head-extending-
+ // only gate has been intentionally widened back to "chain whenever a
+ // currentContext exists" because fork-payload caching needs the parent
+ // link too — see the two-role breakdown below. The narrower gate was
+ // merged from main during the post-#21017 rebase and is the WRONG choice
+ // for this branch; keep the wider gate.
+ //
+ // Chain the validation SD to the latest in-memory canonical generation:
+ // e.currentContext when present, otherwise the newest in-flight commit
+ // generation (gate item 2 — the prior FCU cleared currentContext and
+ // handed its SD to the background commit).
+ //
+ // The parent link serves two roles:
+ //
+ // 1. Head-extending payloads read the canonical generation's
+ // not-yet-committed domain state instead of stale MDBX.
+ //
+ // 2. Fork payloads: unwindToCommonCanonical below must build an unwind
+ // set, and the diffsets of the canonical blocks it unwinds live in
+ // the canonical generation's pastChangesAccumulator — reachable only
+ // through this parent link (GetDiffset chains to the parent). Without
+ // it the unwind silently runs with no unwind set, leaving the
+ // BranchCache unmasked and corrupting the computed root.
+ //
+ // For a fork payload the parent does NOT shadow the unwound base: once
+ // unwindToCommonCanonical has run, doms.mem.unwindChangeset holds every
+ // key the unwound canonical blocks touched, and TemporalMemBatch.getLatest
+ // resolves those from the unwind set before ever consulting the parent.
+ //
+ // Cherry-pick note: the upstream commit also chained to e.latestGen()
+ // (the gate-2 in-flight commit generation) when currentContext is nil;
+ // that generation chain is not on this branch, so currentContext is the
+ // only canonical generation here.
+ if e.currentContext != nil {
doms.SetParent(e.currentContext)
}
@@ -536,11 +570,11 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b
return ValidationResult{}, criticalError
}
- // Clear state cache on invalid block
- isInvalid := status == engine_types.InvalidStatus || status == engine_types.InvalidBlockHashStatus || validationError != nil
- if e.stateCache != nil && isInvalid {
- e.stateCache.ClearWithHash(header.ParentHash)
- }
+ // No cache invalidation needed on an invalid payload: the state cache is
+ // populated only at flush (committed, fork-agnostic state) and this
+ // validation path never flushes, so a rejected payload leaves nothing
+ // fork-specific in the cache. Reads during validation only add canonical
+ // committed bytes. (Cache invalidation happens solely on unwind.)
// Validation tx is the SD's BlockOverlay; defer doms.Close() above handles
// its rollback. By design we do not persist validation-run writes — there
diff --git a/execution/execmodule/exec_module_test.go b/execution/execmodule/exec_module_test.go
index 69680c1f708..d411e772098 100644
--- a/execution/execmodule/exec_module_test.go
+++ b/execution/execmodule/exec_module_test.go
@@ -181,6 +181,79 @@ func TestValidateChainAndUpdateForkChoiceWithSideForksThatGoBackAndForwardInHeig
require.NoError(t, err)
}
+// TestValidateForkPayloadOffNonTipCanonicalBlockWithCache exercises the
+// unwind/fork-payload caching path in ExecModule.ValidateChain. A fork that
+// branches off a NON-tip canonical block forces validateChain to
+// unwindToCommonCanonical (unwinding only the canonical blocks above the branch
+// point), chain the validation SharedDomains to e.currentContext so the unwound
+// blocks' diffsets are reachable via the parent link, and mask the BranchCache
+// with the resulting unwind set. If any of that is wrong the re-executed fork
+// produces a wrong trie root, so a Success validation status is the correctness
+// assertion. The existing side-fork test only branches at genesis (full unwind);
+// this covers the partial-unwind round trip the ValidateChain parent-link
+// comment guards.
+func TestValidateForkPayloadOffNonTipCanonicalBlockWithCache(t *testing.T) {
+ privKey, err := crypto.GenerateKey()
+ require.NoError(t, err)
+ senderAddr := crypto.PubkeyToAddress(privKey.PublicKey)
+ genesis := &types.Genesis{
+ Config: chain.AllProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ senderAddr: {Balance: new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)},
+ },
+ }
+ m := execmoduletester.New(t, execmoduletester.WithGenesisSpec(genesis), execmoduletester.WithKey(privKey))
+ to := common.Address{0xaa}
+ baseFee := m.Genesis.BaseFee().Uint64()
+ mkTx := func(nonce, amount uint64) types.Transaction {
+ tx, err := types.SignTx(
+ types.NewTransaction(nonce, to, uint256.NewInt(amount), 50000, uint256.NewInt(baseFee), nil),
+ *types.LatestSignerForChainID(nil),
+ privKey,
+ )
+ require.NoError(t, err)
+ return tx
+ }
+
+ // blockgen reads the latest committed DB state, so each segment must be
+ // generated while the chain head sits at its intended parent. Build and
+ // finalize the shared prefix genesis → 1 → 2 first (nonces 0,1), each block
+ // carrying a state-touching txn so the BranchCache + commitment hold real
+ // entries. After this the head is block 2.
+ prefix, err := blockgen.GenerateChain(m.ChainConfig, m.Genesis, m.Engine, m.DB, 2, func(i int, b *blockgen.BlockGen) {
+ b.AddTx(mkTx(uint64(i), 1_000))
+ })
+ require.NoError(t, err)
+ require.NoError(t, insertValidateAndUfc1By1(t.Context(), m.ExecModule, prefix.Blocks))
+ block2 := prefix.Blocks[1]
+
+ // With the head at block 2 (signer nonce 2), generate both continuations off
+ // block 2 before inserting either: the canonical tip (block 3, nonce 2) and a
+ // longer fork (blocks 3',4' at nonces 2,3) that branches off the same non-tip
+ // block 2.
+ canonicalTip, err := blockgen.GenerateChain(m.ChainConfig, block2, m.Engine, m.DB, 1, func(i int, b *blockgen.BlockGen) {
+ b.AddTx(mkTx(2, 1_000))
+ })
+ require.NoError(t, err)
+ fork, err := blockgen.GenerateChain(m.ChainConfig, block2, m.Engine, m.DB, 2, func(i int, b *blockgen.BlockGen) {
+ b.AddTx(mkTx(uint64(2+i), 2_000))
+ })
+ require.NoError(t, err)
+
+ // Finalize the canonical tip (head → block 3).
+ require.NoError(t, insertValidateAndUfc1By1(t.Context(), m.ExecModule, canonicalTip.Blocks))
+
+ // Validating fork.Blocks[0] (height 3, parent = block 2) must unwind canonical
+ // block 3 back to block 2; fork.Blocks[1] then head-extends and the FCU reorgs
+ // onto the longer fork.
+ require.NoError(t, insertValidateAndUfc1By1(t.Context(), m.ExecModule, fork.Blocks))
+
+ // Reorg back onto the original canonical block 3 (same common ancestor,
+ // block 2) to exercise the BranchCache masking in the other direction:
+ // unwind the fork blocks and re-validate canonical block 3 off block 2.
+ require.NoError(t, insertValidateAndUfc1By1(t.Context(), m.ExecModule, canonicalTip.Blocks))
+}
+
// Regression for PR #21415: when state's commitBlock is ahead of TxNums.Last
// (e.g. after a snapshot/state misalignment + chaindata wipe), an FCU to a
// block beyond the canonical tip must not be rejected as ReorgTooDeep.
diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go
index ea9051e8675..60219facf1e 100644
--- a/execution/execmodule/forkchoice.go
+++ b/execution/execmodule/forkchoice.go
@@ -416,6 +416,8 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa
return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, false)
}
}
+ // SD.Unwind (inside RunUnwind) tx-aware-invalidates the BranchCache by
+ // the unwound txNum, so no whole-cache clear is needed here.
UpdateForkChoiceDepth(fcuHeader.Number.Uint64() - 1 - unwindTarget)
diff --git a/execution/execmodule/getters.go b/execution/execmodule/getters.go
index 67b69f10433..4225baabce0 100644
--- a/execution/execmodule/getters.go
+++ b/execution/execmodule/getters.go
@@ -27,6 +27,7 @@ import (
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/types"
)
@@ -264,9 +265,9 @@ func (e *ExecModule) GetPayloadBodiesByHash(ctx context.Context, hashes []common
if err != nil {
return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: MarshalTransactionsBinary error %w", err)
}
- balBytes, err := rawdb.ReadBlockAccessListBytes(tx, h, *number)
+ balBytes, err := balcache.BlockAccessListBytes(ctx, h, *number)
if err != nil {
- return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: ReadBlockAccessListBytes error %w", err)
+ return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByHash: BlockAccessListBytes error %w", err)
}
var bal []byte
if len(balBytes) > 0 {
@@ -310,9 +311,9 @@ func (e *ExecModule) GetPayloadBodiesByRange(ctx context.Context, start, count u
if err != nil {
return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: MarshalTransactionsBinary error %w", err)
}
- balBytes, err := rawdb.ReadBlockAccessListBytes(tx, hash, blockNum)
+ balBytes, err := balcache.BlockAccessListBytes(ctx, hash, blockNum)
if err != nil {
- return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: ReadBlockAccessListBytes error %w", err)
+ return nil, fmt.Errorf("ethereumExecutionModule.GetPayloadBodiesByRange: BlockAccessListBytes error %w", err)
}
var bal []byte
if len(balBytes) > 0 {
diff --git a/execution/execmodule/inserters.go b/execution/execmodule/inserters.go
index 4fce4deafd8..971aa238303 100644
--- a/execution/execmodule/inserters.go
+++ b/execution/execmodule/inserters.go
@@ -26,6 +26,7 @@ import (
"github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/db/state/execctx"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/commitment/commitmentdb"
"github.com/erigontech/erigon/execution/metrics"
"github.com/erigontech/erigon/execution/types"
@@ -142,9 +143,11 @@ func (e *ExecModule) InsertBlocks(ctx context.Context, blocks []*types.RawBlock)
if header.BlockAccessListHash == nil {
return 0, fmt.Errorf("ethereumExecutionModule.InsertBlocks: block access list provided without hash for block %d", height)
}
- if err := rawdb.WriteBlockAccessListBytes(blockOverlay, header.Hash(), height, block.BlockAccessList); err != nil {
- return 0, fmt.Errorf("ethereumExecutionModule.InsertBlocks: writeBlockAccessList, block %d: %s", height, err)
- }
+ // BAL bytes go to the in-memory cache instead of MDBX. The
+ // chaindata write was tens of seconds per block on churned DBs;
+ // see db/rawdb/balcache.go. Older blocks needed by eth/71 peers
+ // or RPC are regenerated on demand via the BALRegenerator.
+ balcache.CacheBlockAccessList(header.Hash(), block.BlockAccessList)
}
e.logger.Trace("Inserted block", "hash", header.Hash(), "number", header.Number)
}
diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go
index 91da37f13e1..2d7f5d86b9f 100644
--- a/execution/execmodule/set_head.go
+++ b/execution/execmodule/set_head.go
@@ -103,6 +103,12 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error {
}
defer sd.Close()
+ // Wire the shared state cache so unwindExec3 invalidates it (epoch bump +
+ // floor lower). Without this, GetStateCache() returns nil during the unwind,
+ // the cache keeps pre-unwind values, and the next FCU re-execution reads them
+ // and computes a stale state root (BadBlock). Mirrors ValidateChain/forkchoice.
+ sd.SetStateCache(e.stateCache)
+
// Set the unwind point and run the unwind
if err := e.pipelineExecutor.UnwindTo(targetBlock, stagedsync.StagedUnwind, tx); err != nil {
return fmt.Errorf("failed to set unwind point: %w", err)
diff --git a/execution/stagedsync/bal_create.go b/execution/stagedsync/bal_create.go
index 351613bd264..ce6218cfc50 100644
--- a/execution/stagedsync/bal_create.go
+++ b/execution/stagedsync/bal_create.go
@@ -9,7 +9,7 @@ import (
"github.com/erigontech/erigon/common/dbg"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
- "github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/protocol/rules"
"github.com/erigontech/erigon/execution/state"
"github.com/erigontech/erigon/execution/types"
@@ -64,12 +64,12 @@ func ProcessBAL(tx kv.TemporalRwTx, h *types.Header, vio *state.VersionedIO, isE
return fmt.Errorf("block %d: EIP-7928 active but BlockAccessListHash is nil in header", blockNum)
}
blockBalHash := *h.BlockAccessListHash
- blockBalBytes, err := rawdb.ReadBlockAccessListBytes(tx, blockHash, blockNum)
- if err != nil {
- return fmt.Errorf("block %d: read stored block access list: %w", blockNum, err)
- }
- // A stored BAL sidecar may be absent — eth/71 backfill is best-effort and
- // never blocks stage progress — so cross-check it only when present.
+ // BALs are cache-only (see db/rawdb/balcache.go): the sidecar BAL from
+ // engine_newPayload is cached by execmodule.InsertBlocks and the eth/71
+ // bal-downloader caches what it fetches from peers. Cache misses are OK —
+ // not every code path has a sidecar (the backward block downloader doesn't
+ // carry one) — so cross-check only when the cached BAL is present.
+ blockBalBytes, _ := balcache.CachedBlockAccessList(blockHash)
var blockBal types.BlockAccessList
if blockBalBytes != nil {
blockBal, err = types.DecodeBlockAccessListBytes(blockBalBytes)
diff --git a/execution/stagedsync/calc_state.go b/execution/stagedsync/calc_state.go
index d22781445fe..9a14af64afe 100644
--- a/execution/stagedsync/calc_state.go
+++ b/execution/stagedsync/calc_state.go
@@ -296,8 +296,19 @@ func (cs *calcState) ApplyWrites(writes state.VersionedWrites) {
}
case state.StoragePath:
v := w.Val.(uint256.Int)
- cs.ensureStorage(w.Address, w.Key) // lazy-load if needed
- cs.storageState[w.Address][w.Key] = v
+ // The previous slot value is irrelevant here: the next line
+ // overwrites it with the EVM-write value, and the only
+ // downstream consumer (FlushToUpdates) reads exactly the
+ // value we set. Skip the ensureStorage lazy-load — it
+ // triggers a cold .ef GetAsOf seek per first-touched slot
+ // (~5,910 wasted seeks per SSTORE-bloat block) and discards
+ // the loaded value. Initialize just the inner map.
+ slots := cs.storageState[w.Address]
+ if slots == nil {
+ slots = make(map[accounts.StorageKey]uint256.Int)
+ cs.storageState[w.Address] = slots
+ }
+ slots[w.Key] = v
if cs.storageDirty[w.Address] == nil {
cs.storageDirty[w.Address] = make(map[accounts.StorageKey]bool)
}
diff --git a/execution/stagedsync/committer.go b/execution/stagedsync/committer.go
index 6729524e77d..784176b9474 100644
--- a/execution/stagedsync/committer.go
+++ b/execution/stagedsync/committer.go
@@ -625,6 +625,11 @@ type asOfStateReader struct {
sd *execctx.SharedDomains
roTx kv.TemporalTx
txNum uint64
+ // workerCtx, when non-nil, carries this worker's lock-free metrics
+ // accumulator; the CommitmentDomain read routes through GetLatestContext so
+ // a concurrent trie-warmup worker doesn't write the shared main accumulator
+ // (a race) or take the global metrics lock. Nil on the main reader.
+ workerCtx context.Context
}
func (r *asOfStateReader) WithHistory() bool { return false }
@@ -636,7 +641,11 @@ func (r *asOfStateReader) CheckDataAvailable(d kv.Domain, step kv.Step) error {
func (r *asOfStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64) (enc []byte, step kv.Step, err error) {
if d == kv.CommitmentDomain {
// Branches: use GetLatest — written only by this calculator, sequential.
- enc, step, err = r.sd.GetLatest(d, r.roTx, plainKey)
+ if r.workerCtx != nil {
+ enc, step, err = r.sd.GetLatestContext(r.workerCtx, d, r.roTx, plainKey)
+ } else {
+ enc, step, err = r.sd.GetLatest(d, r.roTx, plainKey)
+ }
} else {
// Account/storage/code: use GetAsOf to avoid reading future state.
// Check sd.mem first (in-memory data from current batch), then
@@ -667,5 +676,13 @@ func (r *asOfStateReader) Clone(tx kv.TemporalTx) commitmentdb.StateReader {
return &asOfStateReader{sd: r.sd, roTx: tx, txNum: r.txNum}
}
+// CloneForWorker meters the worker's CommitmentDomain reads into the per-worker
+// accumulator carried by workerCtx (this reader is used as the commitment
+// reader during block assembly, where trie-warmup runs concurrently — so it
+// must not write the shared main accumulator).
+func (r *asOfStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) commitmentdb.StateReader {
+ return &asOfStateReader{sd: r.sd, roTx: tx, txNum: r.txNum, workerCtx: workerCtx}
+}
+
// Keep imports used.
var _ = commitment.CommitProgress{}
diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go
index 810b9ae9bb7..7d6366690b3 100644
--- a/execution/stagedsync/exec3.go
+++ b/execution/stagedsync/exec3.go
@@ -40,6 +40,7 @@ import (
"github.com/erigontech/erigon/db/rawdb/rawtemporaldb"
dbstate "github.com/erigontech/erigon/db/state"
"github.com/erigontech/erigon/db/state/execctx"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/commitment"
"github.com/erigontech/erigon/execution/exec"
"github.com/erigontech/erigon/execution/protocol"
@@ -197,12 +198,6 @@ func ExecV3(ctx context.Context,
doms.EnableParaTrieDB(cfg.db)
doms.EnableTrieWarmup(true)
- // Short-term: keep the trie warmuper's value cache off on the parallel
- // path. The warmuper reads paraTrieDB (persisted state), so during a
- // multi-block uncommitted batch (fork validation) it caches values stale
- // w.r.t. sd.mem and feeds them to the trie, producing wrong roots. The
- // page-cache warmer above is unaffected; the full fix lands separately.
- doms.EnableWarmupCache(!isApplyingBlocks && !parallel)
doms.SetDeferCommitmentUpdates(false)
// Enable deferred commitment updates for fork validation and parallel initial sync.
// Deferred updates batch commitment calculations to block boundaries rather than
@@ -589,21 +584,11 @@ func (te *txExecutor) executeBlocks(ctx context.Context, startBlockNum uint64, m
}
go warmTxsHashes(b)
- // Bug 2 fix from commit 43a64a0b66 (parallel executor: fix two cache bugs):
- // validate stateCache entries against this block's parent hash so stale
- // values from a prior fork-validation are cleared before reads.
- if stateCache := te.doms.GetStateCache(); stateCache != nil {
- stateCache.ValidateAndPrepare(b.ParentHash(), b.Hash())
- }
-
var dbBAL types.BlockAccessList
- // Read BAL through blockTx (overlay or execRoTx) — do NOT open
- // a separate db.View() as it can deadlock with the stageloop's
- // RW transaction when BlockOverlay is active.
- data, err := rawdb.ReadBlockAccessListBytes(blockTx, b.Hash(), blockNum)
- if err != nil {
- return err
- }
+ // BALs are cache-only (see db/rawdb/balcache.go). If the engine_newPayload
+ // path cached one for this block (via execmodule.InsertBlocks), pick it up
+ // here for the parallel exec's BAL validation.
+ data, _ := balcache.CachedBlockAccessList(b.Hash())
if len(data) > 0 && !dbg.IgnoreBAL {
dbBAL, err = types.DecodeBlockAccessListBytes(data)
if err != nil {
diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go
index cf8ebb78e40..15c23dd8879 100644
--- a/execution/stagedsync/exec3_parallel.go
+++ b/execution/stagedsync/exec3_parallel.go
@@ -237,14 +237,6 @@ func (pe *parallelExecutor) execImpl(ctx context.Context, execStage *StageState,
pe.rs.Domains().SetInMemHistoryReads(true)
defer pe.rs.Domains().SetInMemHistoryReads(prevInMemHistoryReads)
- // Trie warmup left enabled for the parallel path. Original disable was
- // based on a calculator/warmer interaction concern that turned out to be
- // overly conservative — the Warmuper's reads are independent of the
- // calculator's SetUpdates call. Removing the disable produced an 8×
- // throughput improvement on the perf-devnet-3 SSTORE-bloated benchmark
- // (block 24358306) by letting the Warmuper pre-fetch branch data while
- // EVM execution runs. See #20920 for the canonical perf measurement.
-
// Skip step-boundary commitment — the calculator handles this.
pe.rs.StateV3.SetSkipStepBoundaryCommitment(true)
defer pe.rs.StateV3.SetSkipStepBoundaryCommitment(false)
@@ -745,6 +737,9 @@ func (pe *parallelExecutor) execImpl(ctx context.Context, execStage *StageState,
func (pe *parallelExecutor) LogExecution() {
pe.progress.LogExecution(pe.rs.StateV3, pe)
+ if stateCache := pe.doms.GetStateCache(); stateCache != nil {
+ stateCache.PrintStatsAndReset()
+ }
if domainMetrics := pe.domains().LogMetrics(); len(domainMetrics) > 0 {
pe.logger.Info(fmt.Sprintf("[%s] domain reads", pe.logPrefix), domainMetrics...)
}
@@ -1073,17 +1068,10 @@ func (pe *parallelExecutor) execLoop(ctx context.Context) (err error) {
}
func (pe *parallelExecutor) processRequest(ctx context.Context, execRequest *execRequest) (err error) {
- // Validate state cache before processing block - ensures cache is cleared after reorgs
- // This matches the behavior in serial execution (exec3_serial.go)
- if len(execRequest.tasks) > 0 {
- if txTask, ok := execRequest.tasks[0].(*exec.TxTask); ok && txTask.Header != nil {
- parentHash := txTask.Header.ParentHash
- blockHash := execRequest.blockHash
- if stateCache := pe.doms.GetStateCache(); stateCache != nil {
- stateCache.ValidateAndPrepare(parentHash, blockHash)
- }
- }
- }
+ // The state cache is a SharedDomain implementation detail: it is populated
+ // only at flush (committed, fork-agnostic state) and invalidated only on
+ // unwind (txNum/epoch — see StateCache.Unwind). The executor does not touch
+ // it during forward execution.
prevSenderTx := map[accounts.Address]int{}
var scheduleable *blockExecutor
@@ -2483,7 +2471,12 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r
if txTask.IsHistoric() {
stateReader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), be.blockStateCache, txTask.Version().TxNum)
} else {
- stateReader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetter(applyTx), be.blockStateCache)
+ // Use CachedReaderV3 with readCurrent=true so the
+ // finalize (including system TXs) reads from the
+ // BlockStateCache write buffer. This ensures the
+ // system TX sees all accumulated state from prior
+ // TXs in the block, not stale sd.mem values.
+ stateReader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetterNoMetrics(applyTx), be.blockStateCache)
}
}
@@ -2744,7 +2737,7 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r
// the pre-block balance and stomped tx 28's in-block update.
reader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), be.blockStateCache, finalVersion.TxNum)
} else {
- reader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetter(applyTx), be.blockStateCache)
+ reader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetterNoMetrics(applyTx), be.blockStateCache)
}
pe.RUnlock()
diff --git a/execution/stagedsync/exec3_serial.go b/execution/stagedsync/exec3_serial.go
index d2dfae82517..16b65dbd6d4 100644
--- a/execution/stagedsync/exec3_serial.go
+++ b/execution/stagedsync/exec3_serial.go
@@ -82,7 +82,7 @@ func (se *serialExecutor) exec(ctx context.Context, execStage *StageState, u Unw
"initialCycle", initialCycle, "isForkValidation", se.isForkValidation)
}
- stateCache := se.doms.GetStateCache()
+ stateCache := se.doms.GetStateCache() // for periodic PrintStatsAndReset only
for ; blockNum <= maxBlockNum; blockNum++ {
shouldGenerateChangesets := shouldGenerateChangeSets(se.cfg, blockNum, maxBlockNum)
@@ -114,10 +114,6 @@ func (se *serialExecutor) exec(ctx context.Context, execStage *StageState, u Unw
}
go warmTxsHashes(b)
- if stateCache != nil {
- stateCache.ValidateAndPrepare(b.ParentHash(), b.Hash())
- }
-
txs := b.Transactions()
header := b.HeaderNoCopy()
getHashFnMutex := sync.Mutex{}
diff --git a/execution/stagedsync/no_prune_test.go b/execution/stagedsync/no_prune_test.go
index f66f7475f85..e1b9d20bed8 100644
--- a/execution/stagedsync/no_prune_test.go
+++ b/execution/stagedsync/no_prune_test.go
@@ -51,8 +51,6 @@ func TestNoPruneSkipsAllPruneStages(t *testing.T) {
seeds := []seedRow{
{kv.ChangeSets3, "k1", "v1"},
{kv.ChangeSets3, "k2", "v2"},
- {kv.BlockAccessList, "b1", "ba1"},
- {kv.BlockAccessList, "b2", "ba2"},
{kv.TxLookup, "t1", "tl1"},
{kv.BorWitnesses, "w1", "wit1"},
}
@@ -70,7 +68,7 @@ func TestNoPruneSkipsAllPruneStages(t *testing.T) {
}
return n
}
- tracked := []string{kv.ChangeSets3, kv.BlockAccessList, kv.TxLookup, kv.BorWitnesses}
+ tracked := []string{kv.ChangeSets3, kv.TxLookup, kv.BorWitnesses}
pre := map[string]int{}
for _, table := range tracked {
pre[table] = countRows(t, table)
diff --git a/execution/stagedsync/rawdbreset/reset_stages.go b/execution/stagedsync/rawdbreset/reset_stages.go
index 05352bab7d6..023c8e9e626 100644
--- a/execution/stagedsync/rawdbreset/reset_stages.go
+++ b/execution/stagedsync/rawdbreset/reset_stages.go
@@ -36,6 +36,7 @@ import (
"github.com/erigontech/erigon/db/rawdb/blockio"
"github.com/erigontech/erigon/db/services"
"github.com/erigontech/erigon/db/snaptype"
+ dbstate "github.com/erigontech/erigon/db/state"
"github.com/erigontech/erigon/diagnostics/diaglib"
"github.com/erigontech/erigon/execution/stagedsync/stages"
"github.com/erigontech/erigon/execution/types"
@@ -173,7 +174,7 @@ func ResetExec(ctx context.Context, db kv.TemporalRwDB) (err error) {
cleanupList = append(cleanupList, db.Debug().DomainTables(kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain, kv.CommitmentDomain, kv.ReceiptDomain, kv.RCacheDomain)...)
cleanupList = append(cleanupList, db.Debug().InvertedIdxTables(kv.LogAddrIdx, kv.LogTopicIdx, kv.TracesFromIdx, kv.TracesToIdx)...)
- return db.Update(ctx, func(tx kv.RwTx) error {
+ if err := db.Update(ctx, func(tx kv.RwTx) error {
if err := clearStageProgress(tx, stages.Execution); err != nil {
return err
}
@@ -183,7 +184,25 @@ func ResetExec(ctx context.Context, db kv.TemporalRwDB) (err error) {
}
// corner case: state files may be ahead of block files - so, can't use SharedDomains here. juts leave progress as 0.
return nil
- })
+ }); err != nil {
+ return err
+ }
+
+ // Wiping the commitment table leaves the aggregator's in-memory branchCache
+ // referencing trie nodes that no longer exist on disk. A subsequent from-0
+ // re-exec then reads those stale nodes when computing block 0's commitment
+ // and produces a wrong trie root (parallel-exec failure mode of #21138).
+ // Drop the cache so it repopulates from the freshly-wiped table.
+ if hasAgg, ok := db.(dbstate.HasAgg); ok {
+ if agg, ok := hasAgg.Agg().(*dbstate.Aggregator); ok {
+ aggTx := agg.BeginFilesRo()
+ if bc := aggTx.BranchCache(); bc != nil {
+ bc.Clear()
+ }
+ aggTx.Close()
+ }
+ }
+ return nil
}
func ResetTxLookup(tx kv.RwTx) error {
diff --git a/execution/stagedsync/stage_execute.go b/execution/stagedsync/stage_execute.go
index f7679ad0a62..ca12264df02 100644
--- a/execution/stagedsync/stage_execute.go
+++ b/execution/stagedsync/stage_execute.go
@@ -204,8 +204,7 @@ func unwindExec3(u *UnwindState, s *StageState, doms *execctx.SharedDomains, rwT
}
}
}
- // Get the hash of the last executed block (the tip we're unwinding from)
- // so RevertWithDiffset can detect if the cache was modified by a rolled-back tx.
+ // Get the hash of the last executed block (the tip we're unwinding from).
lastExecHash, _, err := br.CanonicalHash(ctx, rwTx, u.CurrentBlockNumber)
if err != nil {
lastExecHash = common.Hash{}
@@ -213,14 +212,12 @@ func unwindExec3(u *UnwindState, s *StageState, doms *execctx.SharedDomains, rwT
if err := unwindExec3State(ctx, doms, rwTx, u.UnwindPoint, txNum, accumulator, changeSet, lastExecHash, logger); err != nil {
return fmt.Errorf("unwindExec3State(%d->%d): %w, took %s", s.BlockNumber, u.UnwindPoint, err, time.Since(t))
}
- // Surgically evict keys touched by the unwound blocks from the state cache.
- // Keys not in the diffset remain cached (they weren't modified by the unwound range).
- if stateCache := doms.GetStateCache(); stateCache != nil && changeSet != nil {
- unwindTargetHash, _, err := br.CanonicalHash(ctx, rwTx, u.UnwindPoint)
- if err != nil {
- unwindTargetHash = common.Hash{}
- }
- stateCache.RevertWithDiffset(changeSet, lastExecHash, unwindTargetHash)
+ // Invalidate the state cache for everything above the unwind point. txNum/epoch
+ // based and diffset-free (see StateCache.Unwind), so it runs unconditionally —
+ // independent of whether changesets were generated for the unwound range, which
+ // they are not below the reorg window. Matches the domain overlay's maxtx prune.
+ if stateCache := doms.GetStateCache(); stateCache != nil {
+ stateCache.Unwind(txNum)
}
if err := rawdb.DeleteNewerEpochs(rwTx, u.UnwindPoint+1); err != nil {
return fmt.Errorf("delete newer epochs: %w", err)
@@ -520,27 +517,6 @@ func PruneExecutionStage(ctx context.Context, s *PruneState, tx kv.RwTx, cfg Exe
}
}
- if s.ForwardProgress > cfg.syncCfg.MaxReorgDepth {
- pruneBalLimit := 10_000
- pruneTimeout := quickPruneTimeout
- if s.CurrentSyncCycle.IsInitialCycle {
- pruneBalLimit = math.MaxInt
- pruneTimeout = time.Hour
- }
- if err := rawdb.PruneTable(
- tx,
- kv.BlockAccessList,
- s.ForwardProgress-cfg.syncCfg.MaxReorgDepth,
- ctx,
- pruneBalLimit,
- pruneTimeout,
- logger,
- s.LogPrefix(),
- ); err != nil {
- return err
- }
- }
-
agg := cfg.db.(state.HasAgg).Agg().(*state.Aggregator)
mxExecStepsInDB.Set(rawdbhelpers.IdxStepsCountV3(tx, agg.StepSize()) * 100)
diff --git a/execution/state/intra_block_state.go b/execution/state/intra_block_state.go
index 14759bcd85a..e1640b406ad 100644
--- a/execution/state/intra_block_state.go
+++ b/execution/state/intra_block_state.go
@@ -39,6 +39,7 @@ import (
"github.com/erigontech/erigon/common/hexutil"
"github.com/erigontech/erigon/common/u256"
"github.com/erigontech/erigon/execution/chain"
+ "github.com/erigontech/erigon/execution/commitment"
"github.com/erigontech/erigon/execution/commitment/trie"
"github.com/erigontech/erigon/execution/protocol/params"
"github.com/erigontech/erigon/execution/tracing"
@@ -339,7 +340,20 @@ func (sdb *IntraBlockState) HasStorage(addr accounts.Address) (bool, error) {
}
}
- // Otherwise check in the DB
+ // Otherwise check in the DB. This is the EIP-684 CREATE collision
+ // fall-through: the in-memory checks above missed, so we ask the
+ // reader, which on the snapshot-backed storage layout means a
+ // kv.HasPrefix(StorageDomain, addr) walk through the .bt index.
+ // That index pages into RAM and is the dominant reason the storage
+ // .bt stays resident on the validation hot path. The cost equation
+ // changed when storage moved to snapshots; the call wasn't re-priced.
+ commitment.RecordHasStorageMiss()
+ if dbg.EnvBool("SKIP_EIP684_HASPREFIX", false) {
+ // CORRECTNESS-BROKEN. Bench scaffold only — quantifies the
+ // .bt-resident cost by short-circuiting the scan. With the gate
+ // on, two CREATEs to the same address will both succeed.
+ return false, nil
+ }
result, err := sdb.stateReader.HasStorage(addr)
return result, err
}
@@ -724,7 +738,20 @@ func (sdb *IntraBlockState) GetCodeSize(addr accounts.Address) (int, error) {
if stateObject.data.CodeHash.IsEmpty() {
return 0, nil
}
- return sdb.stateReader.ReadAccountCodeSize(addr)
+ // Geth pattern (core/state/state_object.go ~Code()): pay full code
+ // fetch once on first touch, populate stateObject.code so subsequent
+ // EXTCODESIZE / EXTCODEHASH / CALL on the same addr in this tx are
+ // in-struct slice-len calls (~50 ns), not full reader round-trips.
+ // On a 30M-gas EXTCODESIZE loop with N unique addrs, this collapses
+ // the per-addr cost from ~150k reader calls to 1 reader call.
+ code, err := sdb.stateReader.ReadAccountCode(addr)
+ if err != nil {
+ return 0, err
+ }
+ if code != nil {
+ stateObject.code = code
+ }
+ return len(code), nil
}
size, source, _, err := versionedRead(sdb, addr, CodeSizePath, accounts.NilKey, false, 0,
@@ -747,7 +774,18 @@ func (sdb *IntraBlockState) GetCodeSize(addr accounts.Address) (int, error) {
if dbg.KVReadLevelledMetrics {
readStart = time.Now()
}
- l, err := sdb.stateReader.ReadAccountCodeSize(addr)
+ // Geth pattern: load full code on first touch, populate
+ // stateObject.code so subsequent same-addr EXTCODESIZE /
+ // EXTCODEHASH / CALL within this tx hit the s.code branch
+ // above instead of re-entering the reader. With BAL prefetch
+ // the bytes are already in cache.StateCache, so this is
+ // effectively a hashmap probe + slice assignment.
+ code, codeErr := sdb.stateReader.ReadAccountCode(addr)
+ l := len(code)
+ if codeErr == nil && code != nil {
+ s.code = code
+ }
+ err := codeErr
if dbg.KVReadLevelledMetrics {
sdb.codeReadDuration += time.Since(readStart)
sdb.codeReadCount++
diff --git a/execution/state/rw_v3.go b/execution/state/rw_v3.go
index 54b0c016609..e08e2142833 100644
--- a/execution/state/rw_v3.go
+++ b/execution/state/rw_v3.go
@@ -1546,11 +1546,28 @@ func (r *ReaderV3) ReadAccountCode(address accounts.Address) ([]byte, error) {
return enc, nil
}
+// codeSizeGetter is the type-asserted fast-path interface for callers
+// that only need the length of the code (EXTCODESIZE / EXTCODEHASH).
+// Implemented by execctx.temporalGetter; fallback to GetLatest otherwise.
+type codeSizeGetter interface {
+ GetCodeSize(addr []byte) (int, bool, error)
+}
+
func (r *ReaderV3) ReadAccountCodeSize(address accounts.Address) (int, error) {
var addressValue common.Address
if !address.IsNil() {
addressValue = address.Value()
}
+ if sg, ok := r.getter.(codeSizeGetter); ok {
+ size, _, err := sg.GetCodeSize(addressValue[:])
+ if err != nil {
+ return 0, err
+ }
+ if r.trace {
+ fmt.Printf("%sReadAccountCodeSize (sz) [%x] => [%d], txNum: %d\n", r.tracePrefix, addressValue, size, r.txNum)
+ }
+ return size, nil
+ }
enc, _, err := r.getter.GetLatest(kv.CodeDomain, addressValue[:])
if err != nil {
return 0, err
diff --git a/execution/state/state_object.go b/execution/state/state_object.go
index ea7b0b9ee21..220c995cbe3 100644
--- a/execution/state/state_object.go
+++ b/execution/state/state_object.go
@@ -35,6 +35,7 @@ import (
"github.com/erigontech/erigon/common/dbg"
"github.com/erigontech/erigon/common/empty"
"github.com/erigontech/erigon/common/u256"
+ "github.com/erigontech/erigon/execution/commitment"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/tracing"
"github.com/erigontech/erigon/execution/types/accounts"
@@ -265,9 +266,23 @@ func (so *stateObject) SetState(key accounts.StorageKey, value uint256.Int, forc
}
if !force && source != UnknownSource && prev == value {
+ commitment.RecordSstoreNoop()
return false, nil
}
+ // SSTORE classification — measurement scaffolding to size the value of
+ // pushing insert/update/delete information down to the warmer / trie
+ // compute (would let those layers skip xorfilter / verify-by-compare
+ // when the operation is known to be a UPDATE on an existing branch).
+ switch {
+ case prev.IsZero() && !value.IsZero():
+ commitment.RecordSstoreInsert()
+ case !prev.IsZero() && value.IsZero():
+ commitment.RecordSstoreDelete()
+ default:
+ commitment.RecordSstoreUpdate()
+ }
+
// New value is different, update and journal the change
so.db.journal.append(storageChange{
account: so.address,
diff --git a/execution/tests/state_test.go b/execution/tests/state_test.go
index 464fb9508ca..645c0f735e1 100644
--- a/execution/tests/state_test.go
+++ b/execution/tests/state_test.go
@@ -37,6 +37,7 @@ import (
"github.com/erigontech/erigon/db/datadir"
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/temporal/temporaltest"
+ "github.com/erigontech/erigon/db/state/execctx"
"github.com/erigontech/erigon/execution/tests/testutil"
"github.com/erigontech/erigon/execution/tracing/tracers/logger"
"github.com/erigontech/erigon/execution/vm"
@@ -122,7 +123,12 @@ func runStateTests(t *testing.T, st *testutil.TestMatcher, testDir string) {
withTrace(t, func(vmconfig vm.Config) error {
tx := beginRwNoContention(t, db)
defer tx.Rollback()
- _, _, err = test.Run(t, tx, subtest, vmconfig, dirs)
+ sd, sdErr := execctx.NewSharedDomains(context.Background(), tx, log.New())
+ if sdErr != nil {
+ return sdErr
+ }
+ defer sd.Close()
+ _, _, err = test.Run(t, sd, tx, subtest, vmconfig, dirs)
tx.Rollback()
if err != nil && len(test.Json.Post[subtest.Fork][subtest.Index].ExpectException) > 0 {
// Ignore expected errors
diff --git a/execution/tests/testutil/state_test_util.go b/execution/tests/testutil/state_test_util.go
index 33f28dbcd88..6617cedb3a8 100644
--- a/execution/tests/testutil/state_test_util.go
+++ b/execution/tests/testutil/state_test_util.go
@@ -247,9 +247,11 @@ func (t *StateTest) checkError(subtest StateSubtest, err error) error {
return nil
}
-// Run executes a specific subtest and verifies the post-state and logs
-func (t *StateTest) Run(tb testing.TB, tx kv.TemporalRwTx, subtest StateSubtest, vmconfig vm.Config, dirs datadir.Dirs) (*state.IntraBlockState, common.Hash, error) {
- st, root, _, err := t.RunNoVerify(tb, tx, subtest, vmconfig, dirs)
+// Run executes a specific subtest and verifies the post-state and logs.
+// sd is the caller-owned SharedDomains: discard (Close without Flush) to
+// prevent per-subtest state from polluting the long-lived branch cache.
+func (t *StateTest) Run(tb testing.TB, sd *execctx.SharedDomains, tx kv.TemporalRwTx, subtest StateSubtest, vmconfig vm.Config, dirs datadir.Dirs) (*state.IntraBlockState, common.Hash, error) {
+ st, root, _, err := t.RunNoVerify(tb, sd, tx, subtest, vmconfig, dirs)
checkedErr := t.checkError(subtest, err)
if checkedErr != nil {
@@ -273,8 +275,12 @@ func (t *StateTest) Run(tb testing.TB, tx kv.TemporalRwTx, subtest StateSubtest,
return st, root, nil
}
-// RunNoVerify runs a specific subtest and returns the statedb, post-state root and gas used.
-func (t *StateTest) RunNoVerify(tb testing.TB, tx kv.TemporalRwTx, subtest StateSubtest, vmconfig vm.Config, dirs datadir.Dirs) (statedb *state.IntraBlockState, root common.Hash, gasUsed uint64, err error) {
+// RunNoVerify runs a specific subtest and returns the statedb, post-state root
+// and gas used. sd is the caller-owned SharedDomains scope; pre-state is loaded
+// into it via MakePreStateInto so the per-subtest writes can be discarded by
+// closing sd without Flush — keeping ephemeral test state out of the long-lived
+// branch cache.
+func (t *StateTest) RunNoVerify(tb testing.TB, sd *execctx.SharedDomains, tx kv.TemporalRwTx, subtest StateSubtest, vmconfig vm.Config, dirs datadir.Dirs) (statedb *state.IntraBlockState, root common.Hash, gasUsed uint64, err error) {
config, eips, err := GetChainConfig(subtest.Fork)
if err != nil {
return nil, common.Hash{}, 0, testforks.UnsupportedForkError{Name: subtest.Fork}
@@ -288,20 +294,15 @@ func (t *StateTest) RunNoVerify(tb testing.TB, tx kv.TemporalRwTx, subtest State
readBlockNr := block.NumberU64()
writeBlockNr := readBlockNr + 1
- _, err = MakePreState(&chain.Rules{}, tx, t.Json.Pre, readBlockNr)
+ _, err = MakePreStateInto(&chain.Rules{}, sd, tx, t.Json.Pre, readBlockNr)
if err != nil {
return nil, common.Hash{}, 0, testforks.UnsupportedForkError{Name: subtest.Fork}
}
- domains, err := execctx.NewSharedDomains(context.Background(), tx, log.New())
- if err != nil {
- return nil, common.Hash{}, 0, testforks.UnsupportedForkError{Name: subtest.Fork}
- }
- defer domains.Close()
blockNum, txNum := readBlockNr, uint64(1)
defer func() {
- rootBytes, rootBytesErr := domains.ComputeCommitment(context2.Background(), tx, true, blockNum, txNum, "", nil)
+ rootBytes, rootBytesErr := sd.ComputeCommitment(context2.Background(), tx, true, blockNum, txNum, "", nil)
if rootBytesErr != nil {
if err != nil {
err = fmt.Errorf("ComputeCommitment: %w: %w", rootBytesErr, err)
@@ -313,8 +314,12 @@ func (t *StateTest) RunNoVerify(tb testing.TB, tx kv.TemporalRwTx, subtest State
root = common.BytesToHash(rootBytes)
}()
- r := rpchelper.NewLatestStateReader(tx)
- w := rpchelper.NewLatestStateWriter(tx, domains, (*freezeblocks.BlockReader)(nil), writeBlockNr)
+ // Read through sd.AsGetter(tx) so the tx execution sees pre-state
+ // writes held in sd.mem. Without this the reader would hit MDBX
+ // directly and miss the never-Flushed pre-state, surfacing as
+ // "insufficient funds for gas * price + value" on every test.
+ r := rpchelper.NewLatestStateReader(sd.AsGetter(tx))
+ w := rpchelper.NewLatestStateWriter(tx, sd, (*freezeblocks.BlockReader)(nil), writeBlockNr)
statedb = state.New(r)
var baseFee *uint256.Int
@@ -420,8 +425,41 @@ func (t *StateTest) RunNoVerify(tb testing.TB, tx kv.TemporalRwTx, subtest State
return statedb, root, gasUsed, nil
}
+// MakePreState loads the test fixture's pre-allocation, flushes it to db +
+// the long-lived branch cache, and returns the resulting IntraBlockState.
+// Used by tracetest callers that need the pre-state to outlive the call.
+//
+// The state-test runner does NOT use this — see MakePreStateInto for the
+// ephemeral-SD shape that keeps per-subtest state out of the branch cache.
func MakePreState(rules *chain.Rules, tx kv.TemporalRwTx, alloc types.GenesisAlloc, blockNr uint64) (*state.IntraBlockState, error) {
- r := rpchelper.NewLatestStateReader(tx)
+ sd, err := execctx.NewSharedDomains(context.Background(), tx, log.New())
+ if err != nil {
+ return nil, err
+ }
+ defer sd.Close()
+ statedb, err := MakePreStateInto(rules, sd, tx, alloc, blockNr)
+ if err != nil {
+ return nil, err
+ }
+ if err := sd.Flush(context2.Background(), tx); err != nil {
+ return nil, err
+ }
+ return statedb, nil
+}
+
+// MakePreStateInto loads the test fixture's pre-allocation into the supplied
+// SharedDomains. The caller owns sd's lifecycle: a SD that is Closed without
+// Flush discards everything written here, leaving the long-lived branch cache
+// untouched. This is the production-aligned shape — application code uses
+// SharedDomains as the ephemeral working scope; Flush is what commits to db
+// and cache. Per-subtest state in the state-test runner is ephemeral and must
+// NOT enter the cache, so each subtest creates an SD, calls this, runs, and
+// discards the SD without flushing.
+func MakePreStateInto(rules *chain.Rules, sd *execctx.SharedDomains, tx kv.TemporalRwTx, alloc types.GenesisAlloc, blockNr uint64) (*state.IntraBlockState, error) {
+ // Read through sd.AsGetter so SetCode/SetBalance see prior pre-state
+ // writes held in sd.mem (consistent with the writer that targets sd
+ // via NewLatestStateWriter below).
+ r := rpchelper.NewLatestStateReader(sd.AsGetter(tx))
statedb := state.New(r)
statedb.SetTxContext(blockNr, 0)
for addr, a := range alloc {
@@ -438,25 +476,17 @@ func MakePreState(rules *chain.Rules, tx kv.TemporalRwTx, alloc types.GenesisAll
val := uint256.NewInt(0).SetBytes(v[:])
statedb.SetState(address, key, *val)
}
-
if len(a.Code) > 0 || len(a.Storage) > 0 {
statedb.SetIncarnation(address, state.FirstContractIncarnation)
}
}
- domains, err := execctx.NewSharedDomains(context.Background(), tx, log.New())
- if err != nil {
- return nil, err
- }
- defer domains.Close()
- latestTxNum, latestBlockNum, err := domains.SeekCommitment(context.Background(), tx)
+ latestTxNum, latestBlockNum, err := sd.SeekCommitment(context.Background(), tx)
if err != nil {
return nil, err
}
- w := rpchelper.NewLatestStateWriter(tx, domains, (*freezeblocks.BlockReader)(nil), blockNr-1)
-
- // Commit and re-open to start with a clean state.
+ w := rpchelper.NewLatestStateWriter(tx, sd, (*freezeblocks.BlockReader)(nil), blockNr-1)
if err := statedb.FinalizeTx(rules, w); err != nil {
return nil, err
}
@@ -464,12 +494,7 @@ func MakePreState(rules *chain.Rules, tx kv.TemporalRwTx, alloc types.GenesisAll
return nil, err
}
- _, err = domains.ComputeCommitment(context.Background(), tx, true, latestBlockNum, latestTxNum, "flush-commitment", nil)
- if err != nil {
- return nil, err
- }
-
- if err := domains.Flush(context2.Background(), tx); err != nil {
+ if _, err := sd.ComputeCommitment(context.Background(), tx, true, latestBlockNum, latestTxNum, "pre-state-commitment", nil); err != nil {
return nil, err
}
return statedb, nil
diff --git a/execution/vm/contract.go b/execution/vm/contract.go
index 88f2d3c89c9..025f9c7018d 100644
--- a/execution/vm/contract.go
+++ b/execution/vm/contract.go
@@ -64,7 +64,7 @@ type Contract struct {
}
// around 64MB cache in the worst case.
-var jumpDestCache = cache.NewGenericCache[bitvec](64*datasize.MB, func(v bitvec) int { return len(v) })
+var jumpDestCache = cache.NewGenericCache[bitvec](64*datasize.MB, func(v bitvec) int { return len(v) }, cache.ModeEvictLRU)
// NewContract returns a new contract environment for the execution of EVM.
func NewContract(caller accounts.Address, callerAddress accounts.Address, addr accounts.Address, value uint256.Int) *Contract {
@@ -113,7 +113,8 @@ func (c *Contract) isCode(udest uint64) bool {
c.analysis = codeBitmap(c.Code)
if !isCodeHashZero {
- jumpDestCache.Put(codeHash[:], c.analysis)
+ // content-addressed by codeHash and never unwound, so txNum is irrelevant
+ jumpDestCache.Put(codeHash[:], c.analysis, 0)
}
return c.analysis.codeSegment(udest)
diff --git a/node/eth/backend.go b/node/eth/backend.go
index 808db683762..761232de615 100644
--- a/node/eth/backend.go
+++ b/node/eth/backend.go
@@ -71,6 +71,7 @@ import (
"github.com/erigontech/erigon/db/state/statecfg"
"github.com/erigontech/erigon/diagnostics/diaglib"
"github.com/erigontech/erigon/diagnostics/mem"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/builder"
"github.com/erigontech/erigon/execution/chain"
chainspec "github.com/erigontech/erigon/execution/chain/spec"
@@ -694,9 +695,14 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger
if chainConfig.AmsterdamTime != nil {
// Always-on once gated, negotiation-driven: if no peer advertises eth/71
// this is a silent no-op per scan pass. When eth/71 peers connect, the
- // downloader backfills missing BALs into rawdb so subsequent stage_exec
- // runs can skip local BAL regeneration.
- go sentry_multi_client.NewBALDownloader(backend.sentryProvider.Client, backend.chainDB, logger).Run(backend.sentryCtx)
+ // downloader backfills missing BALs into the in-memory cache so
+ // subsequent serving (eth/71, RPC) can avoid local BAL regeneration.
+ balDownloader := sentry_multi_client.NewBALDownloader(backend.sentryProvider.Client, backend.chainDB, logger)
+ // Expose the on-demand FetchBALs entry point to sync hooks
+ // (engine_block_downloader after a batch, engine_newPayload when
+ // the CL payload doesn't carry a BAL).
+ balcache.SetBALSyncFetcher(balDownloader)
+ go balDownloader.Run(backend.sentryCtx)
}
var txnProvider txnprovider.TxnProvider
@@ -986,6 +992,19 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger
)
backend.execModule.SetPublishedSD(backend.notifications.Events.LatestSD)
+ // Install the BAL regenerator so eth/71 GetBlockAccessLists serving can
+ // fall back to re-executing the block when nothing is cached. BAL bytes
+ // are no longer persisted to MDBX (see db/rawdb/balcache.go), so older
+ // blocks needed by peers or RPC must be reconstructed on demand.
+ balcache.SetBALRegenerator(execmodule.NewBALRegenerator(execmodule.BALRegeneratorDeps{
+ DB: backend.chainDB,
+ ChainConfig: chainConfig,
+ Engine: backend.engine,
+ BlockReader: blockReader,
+ TxNumsReader: blockReader.TxnumReader(),
+ Logger: logger,
+ }))
+
var executionEngine executionclient.ExecutionEngine
executionEngine, err = executionclient.NewExecutionClientDirect(chainreader.NewChainReaderEth1(chainConfig, backend.execModule, config.FcuTimeout), txPoolRpcClient)
diff --git a/p2p/protocols/eth/handlers.go b/p2p/protocols/eth/handlers.go
index 068ad0224cc..92ead0bd5cd 100644
--- a/p2p/protocols/eth/handlers.go
+++ b/p2p/protocols/eth/handlers.go
@@ -31,6 +31,7 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/types"
@@ -191,7 +192,7 @@ var notAvailableSentinel = rlp.RawValue{0x80}
// size, MaxBlockAccessListsServe caps the disk-lookup count. When a limit is
// reached, the response is truncated (not padded with 0x80) — the peer sees a
// shorter array than requested, same convention as the BlockBodies handler.
-func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, blockReader services.HeaderReader) []rlp.RawValue { //nolint:unparam
+func AnswerGetBlockAccessListsQuery(ctx context.Context, db kv.Tx, query GetBlockAccessListsPacket, blockReader services.HeaderReader) []rlp.RawValue { //nolint:unparam
var bytes int
bals := make([]rlp.RawValue, 0, len(query))
@@ -200,18 +201,21 @@ func AnswerGetBlockAccessListsQuery(db kv.Tx, query GetBlockAccessListsPacket, b
lookups >= 2*MaxBlockAccessListsServe {
break
}
- number, _ := blockReader.HeaderNumber(context.Background(), db, hash)
+ number, _ := blockReader.HeaderNumber(ctx, db, hash)
if number == nil {
// We don't know the block — peer can retry elsewhere.
bals = append(bals, notAvailableSentinel)
bytes += len(notAvailableSentinel)
continue
}
- bal, _ := rawdb.ReadBlockAccessListBytes(db, hash, *number)
+ // 2-tier lookup: in-memory cache → installed BALRegenerator
+ // (re-executes the block when nothing is cached). No MDBX read.
+ bal, _ := balcache.BlockAccessListBytes(ctx, hash, *number)
if len(bal) == 0 {
- // We have the block but no BAL stored (pre-Amsterdam, or pruned).
- // Return 0x80 — unambiguously "not available", distinct from a
- // genuinely empty BAL which would be stored as 0xc0.
+ // We have the block header but no source produced a BAL
+ // (pre-Amsterdam, pruned, or regenerator absent/declined).
+ // Return 0x80 — unambiguously "not available", distinct from
+ // a genuinely empty BAL which would be 0xc0.
bals = append(bals, notAvailableSentinel)
bytes += len(notAvailableSentinel)
continue
diff --git a/p2p/protocols/eth/handlers_test.go b/p2p/protocols/eth/handlers_test.go
index 218704dd91f..42bfe459f44 100644
--- a/p2p/protocols/eth/handlers_test.go
+++ b/p2p/protocols/eth/handlers_test.go
@@ -9,7 +9,7 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/dbcfg"
"github.com/erigontech/erigon/db/kv/memdb"
- "github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/types"
@@ -571,6 +571,8 @@ func (balHeaderReader) Integrity(context.Context) error { panic("not expected")
// ethereum/EIPs#11553) for any hash we don't have stored — including unknown
// blocks and known blocks with no BAL recorded.
func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T) {
+ balcache.ResetBALCacheForTest()
+ t.Cleanup(balcache.ResetBALCacheForTest)
db := memdb.NewTestDB(t, dbcfg.ChainDB)
tx, err := db.BeginRw(context.Background())
if err != nil {
@@ -589,12 +591,10 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T)
}
bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP payload (non-empty)
- if err := rawdb.WriteBlockAccessListBytes(tx, hashKnownWithBAL, 100, bal); err != nil {
- t.Fatalf("WriteBlockAccessListBytes: %v", err)
- }
+ balcache.CacheBlockAccessList(hashKnownWithBAL, bal)
query := GetBlockAccessListsPacket{hashKnownWithBAL, hashUnknown, hashKnownNoBAL}
- result := AnswerGetBlockAccessListsQuery(tx, query, reader)
+ result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader)
if len(result) != 3 {
t.Fatalf("result len: have %d, want 3", len(result))
@@ -613,6 +613,8 @@ func TestAnswerGetBlockAccessListsQuery_OrderedResponseWithMissing(t *testing.T)
// TestAnswerGetBlockAccessListsQuery_SoftSizeLimit verifies the handler
// respects softResponseLimit by truncating the response (not padding).
func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) {
+ balcache.ResetBALCacheForTest()
+ t.Cleanup(balcache.ResetBALCacheForTest)
db := memdb.NewTestDB(t, dbcfg.ChainDB)
tx, err := db.BeginRw(context.Background())
if err != nil {
@@ -640,13 +642,11 @@ func TestAnswerGetBlockAccessListsQuery_SoftSizeLimit(t *testing.T) {
h := common.Hash{byte(i + 1)}
num := uint64(1000 + i)
reader[h] = num
- if err := rawdb.WriteBlockAccessListBytes(tx, h, num, bal); err != nil {
- t.Fatalf("WriteBlockAccessListBytes: %v", err)
- }
+ balcache.CacheBlockAccessList(h, bal)
query = append(query, h)
}
- result := AnswerGetBlockAccessListsQuery(tx, query, reader)
+ result := AnswerGetBlockAccessListsQuery(context.Background(), tx, query, reader)
if len(result) < 1 || len(result) >= len(query) {
t.Fatalf("expected truncation: have %d entries, want 1..%d", len(result), len(query)-1)
}
diff --git a/p2p/sentry/sentry_multi_client/bal_downloader.go b/p2p/sentry/sentry_multi_client/bal_downloader.go
index c711d71656c..f6d06c9ab0a 100644
--- a/p2p/sentry/sentry_multi_client/bal_downloader.go
+++ b/p2p/sentry/sentry_multi_client/bal_downloader.go
@@ -30,7 +30,7 @@ import (
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
- "github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/node/gointerfaces/sentryproto"
"github.com/erigontech/erigon/node/gointerfaces/typesproto"
)
@@ -198,17 +198,10 @@ func (d *BALDownloader) collectMissingBALs(ctx context.Context) ([]missingBAL, e
break
}
hash := hdr.Hash()
- existing, err := rawdb.ReadBlockAccessListBytes(tx, hash, n)
- if err != nil {
- // A real DB error here used to silently fall through, treating
- // this slot as "missing" — leading to refetch+rewrite+refetch
- // loops on every scan pass against a transient or persistent
- // DB issue. Log and skip; next scan will retry.
- d.logger.Debug("[bal-downloader] ReadBlockAccessListBytes failed, skipping slot",
- "n", n, "hash", hash, "err", err)
- continue
- }
- if len(existing) > 0 {
+ // Cache-only — BALs are not persisted to MDBX. If the cache has
+ // already absorbed this hash (recently produced locally or fetched
+ // from another peer this run), skip the fetch.
+ if _, ok := balcache.CachedBlockAccessList(hash); ok {
continue
}
missing = append(missing, missingBAL{
@@ -286,27 +279,19 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i
return
}
- // Write accepted entries. The fetcher already decoded EIP-8159 (post
+ // Cache accepted entries. The fetcher already decoded EIP-8159 (post
// ethereum/EIPs#11553) sentinels: nil = "peer doesn't have it" (was 0x80
// on the wire) — skip and retry next pass from another peer; {0xc0} =
- // "genuinely empty BAL, hash-verified" — write the canonical RLP so
- // callers that distinguish "have" vs "don't have" via rawdb see the
- // record; anything else = hash-validated BAL bytes.
+ // "genuinely empty BAL, hash-verified"; anything else = hash-validated
+ // BAL bytes. We cache rather than write to MDBX — see
+ // db/rawdb/balcache.go for the rationale.
var stored int
- if err := d.rwDB.Update(ctx, func(tx kv.RwTx) error {
- for i, payload := range got {
- if len(payload) == 0 {
- continue
- }
- if err := rawdb.WriteBlockAccessListBytes(tx, batch[i].hash, batch[i].number, payload); err != nil {
- return err
- }
- stored++
+ for i, payload := range got {
+ if len(payload) == 0 {
+ continue
}
- return nil
- }); err != nil {
- d.logger.Debug("[bal-downloader] db write failed", "err", err, "batch_size", len(batch))
- return
+ balcache.CacheBlockAccessList(batch[i].hash, payload)
+ stored++
}
if stored > 0 {
@@ -319,6 +304,48 @@ func (d *BALDownloader) fetchBatch(ctx context.Context, peer [64]byte, sentryI i
}
}
+// FetchBALs implements balcache.BALSyncFetcher: picks an eth/71 peer and
+// requests BALs for the given (hash, number, expected) tuples in a single
+// batched call, caching every hash-verified response. Non-blocking by intent
+// — failures (no peer, peer declined, timeout) are silently dropped because
+// the cache miss can still be filled later via the BALRegenerator. Sync
+// stages MUST NOT block on this fetch.
+//
+// Splits the request into batches that stay below the eth/71 softResponseLimit
+// (~2 MiB per response, ~24 BALs / batch).
+func (d *BALDownloader) FetchBALs(ctx context.Context, hashes []common.Hash, numbers []uint64, expected []common.Hash) {
+ if len(hashes) == 0 {
+ return
+ }
+ if len(numbers) != len(hashes) || len(expected) != len(hashes) {
+ d.logger.Debug("[bal-downloader] FetchBALs called with misaligned slices",
+ "hashes", len(hashes), "numbers", len(numbers), "expected", len(expected))
+ return
+ }
+ peer, sentryI, found, err := d.pickEth71Peer(ctx)
+ if err != nil || !found {
+ // No eth/71 peer available — caller can rely on the BALRegenerator
+ // when the cache miss surfaces.
+ return
+ }
+ const batchSize = 24
+ for i := 0; i < len(hashes); i += batchSize {
+ end := i + batchSize
+ if end > len(hashes) {
+ end = len(hashes)
+ }
+ batch := make([]missingBAL, 0, end-i)
+ for j := i; j < end; j++ {
+ batch = append(batch, missingBAL{
+ hash: hashes[j],
+ number: numbers[j],
+ expected: expected[j],
+ })
+ }
+ d.fetchBatch(ctx, peer, sentryI, batch)
+ }
+}
+
// pickEth71Peer iterates all sentries and returns a random peer that
// advertises the eth/71 capability, plus the index of the sentry that peer
// is connected via, or (_, _, false, nil) if none is connected. The sentry
diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client.go b/p2p/sentry/sentry_multi_client/sentry_multi_client.go
index c9e9df2f965..3a0b62c42bf 100644
--- a/p2p/sentry/sentry_multi_client/sentry_multi_client.go
+++ b/p2p/sentry/sentry_multi_client/sentry_multi_client.go
@@ -336,7 +336,7 @@ func (cs *MultiClient) getBlockAccessLists71(ctx context.Context, inreq *sentryp
return err
}
defer tx.Rollback()
- response := eth.AnswerGetBlockAccessListsQuery(tx, query.GetBlockAccessListsPacket, cs.blockReader)
+ response := eth.AnswerGetBlockAccessListsQuery(ctx, tx, query.GetBlockAccessListsPacket, cs.blockReader)
tx.Rollback()
b, err := rlp.EncodeToBytes(ð.BlockAccessListsPacket66{
RequestId: query.RequestId,
diff --git a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go
index f388f46894d..e7759d72304 100644
--- a/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go
+++ b/p2p/sentry/sentry_multi_client/sentry_multi_client_test.go
@@ -14,8 +14,8 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/dbcfg"
"github.com/erigontech/erigon/db/kv/temporal"
- "github.com/erigontech/erigon/db/rawdb"
"github.com/erigontech/erigon/db/services"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/node/direct"
@@ -291,9 +291,9 @@ func TestGetBlockAccessLists71_AnswersAndSends(t *testing.T) {
hashUnknown := common.Hash{0x02}
const knownBlockNum uint64 = 100
bal := []byte{0xc3, 0x01, 0x02, 0x03} // short valid RLP non-empty payload
- if err := rawdb.WriteBlockAccessListBytes(rwTx, hashKnown, knownBlockNum, bal); err != nil {
- t.Fatalf("WriteBlockAccessListBytes: %v", err)
- }
+ balcache.ResetBALCacheForTest()
+ t.Cleanup(balcache.ResetBALCacheForTest)
+ balcache.CacheBlockAccessList(hashKnown, bal)
if err := rwTx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
diff --git a/rpc/jsonrpc/debug_api.go b/rpc/jsonrpc/debug_api.go
index 8bac6552014..925582fa32b 100644
--- a/rpc/jsonrpc/debug_api.go
+++ b/rpc/jsonrpc/debug_api.go
@@ -30,6 +30,7 @@ import (
"github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/order"
"github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/rlp"
"github.com/erigontech/erigon/execution/stagedsync/stages"
"github.com/erigontech/erigon/execution/state"
@@ -63,6 +64,7 @@ type PrivateDebugAPI interface {
AccountAt(ctx context.Context, blockHash common.Hash, txIndex uint64, account common.Address) (*AccountResult, error)
GetRawHeader(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error)
GetRawBlock(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error)
+ GetRawBlockAccessList(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error)
GetRawReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]hexutil.Bytes, error)
GetBadBlocks(ctx context.Context) ([]map[string]any, error)
GetRawTransaction(ctx context.Context, hash common.Hash) (hexutil.Bytes, error)
@@ -671,6 +673,31 @@ func (api *DebugAPIImpl) GetRawBlock(ctx context.Context, blockNrOrHash rpc.Bloc
return rlp.EncodeToBytes(block)
}
+// GetRawBlockAccessList implements debug_getRawBlockAccessList — returns the
+// raw RLP-encoded BlockAccessList bytes (EIP-7928) that this node has stored
+// for the given block. Returns nil if no BAL is recorded (pre-Amsterdam, or
+// post-Amsterdam but not yet downloaded). The bytes returned are exactly what
+// the server-side eth/71 GetBlockAccessLists handler would send to a peer.
+func (api *DebugAPIImpl) GetRawBlockAccessList(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (hexutil.Bytes, error) {
+ tx, err := api.db.BeginTemporalRo(ctx)
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+ n, h, _, err := rpchelper.GetBlockNumber(ctx, blockNrOrHash, tx, api._blockReader, api.filters)
+ if err != nil {
+ if errors.As(err, &rpc.BlockNotFoundErr{}) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ bal, err := balcache.BlockAccessListBytes(ctx, h, n)
+ if err != nil {
+ return nil, fmt.Errorf("read block access list: %w", err)
+ }
+ return bal, nil
+}
+
// GetRawReceipts implements debug_getRawReceipts - retrieves and returns an array of EIP-2718 binary-encoded receipts of a single block
func (api *DebugAPIImpl) GetRawReceipts(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) ([]hexutil.Bytes, error) {
tx, err := api.db.BeginTemporalRo(ctx)
diff --git a/rpc/jsonrpc/eth_block_access_list.go b/rpc/jsonrpc/eth_block_access_list.go
index 041fb6752a4..7cf70db3038 100644
--- a/rpc/jsonrpc/eth_block_access_list.go
+++ b/rpc/jsonrpc/eth_block_access_list.go
@@ -20,7 +20,7 @@ import (
"context"
"errors"
- "github.com/erigontech/erigon/db/rawdb"
+ "github.com/erigontech/erigon/execution/balcache"
"github.com/erigontech/erigon/execution/types"
"github.com/erigontech/erigon/rpc"
"github.com/erigontech/erigon/rpc/ethapi"
@@ -66,7 +66,7 @@ func (api *APIImpl) GetBlockAccessList(ctx context.Context, numberOrHash rpc.Blo
}
}
- data, err := rawdb.ReadBlockAccessListBytes(tx, blockHash, blockNum)
+ data, err := balcache.BlockAccessListBytes(ctx, blockHash, blockNum)
if err != nil {
return nil, err
}
diff --git a/rpc/jsonrpc/eth_simulation.go b/rpc/jsonrpc/eth_simulation.go
index 8aba524ce6e..8d728b3586d 100644
--- a/rpc/jsonrpc/eth_simulation.go
+++ b/rpc/jsonrpc/eth_simulation.go
@@ -998,6 +998,13 @@ func (r *simulationStateReader) Clone(tx kv.TemporalTx) commitmentdb.StateReader
return newHistoryCommitmentOnlyReader(tx, r.sd, r.commitmentAsOfTxNum, r.plainStateAsOfTxNum)
}
+// CloneForWorker mirrors Clone. eth_simulation runs commitment single-threaded
+// (no concurrent warmup), so worker metering isn't needed here; deferred to the
+// state-read-path migration.
+func (r *simulationStateReader) CloneForWorker(_ context.Context, tx kv.TemporalTx) commitmentdb.StateReader {
+ return newHistoryCommitmentOnlyReader(tx, r.sd, r.commitmentAsOfTxNum, r.plainStateAsOfTxNum)
+}
+
func newHistoryCommitmentOnlyReader(roTx kv.TemporalTx, sd *execctx.SharedDomains, commitmentAsOfTxNum uint64, plainStateAsOfTxNum uint64) commitmentdb.StateReader {
return &simulationStateReader{sd: sd, roTx: roTx, commitmentAsOfTxNum: commitmentAsOfTxNum, plainStateAsOfTxNum: plainStateAsOfTxNum}
}