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} }