From ec77c919ad507cf68f4bd9dccd7b0dfff852f0de Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 14 Jul 2026 12:38:05 +0200 Subject: [PATCH 01/85] execution/cache, db/state/execctx: linearize snapshot read fills Serialize snapshot-freshness admission with canonical cache apply so an older RPC or read-ahead snapshot cannot repopulate state after an authoritative update or physical delete. Route account, storage, code, and derived code-hash fills through the combined admission APIs. Add embedded-RPC integration coverage plus cache and read-ahead concurrency tests. --- db/state/execctx/codehash_routing_test.go | 2 +- db/state/execctx/domain_shared.go | 58 ++------ .../statecache_rpc_integration_test.go | 136 ++++++++++++++++++ execution/cache/cache_test.go | 64 +++++++++ execution/cache/code_cache.go | 2 + execution/cache/state_cache.go | 109 ++++++++++++-- execution/exec/blocks_read_ahead.go | 40 +++--- execution/exec/blocks_read_ahead_test.go | 58 +++++++- 8 files changed, 381 insertions(+), 88 deletions(-) create mode 100644 db/state/execctx/statecache_rpc_integration_test.go diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index c11d892884d..ad591db06f6 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -45,7 +45,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - sc.PutAddrCodeHash(addr[:], staleArr, 0) + sc.PutAddrCodeHashIfFresh(addr[:], staleArr, 0, 0) t.Run("empty in-batch account wins (codeHash-no-code repro)", func(t *testing.T) { acc := accounts.Account{Nonce: 7, CodeHash: accounts.EmptyCodeHash} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index ed76fc775aa..a2bf725304c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1043,41 +1043,15 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun } for i := range pending { u := &pending[i] - switch u.domain { - case kv.CommitmentDomain: + if u.domain == kv.CommitmentDomain { if len(u.val) == 0 { sd.branchCache.Invalidate(u.key) } else { sd.branchCache.Put(u.key, u.val, uint64(u.step), u.txN) } - case kv.AccountsDomain: - if len(u.val) == 0 { - sd.stateCache.Delete(kv.AccountsDomain, u.key) - sd.stateCache.Delete(kv.CodeDomain, u.key) - sd.stateCache.DeleteAddrCodeHash(u.key) - } else { - sd.stateCache.Put(kv.AccountsDomain, u.key, u.val, u.txN) - sd.stateCache.DeleteAddrCodeHash(u.key) - } - case kv.StorageDomain: - if len(u.val) == 0 { - sd.stateCache.Delete(kv.StorageDomain, u.key) - } else { - sd.stateCache.Put(kv.StorageDomain, u.key, u.val, u.txN) - } - case kv.CodeDomain: - if len(u.val) == 0 { - sd.stateCache.Delete(kv.CodeDomain, u.key) - } else { - // Validated committed code: populate the addr layer AND the - // content-addressed codeHash->code map, keyed by keccak(v) so each - // entry is self-consistent by construction. The read-fill path - // (PutCodeWithHash on a cold GetLatest, below) populates the same - // way — both key on keccak(v), never a separately-read account - // codeHash, so the shared map only ever holds self-consistent entries. - sd.stateCache.PutCodeWithHash(u.key, u.val, crypto.Keccak256(u.val), u.txN) - } + continue } + sd.stateCache.Apply(u.domain, u.key, u.val, u.txN) } return nil } @@ -1257,26 +1231,20 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k return nil, 0, fmt.Errorf("storage %x read error: %w", k, err) } - // Populate state cache on successful read. Stamp with an upper bound on the - // value's write txNum (the read only gives us the file/step it came from): - // the last txNum of that step. An unwind below this bound can't leave the - // value stale, so frozen-step reads stay warm across unwinds while - // recent-step reads are dropped. - if sd.stateCache != nil { + // Snapshot freshness is rechecked while the fill is serialized against + // committed cache updates. + if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { + snapshotProgress := tx.Debug().DomainProgress(domain) readTxNum := (uint64(step)+1)*sd.StepSize() - 1 if domain == kv.CodeDomain { if len(v) > 0 { - // This SD getter is the single place that populates the code cache - // on a read. Key the content-addressed entry by the code's OWN hash, - // keccak(v) — NEVER a separately-read account codeHash, which under - // parallel exec can be a skewed/cross-account value and would poison - // the shared codeHash->code map for every account sharing that hash. - // keccak(v) makes every cached entry self-consistent, so a skewed - // account read can never produce a bad entry. - sd.stateCache.PutCodeWithHash(k, v, crypto.Keccak256(v), readTxNum) + sd.stateCache.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), readTxNum, snapshotProgress) } } else { - sd.stateCache.Put(domain, k, v, readTxNum) + if len(v) == 0 { + readTxNum = snapshotProgress + } + sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotProgress) } } // Only cache a branch when the read's txN is known: a txN=0 entry would @@ -1460,7 +1428,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // repeat lookups skip the whole resolve() chain. txNum is a // conservative upper bound (>= the resolved account's write txNum), so // the mapping drops on any unwind that reverts that account. - sd.stateCache.PutAddrCodeHash(addr, fixed, txNum) + sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, tx.Debug().DomainProgress(kv.AccountsDomain)) } return h } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go new file mode 100644 index 00000000000..f9b840f9676 --- /dev/null +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -0,0 +1,136 @@ +// 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 execctx_test + +import ( + "testing" + + "github.com/c2h5oh/datasize" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/cache" + "github.com/erigontech/erigon/execution/execmodule" + "github.com/erigontech/erigon/execution/types/accounts" + "github.com/erigontech/erigon/node/shards" +) + +func TestEmbeddedRPCCacheViewDoesNotResurrectDeletedAccount(t *testing.T) { + testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t, kv.AccountsDomain) +} + +func TestEmbeddedRPCCacheViewDoesNotResurrectDeletedStorage(t *testing.T) { + testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t, kv.StorageDomain) +} + +func TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode(t *testing.T) { + testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t, kv.CodeDomain) +} + +func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain kv.Domain) { + t.Helper() + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + budget := 1 * datasize.MB + stateCache := cache.NewStateCache(budget, budget, budget, budget) + t.Cleanup(stateCache.Close) + + keyLen := 20 + if domain == kv.StorageDomain { + keyLen = 52 + } + key := make([]byte, keyLen) + key[0] = 0xab + value := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + Balance: *uint256.NewInt(1), + }) + switch domain { + case kv.StorageDomain: + value = []byte{0x01} + case kv.CodeDomain: + value = []byte{0xaa, 1, 2, 3} + } + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedDomains.Close() + seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetTxNum(10) + require.NoError(t, seedDomains.DomainPut(domain, seedTx, key, value, 10, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + deleteTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer deleteTx.Rollback() + deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) + require.NoError(t, err) + defer deleteDomains.Close() + deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetTxNum(20) + require.NoError(t, deleteDomains.DomainDel(domain, deleteTx, key, 20, value)) + + events := shards.NewEvents() + events.PublishOverlay(deleteDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) + events.PublishOverlay(nil) + deleteDomains.Close() + + oldValue, _, err := rpcTx.GetLatest(domain, key) + require.NoError(t, err) + require.Equal(t, value, oldValue) + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshValue, _, err := freshTx.GetLatest(domain, key) + require.NoError(t, err) + require.Empty(t, freshValue) + + if domain == kv.CodeDomain { + _, err = rpcView.GetCode(key) + } else { + _, err = rpcView.Get(key) + } + require.NoError(t, err) + + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + got, _, err := freshDomains.GetLatest(domain, freshTx, key) + require.NoError(t, err) + require.Empty(t, got, "the old RPC snapshot must not repopulate the shared cache after the deletion") +} diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 3051a4f365c..d567a295432 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -881,3 +881,67 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { require.Equal(t, fresh, v, "round %d: PutIfAbsent raced past a concurrent Put", round) } } + +func TestStateCache_AppliedProgressLifecycle(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + require.Zero(t, sc.appliedProgress[kv.AccountsDomain]) + + sc.Apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 20) + sc.Apply(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) + require.Equal(t, uint64(20), sc.appliedProgress[kv.AccountsDomain]) + require.Zero(t, sc.appliedProgress[kv.StorageDomain]) + + sc.Unwind(15) + require.Equal(t, uint64(15), sc.appliedProgress[kv.AccountsDomain]) + + sc.Clear() + require.Zero(t, sc.appliedProgress[kv.AccountsDomain]) +} + +func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + stale := makeValue(1) + sc.Apply(kv.AccountsDomain, key, stale, 10) + sc.Apply(kv.AccountsDomain, key, nil, 20) + _, ok := sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "an authoritative deletion must physically remove the entry") + + sc.PutIfFresh(kv.AccountsDomain, key, stale, 10, 10) + _, ok = sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "a snapshot older than the deletion must not fill afterward") +} + +func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + progressKey := makeAddr(1) + key := makeAddr(2) + value := makeValue(1) + for round := range 20000 { + snapshotProgress := uint64(round*2 + 1) + sc.Apply(kv.AccountsDomain, progressKey, value, snapshotProgress) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + sc.Apply(kv.AccountsDomain, key, nil, snapshotProgress+1) + }() + go func() { + defer wg.Done() + sc.PutIfFresh(kv.AccountsDomain, key, value, snapshotProgress, snapshotProgress) + }() + wg.Wait() + + _, ok := sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "round %d: stale fill survived the authoritative delete", round) + } +} diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index 48c3a266a0a..e1ee086eeb2 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -507,7 +507,9 @@ func (c *CodeCache) PutCodeSizeByCodeHash(codeHash []byte, size int, txNum uint6 // Delete removes the address → code mapping for addr. func (c *CodeCache) Delete(addr []byte) { + c.addrBindMu.Lock() c.addrToHash.Remove(common.BytesToAddress(addr)) + c.addrBindMu.Unlock() } // Clear hard-resets every layer and the epoch/floor. Use on Reset / diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 1d215a350e4..9598bc40e1e 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -19,10 +19,12 @@ package cache import ( "bytes" "strings" + "sync" "github.com/c2h5oh/datasize" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" @@ -52,7 +54,9 @@ const ( // Account and Storage use GenericCache. // Code uses CodeCache (two-level for deduplication). type StateCache struct { - caches [kv.DomainLen]Cache + caches [kv.DomainLen]Cache + admissionMu sync.RWMutex + appliedProgress [kv.DomainLen]uint64 } // NewStateCache creates a new StateCache with the specified byte capacities. @@ -109,7 +113,7 @@ func NewDefaultStateCache() *StateCache { } // Get retrieves data for the given domain and key. -// Returns (value, true) on cache hit — including (nil, true) for deleted keys — +// Returns (value, true) on cache hit — including (nil, true) for cached negatives — // and (nil, false) on cache miss. func (c *StateCache) Get(domain kv.Domain, key []byte) ([]byte, bool) { cache := c.caches[domain] @@ -152,9 +156,13 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) c.putCodeWithHash(addr, code, codeHash, txNum, true) } -// PutCodeWithHashIfAbsent is PutCodeWithHash with if-absent binding semantics -// (see Cache.PutIfAbsent). -func (c *StateCache) PutCodeWithHashIfAbsent(addr, code, codeHash []byte, txNum uint64) { +// PutCodeWithHashIfFresh conditionally fills code from a current snapshot. +func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotProgress uint64) { + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if snapshotProgress < c.appliedProgress[kv.CodeDomain] { + return + } c.putCodeWithHash(addr, code, codeHash, txNum, false) } @@ -203,10 +211,7 @@ func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { 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, txNum uint64) { +func (c *StateCache) putAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return @@ -214,10 +219,17 @@ func (c *StateCache) PutAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { cc.PutAddrCodeHash(addr, h, txNum) } -// 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) { +// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. +func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotProgress uint64) { + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if snapshotProgress < c.appliedProgress[kv.AccountsDomain] { + return + } + c.putAddrCodeHash(addr, h, txNum) +} + +func (c *StateCache) deleteAddrCodeHash(addr []byte) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return @@ -231,8 +243,13 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 c.put(domain, key, value, txNum, true) } -// PutIfAbsent is Put with if-absent semantics (see Cache.PutIfAbsent). -func (c *StateCache) PutIfAbsent(domain kv.Domain, key []byte, value []byte, txNum uint64) { +// PutIfFresh conditionally fills a domain from a current snapshot. +func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotProgress uint64) { + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if snapshotProgress < c.appliedProgress[domain] { + return + } c.put(domain, key, value, txNum, false) } @@ -260,13 +277,68 @@ func (c *StateCache) Delete(domain kv.Domain, key []byte) { cache.Delete(key) } +// Apply makes a committed domain update authoritative for subsequent fills. +func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { + var codeHash []byte + if domain == kv.CodeDomain && len(value) > 0 { + codeHash = crypto.Keccak256(value) + } + + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + cache := c.caches[domain] + if cache == nil { + return + } + c.noteApplied(domain, txNum) + + switch domain { + case kv.AccountsDomain: + putOrDelete(cache, key, value, txNum) + c.deleteAddrCodeHash(key) + if len(value) == 0 { + c.noteApplied(kv.CodeDomain, txNum) + if codeCache := c.caches[kv.CodeDomain]; codeCache != nil { + codeCache.Delete(key) + } + } + case kv.CodeDomain: + if len(value) == 0 { + cache.Delete(key) + } else if codeCache, ok := cache.(*CodeCache); ok { + codeCache.PutWithCodeHash(key, common.Copy(value), codeHash, txNum) + } + default: + putOrDelete(cache, key, value, txNum) + } +} + +func putOrDelete(cache Cache, key, value []byte, txNum uint64) { + if len(value) == 0 { + cache.Delete(key) + return + } + cache.Put(key, common.Copy(value), txNum) +} + +func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { + if txNum > c.appliedProgress[domain] { + c.appliedProgress[domain] = txNum + } +} + // Clear removes all mutable entries from all caches. func (c *StateCache) Clear() { + c.admissionMu.Lock() + defer c.admissionMu.Unlock() for _, cache := range c.caches { if cache != nil { cache.Clear() } } + for i := range c.appliedProgress { + c.appliedProgress[i] = 0 + } } // Close releases every sub-cache's slot in the shared memory envelope so later @@ -285,11 +357,18 @@ func (c *StateCache) Close() { // and drops stale entries lazily on read. This is the sole cache-invalidation // path on unwind — the executor never touches the cache during forward execution. func (c *StateCache) Unwind(unwindToTxNum uint64) { + c.admissionMu.Lock() + defer c.admissionMu.Unlock() for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) } } + for i := range c.appliedProgress { + if c.appliedProgress[i] > unwindToTxNum { + c.appliedProgress[i] = unwindToTxNum + } + } } // GetCache returns the cache for the given domain. diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 57eb3a6fdb0..5d7a8b02b18 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -85,31 +85,27 @@ type cachePopulatingGetter struct { g kv.TemporalGetter sc *cache.StateCache stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) + progress func(kv.Domain) uint64 +} + +func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePopulatingGetter { + return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), progress: ttx.Debug().DomainProgress} } 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-absent writes only: this runs in a fire-and-forget goroutine over a - // committed snapshot, so an unconditional Put racing an FCU flush's - // cache-apply could replace the flushed value with the pre-flush one. - if name == kv.CodeDomain && len(v) > 0 { - // Key the content cache by the code's OWN hash, never a separately - // read account codeHash: under parallel/speculative exec that hash - // can be skewed or cross-account, and a (hash, code) pair that - // doesn't satisfy keccak(code)==hash poisons every account sharing - // the hash. keccak(v) makes each entry self-consistent. - cpg.sc.PutCodeWithHashIfAbsent(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1) + if err == nil && cpg.sc != nil && cpg.progress != nil { + snapshotProgress := cpg.progress(name) + if name == kv.CodeDomain { + if len(v) > 0 { + cpg.sc.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1, snapshotProgress) + } } else { - // Cache including nil/empty results: a probe returning no - // bytes is a valid negative answer (missing account, empty - // storage slot; empty code lands here too but CodeCache drops - // zero-length puts) 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.PutIfAbsent(name, k, v, (uint64(step)+1)*cpg.stepSize-1) + txNum := (uint64(step)+1)*cpg.stepSize - 1 + if len(v) == 0 { + txNum = snapshotProgress + } + cpg.sc.PutIfFresh(name, k, v, txNum, snapshotProgress) } } return v, step, err @@ -229,7 +225,7 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t } var getter kv.TemporalGetter = ttx if bra.stateCache != nil { - getter = &cachePopulatingGetter{g: ttx, sc: bra.stateCache, stepSize: ttx.Debug().StepSize()} + getter = newCachePopulatingGetter(ttx, bra.stateCache) } stateReader := state.NewReaderV3(getter) @@ -300,7 +296,7 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t var getter kv.TemporalGetter = ttx var cpg *cachePopulatingGetter if bra.stateCache != nil { - cpg = &cachePopulatingGetter{g: ttx, sc: bra.stateCache, stepSize: ttx.Debug().StepSize()} + cpg = newCachePopulatingGetter(ttx, bra.stateCache) getter = cpg } stateReader := state.NewReaderV3(getter) diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index f3a88e4d15c..7fd7536569d 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -60,7 +60,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) @@ -80,7 +80,7 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -107,7 +107,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { } sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -116,10 +116,58 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} + cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) got, ok = sc.Get(kv.AccountsDomain, key) require.True(t, ok) require.Empty(t, got) } + +func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { + key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") + sc := newTestStateCache() + cpg := &cachePopulatingGetter{ + g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, + progress: func(kv.Domain) uint64 { return 10_000_000 }, + } + _, _, err := cpg.GetLatest(kv.AccountsDomain, key) + require.NoError(t, err) + _, ok := sc.Get(kv.AccountsDomain, key) + require.True(t, ok) + + sc.Unwind(5_000_000) + _, ok = sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "a negative observed at txNum 10M must not survive an unwind to 5M") +} + +func TestCachePopulatingGetterNilProgressNeverFills(t *testing.T) { + key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") + sc := newTestStateCache() + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} + require.NotPanics(t, func() { + _, _, err := cpg.GetLatest(kv.AccountsDomain, key) + require.NoError(t, err) + }) + _, ok := sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "no progress oracle — nothing may be cached") +} + +func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { + key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") + sc := newTestStateCache() + sc.Apply(kv.AccountsDomain, key, nil, 20) + cpg := &cachePopulatingGetter{ + g: stubTemporalGetter{v: []byte("pre-delete-record")}, + sc: sc, + stepSize: 1_562_500, + progress: func(kv.Domain) uint64 { return 10 }, + } + + _, _, err := cpg.GetLatest(kv.AccountsDomain, key) + require.NoError(t, err) + _, ok := sc.Get(kv.AccountsDomain, key) + require.False(t, ok) +} + +func zeroProgress(kv.Domain) uint64 { return 0 } From f99e10eb241db22fd4ff5a26020f20bc78827c4c Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 12:36:14 +0200 Subject: [PATCH 02/85] execution/cache, db/state: use exclusive snapshot frontiers --- db/kv/kv_interface.go | 3 ++ db/kv/remotedb/kv_remote.go | 3 ++ db/kv/temporal/kv_temporal.go | 6 ++++ db/state/aggregator.go | 8 +++++ db/state/execctx/domain_shared.go | 25 ++++++++------ db/state/inverted_index.go | 18 ++++++++++ db/state/inverted_index_test.go | 30 +++++++++++++++++ execution/cache/cache_test.go | 43 ++++++++++++++++++------ execution/cache/state_cache.go | 37 +++++++++++--------- execution/exec/blocks_read_ahead.go | 21 +++++++----- execution/exec/blocks_read_ahead_test.go | 24 ++++++------- 11 files changed, 159 insertions(+), 59 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 4048d7dfd1e..6c3811dee58 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -513,6 +513,9 @@ type TemporalDebugTx interface { HistoryStartFrom(domainName Domain) uint64 DomainProgress(domain Domain) (txNum uint64) + // DomainProgressAndVisibleEnd returns DomainProgress and the exact exclusive + // frontier used for cache admission. ok is false without history. + DomainProgressAndVisibleEnd(domain Domain) (progress, visibleEnd uint64, ok bool) IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 // Retire retires frozen history files entirely below their diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index e3d9d0a490b..01db5f42b48 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -253,6 +253,9 @@ func (tx *tx) Retire(ctx context.Context, cutoffs kv.RetireCutoffs) (int, error) } func (tx *tx) DomainFiles(domain ...kv.Domain) kv.VisibleFiles { panic("not implemented") } func (tx *tx) DomainProgress(domain kv.Domain) uint64 { panic("not implemented") } +func (tx *tx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { + return 0, 0, false +} func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 04d2c459c5f..5dcecc491ba 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -757,6 +757,12 @@ func (tx *Tx) DomainProgress(domain kv.Domain) uint64 { func (tx *RwTx) DomainProgress(domain kv.Domain) uint64 { return tx.aggtx.DomainProgress(domain, tx.RwTx) } +func (tx *Tx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { + return tx.aggtx.DomainProgressAndVisibleEnd(domain, tx.Tx) +} +func (tx *RwTx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { + return tx.aggtx.DomainProgressAndVisibleEnd(domain, tx.RwTx) +} func (tx *Tx) IIProgress(domain kv.InvertedIdx) uint64 { return tx.aggtx.IIProgress(domain, tx.Tx) } diff --git a/db/state/aggregator.go b/db/state/aggregator.go index f9123794e65..393c7a9ad33 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2488,6 +2488,14 @@ func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { } return at.d[name].ht.iit.Progress(tx) } +func (at *AggregatorRoTx) DomainProgressAndVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, uint64, bool) { + d := at.d[name] + if d.d.HistoryDisabled { + return 0, 0, false + } + progress, visibleEnd := d.ht.iit.progressAndVisibleEnd(tx) + return progress, visibleEnd, true +} func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { return at.searchII(name).Progress(tx) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index a2bf725304c..d2e0c5b23d5 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1234,17 +1234,18 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // Snapshot freshness is rechecked while the fill is serialized against // committed cache updates. if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { - snapshotProgress := tx.Debug().DomainProgress(domain) - readTxNum := (uint64(step)+1)*sd.StepSize() - 1 - if domain == kv.CodeDomain { - if len(v) > 0 { - sd.stateCache.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), readTxNum, snapshotProgress) - } - } else { - if len(v) == 0 { - readTxNum = snapshotProgress + if snapshotProgress, snapshotEnd, ok := tx.Debug().DomainProgressAndVisibleEnd(domain); ok { + readTxNum := (uint64(step)+1)*sd.StepSize() - 1 + if domain == kv.CodeDomain { + if len(v) > 0 { + sd.stateCache.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), readTxNum, snapshotEnd) + } + } else { + if len(v) == 0 { + readTxNum = snapshotProgress + } + sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotEnd) } - sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotProgress) } } // Only cache a branch when the read's txN is known: a txN=0 entry would @@ -1428,7 +1429,9 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // repeat lookups skip the whole resolve() chain. txNum is a // conservative upper bound (>= the resolved account's write txNum), so // the mapping drops on any unwind that reverts that account. - sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, tx.Debug().DomainProgress(kv.AccountsDomain)) + if _, snapshotEnd, ok := tx.Debug().DomainProgressAndVisibleEnd(kv.AccountsDomain); ok { + sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, snapshotEnd) + } } return h } diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index bbac3994125..6b5fa566c62 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1237,6 +1237,24 @@ func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { return 0 } +func (ii *InvertedIndex) progressAndVisibleEndInDB(tx kv.Tx) (uint64, uint64) { + lst, _ := kv.LastKey(tx, ii.KeysTable) + if len(lst) == 0 { + return 0, 0 + } + txNum := binary.BigEndian.Uint64(lst) + if txNum == math.MaxUint64 { + return txNum, txNum + } + return txNum, txNum + 1 +} + func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { return max(iit.files.EndTxNum(), iit.ii.maxTxNumInDB(tx)) } + +func (iit *InvertedIndexRoTx) progressAndVisibleEnd(tx kv.Tx) (uint64, uint64) { + filesEnd := iit.files.EndTxNum() + dbProgress, dbEnd := iit.ii.progressAndVisibleEndInDB(tx) + return max(filesEnd, dbProgress), max(filesEnd, dbEnd) +} diff --git a/db/state/inverted_index_test.go b/db/state/inverted_index_test.go index 4a6dccb41a4..4ba06f40a5d 100644 --- a/db/state/inverted_index_test.go +++ b/db/state/inverted_index_test.go @@ -87,6 +87,36 @@ func testDbAndInvertedIndex(tb testing.TB, aggStep uint64, logger log.Logger) (k return db, ii } +func TestInvertedIndexProgressAndVisibleEnd(t *testing.T) { + db, ii := testDbAndInvertedIndex(t, 16, log.New()) + tx, err := db.BeginRw(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + iit := ii.beginForTests() + defer iit.Close() + progress, visibleEnd := iit.progressAndVisibleEnd(tx) + require.Zero(t, progress) + require.Zero(t, visibleEnd) + + var txNum [8]byte + require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) + progress, visibleEnd = iit.progressAndVisibleEnd(tx) + require.Zero(t, progress) + require.Equal(t, uint64(1), visibleEnd) + + binary.BigEndian.PutUint64(txNum[:], 100) + require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) + progress, visibleEnd = iit.progressAndVisibleEnd(tx) + require.Equal(t, uint64(100), progress) + require.Equal(t, uint64(101), visibleEnd) + + iit.files = visibleFiles{{endTxNum: 200}} + progress, visibleEnd = iit.progressAndVisibleEnd(tx) + require.Equal(t, uint64(200), progress) + require.Equal(t, uint64(200), visibleEnd) +} + func TestInvIndexPruningCorrectness(t *testing.T) { t.Parallel() diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index d567a295432..bc18a578515 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -882,22 +882,22 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -func TestStateCache_AppliedProgressLifecycle(t *testing.T) { +func TestStateCache_AppliedEndLifecycle(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) t.Cleanup(sc.Close) - require.Zero(t, sc.appliedProgress[kv.AccountsDomain]) + require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) sc.Apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 20) sc.Apply(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) - require.Equal(t, uint64(20), sc.appliedProgress[kv.AccountsDomain]) - require.Zero(t, sc.appliedProgress[kv.StorageDomain]) + require.Equal(t, uint64(21), sc.appliedEnd[kv.AccountsDomain]) + require.Zero(t, sc.appliedEnd[kv.StorageDomain]) sc.Unwind(15) - require.Equal(t, uint64(15), sc.appliedProgress[kv.AccountsDomain]) + require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain]) sc.Clear() - require.Zero(t, sc.appliedProgress[kv.AccountsDomain]) + require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) } func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { @@ -912,11 +912,31 @@ func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { _, ok := sc.Get(kv.AccountsDomain, key) require.False(t, ok, "an authoritative deletion must physically remove the entry") - sc.PutIfFresh(kv.AccountsDomain, key, stale, 10, 10) + sc.PutIfFresh(kv.AccountsDomain, key, stale, 10, 11) _, ok = sc.Get(kv.AccountsDomain, key) require.False(t, ok, "a snapshot older than the deletion must not fill afterward") } +func TestStateCache_FileEndSnapshotCannotFillAtAppliedTx(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + stale := makeValue(1) + sc.Apply(kv.AccountsDomain, key, nil, 100) + + sc.PutIfFresh(kv.AccountsDomain, key, stale, 99, 100) + _, ok := sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "a [0,100) snapshot does not contain the applied tx 100") + + fresh := makeValue(2) + sc.PutIfFresh(kv.AccountsDomain, key, fresh, 100, 101) + got, ok := sc.Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, fresh, got) +} + func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) @@ -926,18 +946,19 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { key := makeAddr(2) value := makeValue(1) for round := range 20000 { - snapshotProgress := uint64(round*2 + 1) - sc.Apply(kv.AccountsDomain, progressKey, value, snapshotProgress) + appliedTxNum := uint64(round*2 + 1) + snapshotEnd := appliedTxNum + 1 + sc.Apply(kv.AccountsDomain, progressKey, value, appliedTxNum) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - sc.Apply(kv.AccountsDomain, key, nil, snapshotProgress+1) + sc.Apply(kv.AccountsDomain, key, nil, snapshotEnd) }() go func() { defer wg.Done() - sc.PutIfFresh(kv.AccountsDomain, key, value, snapshotProgress, snapshotProgress) + sc.PutIfFresh(kv.AccountsDomain, key, value, appliedTxNum, snapshotEnd) }() wg.Wait() diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 9598bc40e1e..b7952decee8 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -18,6 +18,7 @@ package cache import ( "bytes" + "math" "strings" "sync" @@ -54,9 +55,9 @@ const ( // Account and Storage use GenericCache. // Code uses CodeCache (two-level for deduplication). type StateCache struct { - caches [kv.DomainLen]Cache - admissionMu sync.RWMutex - appliedProgress [kv.DomainLen]uint64 + caches [kv.DomainLen]Cache + admissionMu sync.RWMutex + appliedEnd [kv.DomainLen]uint64 } // NewStateCache creates a new StateCache with the specified byte capacities. @@ -157,10 +158,10 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) } // PutCodeWithHashIfFresh conditionally fills code from a current snapshot. -func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotProgress uint64) { +func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotEnd uint64) { c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if snapshotProgress < c.appliedProgress[kv.CodeDomain] { + if snapshotEnd < c.appliedEnd[kv.CodeDomain] { return } c.putCodeWithHash(addr, code, codeHash, txNum, false) @@ -220,10 +221,10 @@ func (c *StateCache) putAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { } // PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. -func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotProgress uint64) { +func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if snapshotProgress < c.appliedProgress[kv.AccountsDomain] { + if snapshotEnd < c.appliedEnd[kv.AccountsDomain] { return } c.putAddrCodeHash(addr, h, txNum) @@ -244,10 +245,10 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 } // PutIfFresh conditionally fills a domain from a current snapshot. -func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotProgress uint64) { +func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotEnd uint64) { c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if snapshotProgress < c.appliedProgress[domain] { + if snapshotEnd < c.appliedEnd[domain] { return } c.put(domain, key, value, txNum, false) @@ -322,8 +323,12 @@ func putOrDelete(cache Cache, key, value []byte, txNum uint64) { } func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { - if txNum > c.appliedProgress[domain] { - c.appliedProgress[domain] = txNum + end := txNum + if end < math.MaxUint64 { + end++ + } + if end > c.appliedEnd[domain] { + c.appliedEnd[domain] = end } } @@ -336,8 +341,8 @@ func (c *StateCache) Clear() { cache.Clear() } } - for i := range c.appliedProgress { - c.appliedProgress[i] = 0 + for i := range c.appliedEnd { + c.appliedEnd[i] = 0 } } @@ -364,9 +369,9 @@ func (c *StateCache) Unwind(unwindToTxNum uint64) { cache.Unwind(unwindToTxNum) } } - for i := range c.appliedProgress { - if c.appliedProgress[i] > unwindToTxNum { - c.appliedProgress[i] = unwindToTxNum + for i := range c.appliedEnd { + if c.appliedEnd[i] > unwindToTxNum { + c.appliedEnd[i] = unwindToTxNum } } } diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 5d7a8b02b18..a0d1fb5a4bb 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -82,30 +82,33 @@ func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { // (codeHash→bytes) + size-cache layers via PutCodeWithHash, keyed by the // code's own keccak hash so every cached pair is self-consistent. type cachePopulatingGetter struct { - g kv.TemporalGetter - sc *cache.StateCache - stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) - progress func(kv.Domain) uint64 + g kv.TemporalGetter + sc *cache.StateCache + stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) + progressBounds func(kv.Domain) (uint64, uint64, bool) } func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePopulatingGetter { - return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), progress: ttx.Debug().DomainProgress} + return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), progressBounds: ttx.Debug().DomainProgressAndVisibleEnd} } 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 && cpg.progress != nil { - snapshotProgress := cpg.progress(name) + if err == nil && cpg.sc != nil && cpg.progressBounds != nil { + snapshotProgress, snapshotEnd, ok := cpg.progressBounds(name) + if !ok { + return v, step, nil + } if name == kv.CodeDomain { if len(v) > 0 { - cpg.sc.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1, snapshotProgress) + cpg.sc.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1, snapshotEnd) } } else { txNum := (uint64(step)+1)*cpg.stepSize - 1 if len(v) == 0 { txNum = snapshotProgress } - cpg.sc.PutIfFresh(name, k, v, txNum, snapshotProgress) + cpg.sc.PutIfFresh(name, k, v, txNum, snapshotEnd) } } return v, step, err diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 7fd7536569d..df031773cfa 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -60,7 +60,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) @@ -80,7 +80,7 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -107,7 +107,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { } sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -116,7 +116,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, progress: zeroProgress} + cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) got, ok = sc.Get(kv.AccountsDomain, key) @@ -129,7 +129,7 @@ func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { sc := newTestStateCache() cpg := &cachePopulatingGetter{ g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, - progress: func(kv.Domain) uint64 { return 10_000_000 }, + progressBounds: func(kv.Domain) (uint64, uint64, bool) { return 10_000_000, 10_000_001, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) @@ -141,7 +141,7 @@ func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { require.False(t, ok, "a negative observed at txNum 10M must not survive an unwind to 5M") } -func TestCachePopulatingGetterNilProgressNeverFills(t *testing.T) { +func TestCachePopulatingGetterNilProgressBoundsNeverFills(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} @@ -158,10 +158,10 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { sc := newTestStateCache() sc.Apply(kv.AccountsDomain, key, nil, 20) cpg := &cachePopulatingGetter{ - g: stubTemporalGetter{v: []byte("pre-delete-record")}, - sc: sc, - stepSize: 1_562_500, - progress: func(kv.Domain) uint64 { return 10 }, + g: stubTemporalGetter{v: []byte("pre-delete-record")}, + sc: sc, + stepSize: 1_562_500, + progressBounds: func(kv.Domain) (uint64, uint64, bool) { return 10, 11, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) @@ -170,4 +170,4 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { require.False(t, ok) } -func zeroProgress(kv.Domain) uint64 { return 0 } +func zeroProgressBounds(kv.Domain) (uint64, uint64, bool) { return 0, 0, true } From 421d0d34dcc23aafe5b5b66b0226e70bc0f7e971 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 12:55:20 +0200 Subject: [PATCH 03/85] execution/cache: partition admission locks by coherence group --- execution/cache/cache_test.go | 53 ++++++++++++++++++++++++++++++++++ execution/cache/state_cache.go | 50 +++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 13 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index bc18a578515..12189ab057f 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -20,6 +20,7 @@ import ( "bytes" "sync" "testing" + "time" "github.com/c2h5oh/datasize" "github.com/stretchr/testify/assert" @@ -882,6 +883,58 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } +type blockingPutCache struct { + Cache + putStarted chan struct{} + continuePut chan struct{} +} + +func (c *blockingPutCache) Put(key []byte, value []byte, txNum uint64) { + close(c.putStarted) + <-c.continuePut + c.Cache.Put(key, value, txNum) +} + +func TestStateCache_CrossDomainFillDoesNotWaitForApply(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + accountCache := &blockingPutCache{ + Cache: sc.caches[kv.AccountsDomain], + putStarted: make(chan struct{}), + continuePut: make(chan struct{}), + } + sc.caches[kv.AccountsDomain] = accountCache + + accountDone := make(chan struct{}) + go func() { + defer close(accountDone) + sc.Apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 1) + }() + <-accountCache.putStarted + + storageKey := makeAddr(2) + storageDone := make(chan struct{}) + go func() { + defer close(storageDone) + sc.PutIfFresh(kv.StorageDomain, storageKey, makeValue(2), 1, 2) + }() + + select { + case <-storageDone: + case <-time.After(time.Second): + close(accountCache.continuePut) + <-accountDone + t.Fatal("a storage fill waited for an unrelated account apply") + } + close(accountCache.continuePut) + <-accountDone + + _, ok := sc.Get(kv.StorageDomain, storageKey) + require.True(t, ok) +} + func TestStateCache_AppliedEndLifecycle(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index b7952decee8..52b8c7da1ba 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -56,10 +56,30 @@ const ( // Code uses CodeCache (two-level for deduplication). type StateCache struct { caches [kv.DomainLen]Cache - admissionMu sync.RWMutex + admissionMu [kv.DomainLen]sync.RWMutex appliedEnd [kv.DomainLen]uint64 } +func (c *StateCache) admissionLock(domain kv.Domain) *sync.RWMutex { + // Account updates also invalidate address-keyed code state. + if domain == kv.CodeDomain { + domain = kv.AccountsDomain + } + return &c.admissionMu[domain] +} + +func (c *StateCache) lockAllAdmissions() { + for i := range c.admissionMu { + c.admissionMu[i].Lock() + } +} + +func (c *StateCache) unlockAllAdmissions() { + for i := len(c.admissionMu) - 1; i >= 0; i-- { + c.admissionMu[i].Unlock() + } +} + // NewStateCache creates a new StateCache with the specified byte capacities. // Mode for the byte-budget DomainCaches (Account/Storage) is read once from // STATE_CACHE_MODE (evict|noop, default evict). CodeCache has its own LRU and @@ -159,8 +179,9 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) // PutCodeWithHashIfFresh conditionally fills code from a current snapshot. func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotEnd uint64) { - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() + mu := c.admissionLock(kv.CodeDomain) + mu.RLock() + defer mu.RUnlock() if snapshotEnd < c.appliedEnd[kv.CodeDomain] { return } @@ -222,8 +243,9 @@ func (c *StateCache) putAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { // PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() + mu := c.admissionLock(kv.AccountsDomain) + mu.RLock() + defer mu.RUnlock() if snapshotEnd < c.appliedEnd[kv.AccountsDomain] { return } @@ -246,8 +268,9 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 // PutIfFresh conditionally fills a domain from a current snapshot. func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotEnd uint64) { - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() + mu := c.admissionLock(domain) + mu.RLock() + defer mu.RUnlock() if snapshotEnd < c.appliedEnd[domain] { return } @@ -285,8 +308,9 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { codeHash = crypto.Keccak256(value) } - c.admissionMu.Lock() - defer c.admissionMu.Unlock() + mu := c.admissionLock(domain) + mu.Lock() + defer mu.Unlock() cache := c.caches[domain] if cache == nil { return @@ -334,8 +358,8 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { // Clear removes all mutable entries from all caches. func (c *StateCache) Clear() { - c.admissionMu.Lock() - defer c.admissionMu.Unlock() + c.lockAllAdmissions() + defer c.unlockAllAdmissions() for _, cache := range c.caches { if cache != nil { cache.Clear() @@ -362,8 +386,8 @@ func (c *StateCache) Close() { // and drops stale entries lazily on read. This is the sole cache-invalidation // path on unwind — the executor never touches the cache during forward execution. func (c *StateCache) Unwind(unwindToTxNum uint64) { - c.admissionMu.Lock() - defer c.admissionMu.Unlock() + c.lockAllAdmissions() + defer c.unlockAllAdmissions() for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) From dc3ab9503a00f1983f0bde58321300f8b9284ac3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 13:24:42 +0200 Subject: [PATCH 04/85] db/state: deduplicate inverted index progress lookup --- db/state/inverted_index.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index 6b5fa566c62..5b01ecd0f71 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1229,12 +1229,8 @@ func (ii *InvertedIndex) minTxNumInDB(tx kv.Tx) uint64 { } func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { - lst, _ := kv.LastKey(tx, ii.KeysTable) - if len(lst) > 0 { - lstInDb := binary.BigEndian.Uint64(lst) - return lstInDb - } - return 0 + txNum, _ := ii.progressAndVisibleEndInDB(tx) + return txNum } func (ii *InvertedIndex) progressAndVisibleEndInDB(tx kv.Tx) (uint64, uint64) { From 4a38f9107c71f38288504c612c3729a9cc1539e6 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 14:52:50 +0200 Subject: [PATCH 05/85] execution/cache: derive cached code and its hash from the same copy in Apply --- execution/cache/state_cache.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 52b8c7da1ba..81d2b359ad7 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -305,6 +305,9 @@ func (c *StateCache) Delete(domain kv.Domain, key []byte) { func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { var codeHash []byte if domain == kv.CodeDomain && len(value) > 0 { + // Copy before hashing so the stored bytes and codeHash come from the + // same snapshot of the caller-owned buffer. + value = common.Copy(value) codeHash = crypto.Keccak256(value) } @@ -331,7 +334,7 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { if len(value) == 0 { cache.Delete(key) } else if codeCache, ok := cache.(*CodeCache); ok { - codeCache.PutWithCodeHash(key, common.Copy(value), codeHash, txNum) + codeCache.PutWithCodeHash(key, value, codeHash, txNum) } default: putOrDelete(cache, key, value, txNum) From cb2cdde21f676ff3fdbc620beca8eb45a88b842a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:28:34 +0200 Subject: [PATCH 06/85] =?UTF-8?q?execution/cache:=20drop=20derived=20addr?= =?UTF-8?q?=E2=86=92codeHash=20on=20code=20deletion=20in=20Apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- execution/cache/cache_test.go | 17 +++++++++++++++++ execution/cache/state_cache.go | 1 + 2 files changed, 18 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index eedbc8e5a93..8e9e3e50ca8 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1019,3 +1019,20 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { require.False(t, ok, "round %d: stale fill survived the authoritative delete", round) } } + +func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + addr := makeAddr(1) + var h [32]byte + h[0] = 0xaa + sc.PutAddrCodeHashIfFresh(addr, h, 10, 0) + _, ok := sc.GetAddrCodeHash(addr) + require.True(t, ok) + + sc.Apply(kv.CodeDomain, addr, nil, 20) + _, ok = sc.GetAddrCodeHash(addr) + require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 81d2b359ad7..a9cc7398caf 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -333,6 +333,7 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { case kv.CodeDomain: if len(value) == 0 { cache.Delete(key) + c.deleteAddrCodeHash(key) } else if codeCache, ok := cache.(*CodeCache); ok { codeCache.PutWithCodeHash(key, value, codeHash, txNum) } From fdf367fe8b49ebb7313886b3ebf9d461f2824b7b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:33:58 +0200 Subject: [PATCH 07/85] =?UTF-8?q?execution/cache:=20pin=20account-delete?= =?UTF-8?q?=20=E2=86=92=20stale=20code-fill=20rejection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- execution/cache/cache_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 8e9e3e50ca8..20d6a998d5f 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1036,3 +1036,29 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { _, ok = sc.GetAddrCodeHash(addr) require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } + +func TestStateCache_AccountDeleteBlocksStaleCodeFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + addr := makeAddr(1) + code := makeCode(1) + codeHash := crypto.Keccak256(code) + + sc.Apply(kv.CodeDomain, addr, code, 10) + _, ok := sc.Get(kv.CodeDomain, addr) + require.True(t, ok) + + sc.Apply(kv.AccountsDomain, addr, nil, 20) + _, ok = sc.Get(kv.CodeDomain, addr) + require.False(t, ok, "an account deletion must drop the addr→code binding") + + sc.PutCodeWithHashIfFresh(addr, code, codeHash, 10, 20) + _, ok = sc.Get(kv.CodeDomain, addr) + require.False(t, ok, "a snapshot without the account deletion must not refill its code") + + sc.PutCodeWithHashIfFresh(addr, code, codeHash, 21, 21) + _, ok = sc.Get(kv.CodeDomain, addr) + require.True(t, ok, "a snapshot containing the account deletion is admissible") +} From c557351cab34f5a46861c51d4c84b6bf3727e19a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 15:39:14 +0200 Subject: [PATCH 08/85] execution/cache: drop stale call-site inventory from PutAddrCodeHash comment --- execution/cache/code_cache.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index e1ee086eeb2..302948d42a1 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -378,10 +378,8 @@ func (c *CodeCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return e.hash, true } -// 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. txNum stamps the mapping for unwind invalidation. +// PutAddrCodeHash records the addr → codeHash mapping. txNum stamps the +// mapping for unwind invalidation. func (c *CodeCache) PutAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { c.addrToCodeHash.Add(common.BytesToAddress(addr), addrCodeHashEntry{hash: h, txNum: txNum, epoch: c.coh.Epoch()}) } From 436ae1cf704f563b29e7a99e664f2af545cc5e32 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:03:06 +0200 Subject: [PATCH 09/85] db, execution: collapse DomainProgressAndVisibleEnd to DomainVisibleEnd --- db/kv/kv_interface.go | 6 +++--- db/kv/remotedb/kv_remote.go | 4 ++-- db/kv/temporal/kv_temporal.go | 8 ++++---- db/state/aggregator.go | 7 +++---- db/state/execctx/domain_shared.go | 6 +++--- db/state/inverted_index.go | 7 +++---- db/state/inverted_index_test.go | 22 +++++++++------------- execution/exec/blocks_read_ahead.go | 16 ++++++++-------- execution/exec/blocks_read_ahead_test.go | 24 ++++++++++++------------ 9 files changed, 47 insertions(+), 53 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 6c3811dee58..a24dbffe4e0 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -513,9 +513,9 @@ type TemporalDebugTx interface { HistoryStartFrom(domainName Domain) uint64 DomainProgress(domain Domain) (txNum uint64) - // DomainProgressAndVisibleEnd returns DomainProgress and the exact exclusive - // frontier used for cache admission. ok is false without history. - DomainProgressAndVisibleEnd(domain Domain) (progress, visibleEnd uint64, ok bool) + // DomainVisibleEnd returns the domain's exact exclusive frontier used for + // cache-fill admission. ok is false without history. + DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 // Retire retires frozen history files entirely below their diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index 01db5f42b48..89893039ff2 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -253,8 +253,8 @@ func (tx *tx) Retire(ctx context.Context, cutoffs kv.RetireCutoffs) (int, error) } func (tx *tx) DomainFiles(domain ...kv.Domain) kv.VisibleFiles { panic("not implemented") } func (tx *tx) DomainProgress(domain kv.Domain) uint64 { panic("not implemented") } -func (tx *tx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { - return 0, 0, false +func (tx *tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { + return 0, false } func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index e41cc6bebaf..9ca00a2c606 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -733,11 +733,11 @@ func (tx *Tx) DomainProgress(domain kv.Domain) uint64 { func (tx *RwTx) DomainProgress(domain kv.Domain) uint64 { return tx.aggtx.DomainProgress(domain, tx.RwTx) } -func (tx *Tx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { - return tx.aggtx.DomainProgressAndVisibleEnd(domain, tx.Tx) +func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { + return tx.aggtx.DomainVisibleEnd(domain, tx.Tx) } -func (tx *RwTx) DomainProgressAndVisibleEnd(domain kv.Domain) (uint64, uint64, bool) { - return tx.aggtx.DomainProgressAndVisibleEnd(domain, tx.RwTx) +func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { + return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) } func (tx *Tx) IIProgress(domain kv.InvertedIdx) uint64 { return tx.aggtx.IIProgress(domain, tx.Tx) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 9bbc1544963..c47699e993f 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2500,13 +2500,12 @@ func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { } return at.d[name].ht.iit.Progress(tx) } -func (at *AggregatorRoTx) DomainProgressAndVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, uint64, bool) { +func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { d := at.d[name] if d.d.HistoryDisabled { - return 0, 0, false + return 0, false } - progress, visibleEnd := d.ht.iit.progressAndVisibleEnd(tx) - return progress, visibleEnd, true + return d.ht.iit.visibleEnd(tx), true } func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { return at.searchII(name).Progress(tx) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index d2e0c5b23d5..9ad497df1d3 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1234,7 +1234,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // Snapshot freshness is rechecked while the fill is serialized against // committed cache updates. if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { - if snapshotProgress, snapshotEnd, ok := tx.Debug().DomainProgressAndVisibleEnd(domain); ok { + if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(domain); ok { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 if domain == kv.CodeDomain { if len(v) > 0 { @@ -1242,7 +1242,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k } } else { if len(v) == 0 { - readTxNum = snapshotProgress + readTxNum = snapshotEnd } sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotEnd) } @@ -1429,7 +1429,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // repeat lookups skip the whole resolve() chain. txNum is a // conservative upper bound (>= the resolved account's write txNum), so // the mapping drops on any unwind that reverts that account. - if _, snapshotEnd, ok := tx.Debug().DomainProgressAndVisibleEnd(kv.AccountsDomain); ok { + if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(kv.AccountsDomain); ok { sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, snapshotEnd) } } diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index d08df3f5857..2bc0b70381f 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1253,8 +1253,7 @@ func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { return max(iit.files.EndTxNum(), iit.ii.maxTxNumInDB(tx)) } -func (iit *InvertedIndexRoTx) progressAndVisibleEnd(tx kv.Tx) (uint64, uint64) { - filesEnd := iit.files.EndTxNum() - dbProgress, dbEnd := iit.ii.progressAndVisibleEndInDB(tx) - return max(filesEnd, dbProgress), max(filesEnd, dbEnd) +func (iit *InvertedIndexRoTx) visibleEnd(tx kv.Tx) uint64 { + _, dbEnd := iit.ii.progressAndVisibleEndInDB(tx) + return max(iit.files.EndTxNum(), dbEnd) } diff --git a/db/state/inverted_index_test.go b/db/state/inverted_index_test.go index 3e4a562de8d..cebb462a997 100644 --- a/db/state/inverted_index_test.go +++ b/db/state/inverted_index_test.go @@ -87,7 +87,7 @@ func testDbAndInvertedIndex(tb testing.TB, aggStep uint64, logger log.Logger) (k return db, ii } -func TestInvertedIndexProgressAndVisibleEnd(t *testing.T) { +func TestInvertedIndexVisibleEnd(t *testing.T) { db, ii := testDbAndInvertedIndex(t, 16, log.New()) tx, err := db.BeginRw(t.Context()) require.NoError(t, err) @@ -95,26 +95,22 @@ func TestInvertedIndexProgressAndVisibleEnd(t *testing.T) { iit := ii.beginForTests() defer iit.Close() - progress, visibleEnd := iit.progressAndVisibleEnd(tx) - require.Zero(t, progress) - require.Zero(t, visibleEnd) + require.Zero(t, iit.Progress(tx)) + require.Zero(t, iit.visibleEnd(tx)) var txNum [8]byte require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) - progress, visibleEnd = iit.progressAndVisibleEnd(tx) - require.Zero(t, progress) - require.Equal(t, uint64(1), visibleEnd) + require.Zero(t, iit.Progress(tx)) + require.Equal(t, uint64(1), iit.visibleEnd(tx)) binary.BigEndian.PutUint64(txNum[:], 100) require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) - progress, visibleEnd = iit.progressAndVisibleEnd(tx) - require.Equal(t, uint64(100), progress) - require.Equal(t, uint64(101), visibleEnd) + require.Equal(t, uint64(100), iit.Progress(tx)) + require.Equal(t, uint64(101), iit.visibleEnd(tx)) iit.files = visibleFiles{{endTxNum: 200}} - progress, visibleEnd = iit.progressAndVisibleEnd(tx) - require.Equal(t, uint64(200), progress) - require.Equal(t, uint64(200), visibleEnd) + require.Equal(t, uint64(200), iit.Progress(tx)) + require.Equal(t, uint64(200), iit.visibleEnd(tx)) } func TestInvIndexPruningCorrectness(t *testing.T) { diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index caea00410fb..59669a26e54 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -82,20 +82,20 @@ func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { // (codeHash→bytes) + size-cache layers via PutCodeWithHash, keyed by the // code's own keccak hash so every cached pair is self-consistent. type cachePopulatingGetter struct { - g kv.TemporalGetter - sc *cache.StateCache - stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) - progressBounds func(kv.Domain) (uint64, uint64, bool) + g kv.TemporalGetter + sc *cache.StateCache + stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) + visibleEnd func(kv.Domain) (uint64, bool) } func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePopulatingGetter { - return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), progressBounds: ttx.Debug().DomainProgressAndVisibleEnd} + return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), visibleEnd: ttx.Debug().DomainVisibleEnd} } 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 && cpg.progressBounds != nil { - snapshotProgress, snapshotEnd, ok := cpg.progressBounds(name) + if err == nil && cpg.sc != nil && cpg.visibleEnd != nil { + snapshotEnd, ok := cpg.visibleEnd(name) if !ok { return v, step, nil } @@ -106,7 +106,7 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k } else { txNum := (uint64(step)+1)*cpg.stepSize - 1 if len(v) == 0 { - txNum = snapshotProgress + txNum = snapshotEnd } cpg.sc.PutIfFresh(name, k, v, txNum, snapshotEnd) } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index df031773cfa..3546e0f4a90 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -60,7 +60,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) @@ -80,7 +80,7 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -107,7 +107,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { } sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -116,7 +116,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, progressBounds: zeroProgressBounds} + cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) got, ok = sc.Get(kv.AccountsDomain, key) @@ -129,7 +129,7 @@ func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { sc := newTestStateCache() cpg := &cachePopulatingGetter{ g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, - progressBounds: func(kv.Domain) (uint64, uint64, bool) { return 10_000_000, 10_000_001, true }, + visibleEnd: func(kv.Domain) (uint64, bool) { return 10_000_001, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) @@ -141,7 +141,7 @@ func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { require.False(t, ok, "a negative observed at txNum 10M must not survive an unwind to 5M") } -func TestCachePopulatingGetterNilProgressBoundsNeverFills(t *testing.T) { +func TestCachePopulatingGetterNilVisibleEndNeverFills(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} @@ -158,10 +158,10 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { sc := newTestStateCache() sc.Apply(kv.AccountsDomain, key, nil, 20) cpg := &cachePopulatingGetter{ - g: stubTemporalGetter{v: []byte("pre-delete-record")}, - sc: sc, - stepSize: 1_562_500, - progressBounds: func(kv.Domain) (uint64, uint64, bool) { return 10, 11, true }, + g: stubTemporalGetter{v: []byte("pre-delete-record")}, + sc: sc, + stepSize: 1_562_500, + visibleEnd: func(kv.Domain) (uint64, bool) { return 11, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) @@ -170,4 +170,4 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { require.False(t, ok) } -func zeroProgressBounds(kv.Domain) (uint64, uint64, bool) { return 0, 0, true } +func zeroVisibleEnd(kv.Domain) (uint64, bool) { return 0, true } From e976b63e7ee9722f00a87bdf9190a9420043d033 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 15 Jul 2026 16:20:30 +0200 Subject: [PATCH 10/85] execution/cache: unify admission into a single RWMutex --- execution/cache/cache_test.go | 53 -------------------------------- execution/cache/state_cache.go | 56 +++++++++++----------------------- 2 files changed, 18 insertions(+), 91 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 20d6a998d5f..06b758c2e4f 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -20,7 +20,6 @@ import ( "bytes" "sync" "testing" - "time" "github.com/c2h5oh/datasize" "github.com/stretchr/testify/assert" @@ -883,58 +882,6 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -type blockingPutCache struct { - Cache - putStarted chan struct{} - continuePut chan struct{} -} - -func (c *blockingPutCache) Put(key []byte, value []byte, txNum uint64) { - close(c.putStarted) - <-c.continuePut - c.Cache.Put(key, value, txNum) -} - -func TestStateCache_CrossDomainFillDoesNotWaitForApply(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - - accountCache := &blockingPutCache{ - Cache: sc.caches[kv.AccountsDomain], - putStarted: make(chan struct{}), - continuePut: make(chan struct{}), - } - sc.caches[kv.AccountsDomain] = accountCache - - accountDone := make(chan struct{}) - go func() { - defer close(accountDone) - sc.Apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 1) - }() - <-accountCache.putStarted - - storageKey := makeAddr(2) - storageDone := make(chan struct{}) - go func() { - defer close(storageDone) - sc.PutIfFresh(kv.StorageDomain, storageKey, makeValue(2), 1, 2) - }() - - select { - case <-storageDone: - case <-time.After(time.Second): - close(accountCache.continuePut) - <-accountDone - t.Fatal("a storage fill waited for an unrelated account apply") - } - close(accountCache.continuePut) - <-accountDone - - _, ok := sc.Get(kv.StorageDomain, storageKey) - require.True(t, ok) -} - func TestStateCache_AppliedEndLifecycle(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index a9cc7398caf..9f38e004943 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -55,31 +55,13 @@ const ( // Account and Storage use GenericCache. // Code uses CodeCache (two-level for deduplication). type StateCache struct { - caches [kv.DomainLen]Cache - admissionMu [kv.DomainLen]sync.RWMutex + caches [kv.DomainLen]Cache + // admissionMu makes Apply's frontier advance + cache mutation atomic + // against concurrent read-fills, which recheck freshness under RLock. + admissionMu sync.RWMutex appliedEnd [kv.DomainLen]uint64 } -func (c *StateCache) admissionLock(domain kv.Domain) *sync.RWMutex { - // Account updates also invalidate address-keyed code state. - if domain == kv.CodeDomain { - domain = kv.AccountsDomain - } - return &c.admissionMu[domain] -} - -func (c *StateCache) lockAllAdmissions() { - for i := range c.admissionMu { - c.admissionMu[i].Lock() - } -} - -func (c *StateCache) unlockAllAdmissions() { - for i := len(c.admissionMu) - 1; i >= 0; i-- { - c.admissionMu[i].Unlock() - } -} - // NewStateCache creates a new StateCache with the specified byte capacities. // Mode for the byte-budget DomainCaches (Account/Storage) is read once from // STATE_CACHE_MODE (evict|noop, default evict). CodeCache has its own LRU and @@ -179,9 +161,8 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) // PutCodeWithHashIfFresh conditionally fills code from a current snapshot. func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotEnd uint64) { - mu := c.admissionLock(kv.CodeDomain) - mu.RLock() - defer mu.RUnlock() + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() if snapshotEnd < c.appliedEnd[kv.CodeDomain] { return } @@ -243,9 +224,8 @@ func (c *StateCache) putAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { // PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { - mu := c.admissionLock(kv.AccountsDomain) - mu.RLock() - defer mu.RUnlock() + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() if snapshotEnd < c.appliedEnd[kv.AccountsDomain] { return } @@ -268,9 +248,8 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 // PutIfFresh conditionally fills a domain from a current snapshot. func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotEnd uint64) { - mu := c.admissionLock(domain) - mu.RLock() - defer mu.RUnlock() + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() if snapshotEnd < c.appliedEnd[domain] { return } @@ -311,9 +290,8 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { codeHash = crypto.Keccak256(value) } - mu := c.admissionLock(domain) - mu.Lock() - defer mu.Unlock() + c.admissionMu.Lock() + defer c.admissionMu.Unlock() cache := c.caches[domain] if cache == nil { return @@ -325,6 +303,8 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { putOrDelete(cache, key, value, txNum) c.deleteAddrCodeHash(key) if len(value) == 0 { + // Deleting an account also invalidates its address-keyed code + // state, so the code-domain frontier advances with it. c.noteApplied(kv.CodeDomain, txNum) if codeCache := c.caches[kv.CodeDomain]; codeCache != nil { codeCache.Delete(key) @@ -362,8 +342,8 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { // Clear removes all mutable entries from all caches. func (c *StateCache) Clear() { - c.lockAllAdmissions() - defer c.unlockAllAdmissions() + c.admissionMu.Lock() + defer c.admissionMu.Unlock() for _, cache := range c.caches { if cache != nil { cache.Clear() @@ -390,8 +370,8 @@ func (c *StateCache) Close() { // and drops stale entries lazily on read. This is the sole cache-invalidation // path on unwind — the executor never touches the cache during forward execution. func (c *StateCache) Unwind(unwindToTxNum uint64) { - c.lockAllAdmissions() - defer c.unlockAllAdmissions() + c.admissionMu.Lock() + defer c.admissionMu.Unlock() for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) From 82ee60d537afe136b97861135072128ebc299c90 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 14:43:19 +0200 Subject: [PATCH 11/85] execution/cache: avoid advancing code frontier on account delete --- .../statecache_rpc_integration_test.go | 67 +++++++++++++++++++ execution/cache/cache_test.go | 11 +-- execution/cache/state_cache.go | 3 - 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index f9b840f9676..45b82e63b21 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -23,6 +23,7 @@ import ( "github.com/holiman/uint256" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/state/execctx" @@ -44,6 +45,72 @@ func TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode(t *testing.T) { testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t, kv.CodeDomain) } +func TestTransientAccountDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + contractAddr := make([]byte, 20) + contractAddr[0] = 0xaa + transientAddr := make([]byte, 20) + transientAddr[0] = 0xbb + code := []byte{0xcc, 1, 2, 3} + account := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + Balance: *uint256.NewInt(1), + CodeHash: accounts.InternCodeHash(crypto.Keccak256Hash(code)), + }) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedDomains.Close() + seedDomains.SetTxNum(10) + require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, contractAddr, account, 10, nil)) + require.NoError(t, seedDomains.DomainPut(kv.CodeDomain, seedTx, contractAddr, code, 10, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + budget := 1 * datasize.MB + stateCache := cache.NewStateCache(budget, budget, budget, budget) + t.Cleanup(stateCache.Close) + + deleteTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer deleteTx.Rollback() + deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) + require.NoError(t, err) + defer deleteDomains.Close() + deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetTxNum(20) + require.NoError(t, deleteDomains.DomainDel(kv.AccountsDomain, deleteTx, transientAddr, 20, nil)) + require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) + deleteDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + codeEnd, ok := freshTx.Debug().DomainVisibleEnd(kv.CodeDomain) + require.True(t, ok) + accountsEnd, ok := freshTx.Debug().DomainVisibleEnd(kv.AccountsDomain) + require.True(t, ok) + require.Less(t, codeEnd, accountsEnd) + + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + got, _, err := freshDomains.GetLatest(kv.CodeDomain, freshTx, contractAddr) + require.NoError(t, err) + require.Equal(t, code, got) + + cached, ok := stateCache.Get(kv.CodeDomain, contractAddr) + require.True(t, ok, "an account-only deletion must not block unrelated code fills") + require.Equal(t, code, cached) +} + func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain kv.Domain) { t.Helper() const stepSize = uint64(16) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 06b758c2e4f..a69eed16441 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -984,14 +984,13 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } -func TestStateCache_AccountDeleteBlocksStaleCodeFill(t *testing.T) { +func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) t.Cleanup(sc.Close) addr := makeAddr(1) code := makeCode(1) - codeHash := crypto.Keccak256(code) sc.Apply(kv.CodeDomain, addr, code, 10) _, ok := sc.Get(kv.CodeDomain, addr) @@ -1000,12 +999,4 @@ func TestStateCache_AccountDeleteBlocksStaleCodeFill(t *testing.T) { sc.Apply(kv.AccountsDomain, addr, nil, 20) _, ok = sc.Get(kv.CodeDomain, addr) require.False(t, ok, "an account deletion must drop the addr→code binding") - - sc.PutCodeWithHashIfFresh(addr, code, codeHash, 10, 20) - _, ok = sc.Get(kv.CodeDomain, addr) - require.False(t, ok, "a snapshot without the account deletion must not refill its code") - - sc.PutCodeWithHashIfFresh(addr, code, codeHash, 21, 21) - _, ok = sc.Get(kv.CodeDomain, addr) - require.True(t, ok, "a snapshot containing the account deletion is admissible") } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 9f38e004943..bd116ea3281 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -303,9 +303,6 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { putOrDelete(cache, key, value, txNum) c.deleteAddrCodeHash(key) if len(value) == 0 { - // Deleting an account also invalidates its address-keyed code - // state, so the code-domain frontier advances with it. - c.noteApplied(kv.CodeDomain, txNum) if codeCache := c.caches[kv.CodeDomain]; codeCache != nil { codeCache.Delete(key) } From 1ff29c1f7d31429158ae1ee4a7fa3bc11aea4e3b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 15:11:34 +0200 Subject: [PATCH 12/85] exec, execctx: fix negative cache unwind stamp --- db/state/execctx/domain_shared.go | 5 +- .../statecache_rpc_integration_test.go | 57 +++++++++++++++++++ execution/exec/blocks_read_ahead.go | 5 +- execution/exec/blocks_read_ahead_test.go | 13 +++-- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 9ad497df1d3..6191474f90a 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1242,7 +1242,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k } } else { if len(v) == 0 { - readTxNum = snapshotEnd + readTxNum = 0 + if snapshotEnd > 0 { + readTxNum = snapshotEnd - 1 + } } sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotEnd) } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 45b82e63b21..59efcb0b060 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -111,6 +111,63 @@ func TestTransientAccountDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { require.Equal(t, code, cached) } +func TestSharedDomainsNegativeCacheEntryUsesLastVisibleTxNum(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + presentKey := make([]byte, 20) + presentKey[0] = 0xaa + missingKey := make([]byte, 20) + missingKey[0] = 0xbb + account := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + Balance: *uint256.NewInt(1), + }) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedDomains.Close() + seedDomains.SetTxNum(10) + require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, presentKey, account, 10, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + budget := 1 * datasize.MB + stateCache := cache.NewStateCache(budget, budget, budget, budget) + t.Cleanup(stateCache.Close) + + readTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer readTx.Rollback() + visibleEnd, ok := readTx.Debug().DomainVisibleEnd(kv.AccountsDomain) + require.True(t, ok) + require.NotZero(t, visibleEnd) + + readDomains, err := execctx.NewSharedDomains(ctx, readTx, log.New()) + require.NoError(t, err) + defer readDomains.Close() + readDomains.SetStateCacheForTest(stateCache) + got, _, err := readDomains.GetLatest(kv.AccountsDomain, readTx, missingKey) + require.NoError(t, err) + require.Empty(t, got) + + cached, ok := stateCache.Get(kv.AccountsDomain, missingKey) + require.True(t, ok) + require.Empty(t, cached) + + stateCache.Unwind(visibleEnd) + _, ok = stateCache.Get(kv.AccountsDomain, missingKey) + require.True(t, ok, "a negative observed before the unwind floor must remain cached") + + stateCache.Unwind(visibleEnd - 1) + _, ok = stateCache.Get(kv.AccountsDomain, missingKey) + require.False(t, ok, "a negative observed at the unwind floor must be invalidated") +} + func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain kv.Domain) { t.Helper() const stepSize = uint64(16) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 59669a26e54..d5ba0f00102 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -106,7 +106,10 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k } else { txNum := (uint64(step)+1)*cpg.stepSize - 1 if len(v) == 0 { - txNum = snapshotEnd + txNum = 0 + if snapshotEnd > 0 { + txNum = snapshotEnd - 1 + } } cpg.sc.PutIfFresh(name, k, v, txNum, snapshotEnd) } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 3546e0f4a90..be160f09359 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -124,21 +124,26 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { require.Empty(t, got) } -func TestCachePopulatingGetterNegativeDropsOnUnwind(t *testing.T) { +func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { + const visibleEnd = uint64(10_000_001) key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{ g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, - visibleEnd: func(kv.Domain) (uint64, bool) { return 10_000_001, true }, + visibleEnd: func(kv.Domain) (uint64, bool) { return visibleEnd, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) _, ok := sc.Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Unwind(5_000_000) + sc.Unwind(visibleEnd) _, ok = sc.Get(kv.AccountsDomain, key) - require.False(t, ok, "a negative observed at txNum 10M must not survive an unwind to 5M") + require.True(t, ok, "a negative observed before the unwind floor must remain cached") + + sc.Unwind(visibleEnd - 1) + _, ok = sc.Get(kv.AccountsDomain, key) + require.False(t, ok, "a negative observed at the unwind floor must be invalidated") } func TestCachePopulatingGetterNilVisibleEndNeverFills(t *testing.T) { From fd1c8d1338e97f850c173fa642d9eb3cf84f32ad Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 15:54:59 +0200 Subject: [PATCH 13/85] db, execution: align cache frontier terminology --- db/kv/kv_interface.go | 2 +- db/state/execctx/domain_shared.go | 7 +++--- .../execctx/statecache_readfill_bench_test.go | 10 ++++----- db/state/execctx/statecache_readfill_test.go | 22 +++++++++++-------- execution/exec/blocks_read_ahead_test.go | 14 ++++++------ 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index d5654f1a18e..15200b2c951 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -519,7 +519,7 @@ type TemporalDebugTx interface { DomainProgress(domain Domain) (txNum uint64) // DomainVisibleEnd returns the domain's exact exclusive frontier used for - // cache-fill admission. ok is false without history. + // cache-fill admission. ok is false when an exact frontier is unavailable. DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 669eb13f451..2efb00550a7 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1138,13 +1138,12 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k MeteredGetLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.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. + // stateCache holds committed values shared across domain readers. if sd.stateCache != nil { v, cTxNum, ok := sd.stateCache.GetWithTxNum(domain, k) // The cache stamps txNums — divide to get the step the entry reflects. - // An empty value is stamped with the domain's progress at fill time, so - // its cStep is progress-derived, not the step of any deletion. + // A negative uses the last txNum included by its snapshot frontier, not + // the step of a deletion. cStep := kv.Step(cTxNum / sd.StepSize()) if ok && !servableUnderBound(cStep, maxStep) { ok = false diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index a5868cf2933..8f8caff625d 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -49,22 +49,22 @@ func benchSeedDb(b *testing.B) kv.TemporalRwDB { return db } -// BenchmarkDomainProgress isolates the negative-stamp source: one +// BenchmarkDomainVisibleEnd isolates the fill-admission frontier lookup: one // files.EndTxNum read plus an MDBX LastKey on the domain's keys table. -func BenchmarkDomainProgress(b *testing.B) { +func BenchmarkDomainVisibleEnd(b *testing.B) { db := benchSeedDb(b) roTx, err := db.BeginTemporalRo(b.Context()) require.NoError(b, err) defer roTx.Rollback() b.ResetTimer() for i := 0; i < b.N; i++ { - _ = roTx.Debug().DomainProgress(kv.AccountsDomain) + _, _ = roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) } } // benchColdNegativeReads drives the full cold-negative SD read: the whole -// miss stack, plus — when a cache is wired — the progress stamp and the -// if-absent fill. +// miss stack, plus — when a cache is wired — the exact-frontier lookup and +// freshness-checked fill. func benchColdNegativeReads(b *testing.B, withCache bool) { db := benchSeedDb(b) ctx := b.Context() diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 95d63fbafd7..d2bbc78cd36 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -208,10 +208,9 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { require.Equal(t, v3, got, "read-fill must not clobber the live entry") } -// Negative results (missing account) must be stamped with the domain's -// progress at observation time, not a synthetic step-0 bound that survives -// every unwind. -func TestReadFill_NegativeStampedWithProgress(t *testing.T) { +// A negative reflects transactions below the snapshot's exclusive frontier, +// so its unwind stamp is the last included txNum. +func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -237,6 +236,9 @@ func TestReadFill_NegativeStampedWithProgress(t *testing.T) { roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer roTx.Rollback() + visibleEnd, ok := roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) + require.True(t, ok) + require.NotZero(t, visibleEnd) sd2, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd2.Close() @@ -247,12 +249,14 @@ func TestReadFill_NegativeStampedWithProgress(t *testing.T) { v, _, err := sd2.GetLatest(kv.AccountsDomain, roTx, missing) require.NoError(t, err) require.Empty(t, v) - _, ok := sc.Get(kv.AccountsDomain, missing) + _, ok = sc.Get(kv.AccountsDomain, missing) require.True(t, ok, "the negative result must be cached") - // The domain's progress is 100 (the committed write), so any unwind at or - // below it must drop the negative instead of letting it outlive the fact. - sc.Unwind(50) + sc.Unwind(visibleEnd) + _, ok = sc.Get(kv.AccountsDomain, missing) + require.True(t, ok, "an unwind starting after the snapshot must preserve the negative") + + sc.Unwind(visibleEnd - 1) _, ok = sc.Get(kv.AccountsDomain, missing) - require.False(t, ok, "a negative observed at progress 100 must not survive an unwind to 50") + require.False(t, ok, "an unwind of the snapshot's last included txNum must invalidate the negative") } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index be160f09359..078abf13105 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -60,7 +60,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) @@ -80,7 +80,7 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -107,7 +107,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { } sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} + cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -116,7 +116,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: zeroVisibleEnd} + cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) got, ok = sc.Get(kv.AccountsDomain, key) @@ -155,7 +155,7 @@ func TestCachePopulatingGetterNilVisibleEndNeverFills(t *testing.T) { require.NoError(t, err) }) _, ok := sc.Get(kv.AccountsDomain, key) - require.False(t, ok, "no progress oracle — nothing may be cached") + require.False(t, ok, "no exact frontier — nothing may be cached") } func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { @@ -175,4 +175,4 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { require.False(t, ok) } -func zeroVisibleEnd(kv.Domain) (uint64, bool) { return 0, true } +func emptyVisibleEnd(kv.Domain) (uint64, bool) { return 0, true } From 87ddb96707d85145e3b21281ffc930a3614e5fd7 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 16:12:03 +0200 Subject: [PATCH 14/85] db/kv/temporal: cache domain visible ends --- db/kv/temporal/kv_temporal.go | 13 ++++++++++++- db/state/execctx/statecache_readfill_bench_test.go | 5 +++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 9ca00a2c606..62d90542870 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -268,6 +268,7 @@ type tx struct { type Tx struct { kv.Tx tx + visibleEnds [kv.DomainLen]domainVisibleEnd } type RwTx struct { @@ -275,6 +276,12 @@ type RwTx struct { tx } +type domainVisibleEnd struct { + once sync.Once + end uint64 + ok bool +} + func (tx *tx) ForceReopenUnderlyingFilesTx() { if tx.blocktx != nil { tx.blocktx.Close() @@ -734,7 +741,11 @@ func (tx *RwTx) DomainProgress(domain kv.Domain) uint64 { return tx.aggtx.DomainProgress(domain, tx.RwTx) } func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return tx.aggtx.DomainVisibleEnd(domain, tx.Tx) + visibleEnd := &tx.visibleEnds[domain] + visibleEnd.once.Do(func() { + visibleEnd.end, visibleEnd.ok = tx.aggtx.DomainVisibleEnd(domain, tx.Tx) + }) + return visibleEnd.end, visibleEnd.ok } func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 8f8caff625d..5fe4dadeb66 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -49,13 +49,14 @@ func benchSeedDb(b *testing.B) kv.TemporalRwDB { return db } -// BenchmarkDomainVisibleEnd isolates the fill-admission frontier lookup: one -// files.EndTxNum read plus an MDBX LastKey on the domain's keys table. +// BenchmarkDomainVisibleEnd isolates the transaction-local cached frontier +// lookup used by repeated cache fills. func BenchmarkDomainVisibleEnd(b *testing.B) { db := benchSeedDb(b) roTx, err := db.BeginTemporalRo(b.Context()) require.NoError(b, err) defer roTx.Rollback() + _, _ = roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) b.ResetTimer() for i := 0; i < b.N; i++ { _, _ = roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) From e1eb581c7c8f9bbd924b594d140b68389a040e37 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Thu, 16 Jul 2026 16:43:22 +0200 Subject: [PATCH 15/85] db/kv/temporal: compact visible end cache --- db/kv/temporal/kv_temporal.go | 44 +++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 62d90542870..c97465d94fb 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "sync" + "sync/atomic" "time" "github.com/erigontech/erigon/db/datadir" @@ -268,7 +269,7 @@ type tx struct { type Tx struct { kv.Tx tx - visibleEnds [kv.DomainLen]domainVisibleEnd + visibleEnds domainVisibleEnds } type RwTx struct { @@ -276,10 +277,37 @@ type RwTx struct { tx } -type domainVisibleEnd struct { - once sync.Once - end uint64 - ok bool +type domainVisibleEnds struct { + ends [kv.DomainLen]uint64 + mu sync.Mutex + state atomic.Uint32 +} + +func (v *domainVisibleEnds) get(tx *Tx, domain kv.Domain) (uint64, bool) { + bit := uint32(1) << uint32(domain) + state := v.state.Load() + if state&bit != 0 { + return v.ends[domain], state&(bit< Date: Fri, 17 Jul 2026 10:02:52 +0200 Subject: [PATCH 16/85] execution/cache, exec, execctx: centralize snapshot fills --- db/state/execctx/domain_shared.go | 14 +-------- execution/cache/cache_test.go | 8 ++--- execution/cache/state_cache.go | 38 ++++++++++++++++-------- execution/exec/blocks_read_ahead.go | 21 ++----------- execution/exec/blocks_read_ahead_test.go | 3 ++ 5 files changed, 36 insertions(+), 48 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 2efb00550a7..a0173942918 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1224,19 +1224,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(domain); ok { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 - if domain == kv.CodeDomain { - if len(v) > 0 { - sd.stateCache.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), readTxNum, snapshotEnd) - } - } else { - if len(v) == 0 { - readTxNum = 0 - if snapshotEnd > 0 { - readTxNum = snapshotEnd - 1 - } - } - sd.stateCache.PutIfFresh(domain, k, v, readTxNum, snapshotEnd) - } + sd.stateCache.FillIfFresh(domain, k, v, readTxNum, snapshotEnd) } } // Only cache a branch when the read's txN is known: a txN=0 entry would diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 7eae02c3322..a435269fb91 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -921,7 +921,7 @@ func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { _, ok := sc.Get(kv.AccountsDomain, key) require.False(t, ok, "an authoritative deletion must physically remove the entry") - sc.PutIfFresh(kv.AccountsDomain, key, stale, 10, 11) + sc.FillIfFresh(kv.AccountsDomain, key, stale, 10, 11) _, ok = sc.Get(kv.AccountsDomain, key) require.False(t, ok, "a snapshot older than the deletion must not fill afterward") } @@ -935,12 +935,12 @@ func TestStateCache_FileEndSnapshotCannotFillAtAppliedTx(t *testing.T) { stale := makeValue(1) sc.Apply(kv.AccountsDomain, key, nil, 100) - sc.PutIfFresh(kv.AccountsDomain, key, stale, 99, 100) + sc.FillIfFresh(kv.AccountsDomain, key, stale, 99, 100) _, ok := sc.Get(kv.AccountsDomain, key) require.False(t, ok, "a [0,100) snapshot does not contain the applied tx 100") fresh := makeValue(2) - sc.PutIfFresh(kv.AccountsDomain, key, fresh, 100, 101) + sc.FillIfFresh(kv.AccountsDomain, key, fresh, 100, 101) got, ok := sc.Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, fresh, got) @@ -967,7 +967,7 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { }() go func() { defer wg.Done() - sc.PutIfFresh(kv.AccountsDomain, key, value, appliedTxNum, snapshotEnd) + sc.FillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, snapshotEnd) }() wg.Wait() diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index bd116ea3281..cb84ff779ea 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -159,16 +159,6 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) c.putCodeWithHash(addr, code, codeHash, txNum, true) } -// PutCodeWithHashIfFresh conditionally fills code from a current snapshot. -func (c *StateCache) PutCodeWithHashIfFresh(addr, code, codeHash []byte, txNum, snapshotEnd uint64) { - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if snapshotEnd < c.appliedEnd[kv.CodeDomain] { - return - } - c.putCodeWithHash(addr, code, codeHash, txNum, false) -} - func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64, overwrite bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -246,14 +236,36 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 c.put(domain, key, value, txNum, true) } -// PutIfFresh conditionally fills a domain from a current snapshot. -func (c *StateCache) PutIfFresh(domain kv.Domain, key []byte, value []byte, txNum, snapshotEnd uint64) { +// FillIfFresh conditionally inserts a snapshot read without replacing an +// authoritative entry. Negative values use the snapshot's last visible txNum. +func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, snapshotEnd uint64) { + cache := c.caches[domain] + if cache == nil || (domain == kv.CodeDomain && len(value) == 0) { + return + } + + var codeHash []byte + if domain == kv.CodeDomain { + codeHash = crypto.Keccak256(value) + } + c.admissionMu.RLock() defer c.admissionMu.RUnlock() if snapshotEnd < c.appliedEnd[domain] { return } - c.put(domain, key, value, txNum, false) + + if domain == kv.CodeDomain { + c.putCodeWithHash(key, value, codeHash, readTxNum, false) + return + } + if len(value) == 0 { + readTxNum = 0 + if snapshotEnd > 0 { + readTxNum = snapshotEnd - 1 + } + } + c.put(domain, key, value, readTxNum, false) } func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64, overwrite bool) { diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index af4d032659c..18b893702f6 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -10,7 +10,6 @@ import ( "golang.org/x/sync/errgroup" "github.com/erigontech/erigon/common" - "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/length" "github.com/erigontech/erigon/common/log/v3" @@ -78,9 +77,7 @@ func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { // that SharedDomains.GetLatest consults — eliminating the file-accessor // stack cost on the EVM's first touch of any prefetched address. // -// For the CodeDomain the wrapper also populates the codeHashToCode -// (codeHash→bytes) + size-cache layers via PutCodeWithHashIfFresh, keyed by -// the code's own keccak hash so every cached pair is self-consistent. +// Code reads also populate the content-addressed and size-cache layers. type cachePopulatingGetter struct { g kv.TemporalGetter sc *cache.StateCache @@ -99,20 +96,8 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k if !ok { return v, step, nil } - if name == kv.CodeDomain { - if len(v) > 0 { - cpg.sc.PutCodeWithHashIfFresh(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1, snapshotEnd) - } - } else { - txNum := (uint64(step)+1)*cpg.stepSize - 1 - if len(v) == 0 { - txNum = 0 - if snapshotEnd > 0 { - txNum = snapshotEnd - 1 - } - } - cpg.sc.PutIfFresh(name, k, v, txNum, snapshotEnd) - } + readTxNum := (uint64(step)+1)*cpg.stepSize - 1 + cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) } return v, step, err } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 078abf13105..adfaae0d2a4 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -113,6 +113,9 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { got, ok := sc.Get(kv.CodeDomain, key) require.True(t, ok) require.Equal(t, code, got) + got, ok = sc.GetCodeByHash(crypto.Keccak256(code)) + require.True(t, ok) + require.Equal(t, code, got) // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() From 576b7879ae7c0388de2e69e69cbd9e365a3a39c8 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 10:24:21 +0200 Subject: [PATCH 17/85] execution/cache: remove unreachable commitment guard --- execution/cache/state_cache.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index cb84ff779ea..187a1793987 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -17,7 +17,6 @@ package cache import ( - "bytes" "math" "strings" "sync" @@ -29,7 +28,6 @@ import ( "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 ( @@ -273,9 +271,6 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 if cache == nil { return } - if domain == kv.CommitmentDomain && bytes.Equal(key, commitmentdb.KeyCommitmentState) { - return - } if overwrite { cache.Put(key, common.Copy(value), txNum) } else { From 7b4efea1a6240baf4d545c7c242f6a2a9cfd3a3e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 10:33:59 +0200 Subject: [PATCH 18/85] db/state: simplify inverted index progress lookup --- db/state/inverted_index.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index efe8d85a19a..f5eac56baa8 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1233,21 +1233,17 @@ func (ii *InvertedIndex) minTxNumInDB(tx kv.Tx) uint64 { return 0 } -func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { - txNum, _ := ii.progressAndVisibleEndInDB(tx) - return txNum -} - -func (ii *InvertedIndex) progressAndVisibleEndInDB(tx kv.Tx) (uint64, uint64) { +func (ii *InvertedIndex) lastTxNumInDB(tx kv.Tx) (uint64, bool) { lst, _ := kv.LastKey(tx, ii.KeysTable) if len(lst) == 0 { - return 0, 0 - } - txNum := binary.BigEndian.Uint64(lst) - if txNum == math.MaxUint64 { - return txNum, txNum + return 0, false } - return txNum, txNum + 1 + return binary.BigEndian.Uint64(lst), true +} + +func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { + txNum, _ := ii.lastTxNumInDB(tx) + return txNum } func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { @@ -1255,6 +1251,9 @@ func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { } func (iit *InvertedIndexRoTx) visibleEnd(tx kv.Tx) uint64 { - _, dbEnd := iit.ii.progressAndVisibleEndInDB(tx) + dbEnd, ok := iit.ii.lastTxNumInDB(tx) + if ok && dbEnd < math.MaxUint64 { + dbEnd++ + } return max(iit.files.EndTxNum(), dbEnd) } From 60eecc1ff0eb05831ed2f68ce68743466e2e9bbc Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 10:56:30 +0200 Subject: [PATCH 19/85] execution/exec: remove redundant read-ahead temporary --- execution/exec/blocks_read_ahead.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 18b893702f6..de13342e307 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -285,10 +285,8 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t return nil } var getter kv.TemporalGetter = ttx - var cpg *cachePopulatingGetter if bra.stateCache != nil { - cpg = newCachePopulatingGetter(ttx, bra.stateCache) - getter = cpg + getter = newCachePopulatingGetter(ttx, bra.stateCache) } stateReader := state.NewReaderV3(getter) From 748f6f8bceee67ba3f8a79fe4dd3977e403054f1 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:02:45 +0200 Subject: [PATCH 20/85] execution/exec: simplify read-ahead frontier check --- execution/exec/blocks_read_ahead.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index de13342e307..09323c0a850 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -92,12 +92,10 @@ func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePop 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 && cpg.visibleEnd != nil { - snapshotEnd, ok := cpg.visibleEnd(name) - if !ok { - return v, step, nil + if snapshotEnd, ok := cpg.visibleEnd(name); ok { + readTxNum := (uint64(step)+1)*cpg.stepSize - 1 + cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) } - readTxNum := (uint64(step)+1)*cpg.stepSize - 1 - cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) } return v, step, err } From 14d5f7a4820c44f632e2b034917395b7bd82d3cc Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:17:41 +0200 Subject: [PATCH 21/85] execution/cache: inline address code hash fill --- execution/cache/state_cache.go | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 187a1793987..8ae8fca0a0b 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -202,22 +202,18 @@ func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return cc.GetAddrCodeHash(addr) } -func (c *StateCache) putAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { +// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. +func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - cc.PutAddrCodeHash(addr, h, txNum) -} - -// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. -func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { c.admissionMu.RLock() defer c.admissionMu.RUnlock() if snapshotEnd < c.appliedEnd[kv.AccountsDomain] { return } - c.putAddrCodeHash(addr, h, txNum) + cc.PutAddrCodeHash(addr, h, txNum) } func (c *StateCache) deleteAddrCodeHash(addr []byte) { From d662dcd53aca497360d7687f09bd043ada2b7223 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:22:21 +0200 Subject: [PATCH 22/85] execution/cache: reuse resolved cache for fills --- execution/cache/state_cache.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 8ae8fca0a0b..ea495068293 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -259,7 +259,7 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea readTxNum = snapshotEnd - 1 } } - c.put(domain, key, value, readTxNum, false) + cache.PutIfAbsent(key, common.Copy(value), readTxNum) } func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64, overwrite bool) { From 9fba079bc7088c354d1ece41018320ec7b6816b1 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:28:39 +0200 Subject: [PATCH 23/85] execution/cache: make code fill modes explicit --- execution/cache/state_cache.go | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index ea495068293..dde7c33d320 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -154,19 +154,11 @@ func (c *StateCache) GetCodeByHash(codeHash []byte) ([]byte, bool) { // codeHash-keyed codeHashToCode 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, codeHash []byte, txNum uint64) { - c.putCodeWithHash(addr, code, codeHash, txNum, true) -} - -func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64, overwrite bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - if overwrite { - cc.PutWithCodeHash(addr, common.Copy(code), codeHash, txNum) - } else { - cc.PutWithCodeHashIfAbsent(addr, common.Copy(code), codeHash, txNum) - } + cc.PutWithCodeHash(addr, common.Copy(code), codeHash, txNum) } // GetCodeSizeByHash returns the size of code by its Ethereum codeHash @@ -250,7 +242,9 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea } if domain == kv.CodeDomain { - c.putCodeWithHash(key, value, codeHash, readTxNum, false) + if codeCache, ok := cache.(*CodeCache); ok { + codeCache.PutWithCodeHashIfAbsent(key, common.Copy(value), codeHash, readTxNum) + } return } if len(value) == 0 { From 04e35ecbb09bb3a16418c74a7ae16838b05dd6b3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:35:45 +0200 Subject: [PATCH 24/85] execution/exec: centralize read-ahead getter selection --- execution/exec/blocks_read_ahead.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 09323c0a850..e7773d15504 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -89,6 +89,13 @@ func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePop return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), visibleEnd: ttx.Debug().DomainVisibleEnd} } +func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { + if sc == nil { + return ttx + } + return newCachePopulatingGetter(ttx, sc) +} + 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 && cpg.visibleEnd != nil { @@ -212,11 +219,7 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t if !ok { return nil } - var getter kv.TemporalGetter = ttx - if bra.stateCache != nil { - getter = newCachePopulatingGetter(ttx, bra.stateCache) - } - stateReader := state.NewReaderV3(getter) + stateReader := state.NewReaderV3(readAheadGetter(ttx, bra.stateCache)) for idx := workerStart; idx < workerEnd; idx++ { select { @@ -282,11 +285,7 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t if !ok { return nil } - var getter kv.TemporalGetter = ttx - if bra.stateCache != nil { - getter = newCachePopulatingGetter(ttx, bra.stateCache) - } - stateReader := state.NewReaderV3(getter) + stateReader := state.NewReaderV3(readAheadGetter(ttx, bra.stateCache)) for txIdx := workerStart; txIdx < workerEnd; txIdx++ { select { From 70ffa64cf12a192d61e2b7d0cacb37c777e6cac9 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:41:26 +0200 Subject: [PATCH 25/85] execution/exec: inline read-ahead getter construction --- execution/exec/blocks_read_ahead.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index e7773d15504..cccb53657e2 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -85,15 +85,12 @@ type cachePopulatingGetter struct { visibleEnd func(kv.Domain) (uint64, bool) } -func newCachePopulatingGetter(ttx kv.TemporalTx, sc *cache.StateCache) *cachePopulatingGetter { - return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: ttx.Debug().StepSize(), visibleEnd: ttx.Debug().DomainVisibleEnd} -} - func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx } - return newCachePopulatingGetter(ttx, sc) + debug := ttx.Debug() + return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: debug.StepSize(), visibleEnd: debug.DomainVisibleEnd} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { From ba58d75775f9ffc2d94ea70cf4f3f8494bbbd535 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:48:34 +0200 Subject: [PATCH 26/85] execution/cache: reset applied frontiers directly --- execution/cache/state_cache.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index dde7c33d320..bbe29e6df1f 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -343,9 +343,7 @@ func (c *StateCache) Clear() { cache.Clear() } } - for i := range c.appliedEnd { - c.appliedEnd[i] = 0 - } + c.appliedEnd = [kv.DomainLen]uint64{} } // Close releases every sub-cache's slot in the shared memory envelope so later From 33af7de05555f9df5769060758fd94376652a481 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 11:55:56 +0200 Subject: [PATCH 27/85] execution/cache: clamp applied frontiers on unwind --- execution/cache/state_cache.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index bbe29e6df1f..cb00a6a04af 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -370,9 +370,7 @@ func (c *StateCache) Unwind(unwindToTxNum uint64) { } } for i := range c.appliedEnd { - if c.appliedEnd[i] > unwindToTxNum { - c.appliedEnd[i] = unwindToTxNum - } + c.appliedEnd[i] = min(c.appliedEnd[i], unwindToTxNum) } } From 28e36cfa3756c34f9179f81d7ecaab0cf84c5a2a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 12:04:22 +0200 Subject: [PATCH 28/85] execution/exec: embed read-ahead temporal getter --- execution/exec/blocks_read_ahead.go | 14 +++----------- execution/exec/blocks_read_ahead_test.go | 22 +++++++++++----------- 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index cccb53657e2..58e361d890b 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -79,7 +79,7 @@ func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { // // Code reads also populate the content-addressed and size-cache layers. type cachePopulatingGetter struct { - g kv.TemporalGetter + kv.TemporalGetter sc *cache.StateCache stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) visibleEnd func(kv.Domain) (uint64, bool) @@ -90,11 +90,11 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter return ttx } debug := ttx.Debug() - return &cachePopulatingGetter{g: ttx, sc: sc, stepSize: debug.StepSize(), visibleEnd: debug.DomainVisibleEnd} + return &cachePopulatingGetter{TemporalGetter: ttx, sc: sc, stepSize: debug.StepSize(), visibleEnd: debug.DomainVisibleEnd} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { - v, step, err := cpg.g.GetLatest(name, k) + v, step, err := cpg.TemporalGetter.GetLatest(name, k) if err == nil && cpg.sc != nil && cpg.visibleEnd != nil { if snapshotEnd, ok := cpg.visibleEnd(name); ok { readTxNum := (uint64(step)+1)*cpg.stepSize - 1 @@ -104,14 +104,6 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k 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...) -} - func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, header *types.Header, body *types.Body) { blockHash := header.Hash() bra.headers.Add(blockHash, header) diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index adfaae0d2a4..66fabed3831 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -60,7 +60,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) @@ -80,7 +80,7 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) @@ -98,7 +98,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -107,7 +107,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { } sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -119,7 +119,7 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) got, ok = sc.Get(kv.AccountsDomain, key) @@ -132,7 +132,7 @@ func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{ - g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, + TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: func(kv.Domain) (uint64, bool) { return visibleEnd, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) @@ -152,7 +152,7 @@ func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { func TestCachePopulatingGetterNilVisibleEndNeverFills(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() - cpg := &cachePopulatingGetter{g: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} require.NotPanics(t, func() { _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) @@ -166,10 +166,10 @@ func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { sc := newTestStateCache() sc.Apply(kv.AccountsDomain, key, nil, 20) cpg := &cachePopulatingGetter{ - g: stubTemporalGetter{v: []byte("pre-delete-record")}, - sc: sc, - stepSize: 1_562_500, - visibleEnd: func(kv.Domain) (uint64, bool) { return 11, true }, + TemporalGetter: stubTemporalGetter{v: []byte("pre-delete-record")}, + sc: sc, + stepSize: 1_562_500, + visibleEnd: func(kv.Domain) (uint64, bool) { return 11, true }, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) From 0f1b26e8cc1e4dca586673a9efbc3fd709957144 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 12:11:30 +0200 Subject: [PATCH 29/85] execution/exec: drop redundant warmup cache check --- execution/exec/blocks_read_ahead.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 58e361d890b..2bfaffcab5d 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -95,7 +95,7 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { v, step, err := cpg.TemporalGetter.GetLatest(name, k) - if err == nil && cpg.sc != nil && cpg.visibleEnd != nil { + if err == nil && cpg.visibleEnd != nil { if snapshotEnd, ok := cpg.visibleEnd(name); ok { readTxNum := (uint64(step)+1)*cpg.stepSize - 1 cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) From 57fbc1a70dfca7875190d9887357b35e11497027 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 12:19:57 +0200 Subject: [PATCH 30/85] execution/cache: reuse domain delete path --- execution/cache/state_cache.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index cb00a6a04af..7b44039fdaa 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -300,9 +300,7 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { putOrDelete(cache, key, value, txNum) c.deleteAddrCodeHash(key) if len(value) == 0 { - if codeCache := c.caches[kv.CodeDomain]; codeCache != nil { - codeCache.Delete(key) - } + c.Delete(kv.CodeDomain, key) } case kv.CodeDomain: if len(value) == 0 { From ca3f6eac8e5b53df2f15f67bbb515f76bdcfdd1c Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 12:35:26 +0200 Subject: [PATCH 31/85] execution/exec: remove impossible frontier state --- execution/exec/blocks_read_ahead.go | 2 +- execution/exec/blocks_read_ahead_test.go | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 2bfaffcab5d..4e645b28ca1 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -95,7 +95,7 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { v, step, err := cpg.TemporalGetter.GetLatest(name, k) - if err == nil && cpg.visibleEnd != nil { + if err == nil { if snapshotEnd, ok := cpg.visibleEnd(name); ok { readTxNum := (uint64(step)+1)*cpg.stepSize - 1 cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 66fabed3831..22efb04c08d 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -149,14 +149,15 @@ func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { require.False(t, ok, "a negative observed at the unwind floor must be invalidated") } -func TestCachePopulatingGetterNilVisibleEndNeverFills(t *testing.T) { +func TestCachePopulatingGetterUnavailableVisibleEndNeverFills(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500} - require.NotPanics(t, func() { - _, _, err := cpg.GetLatest(kv.AccountsDomain, key) - require.NoError(t, err) - }) + cpg := &cachePopulatingGetter{ + TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, + visibleEnd: func(kv.Domain) (uint64, bool) { return 0, false }, + } + _, _, err := cpg.GetLatest(kv.AccountsDomain, key) + require.NoError(t, err) _, ok := sc.Get(kv.AccountsDomain, key) require.False(t, ok, "no exact frontier — nothing may be cached") } From bb2529388b6be48be94d4da89e123e2f6d71835a Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 13:58:52 +0200 Subject: [PATCH 32/85] =?UTF-8?q?db/state/execctx:=20seed=20addr=E2=86=92c?= =?UTF-8?q?odeHash=20mapping=20only=20from=20snapshot-sourced=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PutAddrCodeHashIfFresh admission gate compares the tx snapshot's frontier against the applied end, which vouches only for data read from that snapshot. codeHashForAddr's resolve() could also serve the account record from the shared accounts cache, which lags a just-committed flush until the apply loop reaches the key: an apply landing between the cache read and the fill would admit a mapping derived from the pre-apply record at the snapshotEnd == appliedEnd boundary, leaving a stale addr→codeHash binding that the GetCodeSize/GetCode fast paths then serve with no authoritative fall-through. resolve() now reports the record's source, and only snapshot-sourced records (including the genuine-miss zero-hash sentinel) seed the mapping. A failed snapshot read no longer seeds the sentinel either. --- db/state/execctx/codehash_routing_test.go | 105 ++++++++++++++++++++++ db/state/execctx/domain_shared.go | 32 ++++--- 2 files changed, 124 insertions(+), 13 deletions(-) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index ad591db06f6..ae24780fa36 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -68,3 +68,108 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { require.NotEqual(t, stale[:], got) }) } + +// The addr→codeHash admission gate vouches for the tx snapshot's frontier, but +// resolve() may serve the account record from the shared accounts cache, which +// lags a just-committed flush until the apply loop reaches the key. A +// cache-sourced record must therefore never seed the mapping — an apply +// interleaved between the read and the fill would leave a mapping derived from +// the pre-apply record. +func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { + if testing.Short() { + t.Skip() + } + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + sc := cache.NewDefaultStateCache() + t.Cleanup(sc.Close) + + var addr common.Address + addr[0] = 0xab + var codeHash common.Hash + for i := range codeHash { + codeHash[i] = 0x11 + } + acc := accounts.Account{Nonce: 7, CodeHash: accounts.InternCodeHash(codeHash)} + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedSD, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedSD.Close() + seedSD.SetStateCacheForTest(sc) + seedSD.SetTxNum(10) + require.NoError(t, seedSD.DomainPut(kv.AccountsDomain, seedTx, addr[:], accounts.SerialiseV3(&acc), 10, nil)) + require.NoError(t, seedSD.Commit(ctx, seedTx)) + seedSD.Close() + + _, ok := sc.Get(kv.AccountsDomain, addr[:]) + require.True(t, ok, "the committed record must be served by the accounts cache") + _, ok = sc.GetAddrCodeHash(addr[:]) + require.False(t, ok, "the flush apply must leave the derived mapping empty") + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) + require.NoError(t, err) + defer sd.Close() + sd.SetStateCacheForTest(sc) + + got := sd.CodeHashForAddr(roTx, addr[:], 20) + require.Equal(t, codeHash[:], got) + _, ok = sc.GetAddrCodeHash(addr[:]) + require.False(t, ok, "a cache-sourced account record must not seed the addr→codeHash mapping") +} + +// A record read from the tx snapshot (accounts-cache miss) is exactly what the +// admission gate vouches for, so it still seeds the mapping. +func TestCodeHashForAddr_SnapshotSourcedRecordSeedsMapping(t *testing.T) { + if testing.Short() { + t.Skip() + } + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + + var addr common.Address + addr[0] = 0xcd + var codeHash common.Hash + for i := range codeHash { + codeHash[i] = 0x22 + } + acc := accounts.Account{Nonce: 3, CodeHash: accounts.InternCodeHash(codeHash)} + + // Seed without a state cache so the record lands in the DB only. + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedSD, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedSD.Close() + seedSD.SetTxNum(10) + require.NoError(t, seedSD.DomainPut(kv.AccountsDomain, seedTx, addr[:], accounts.SerialiseV3(&acc), 10, nil)) + require.NoError(t, seedSD.Commit(ctx, seedTx)) + seedSD.Close() + + sc := cache.NewDefaultStateCache() + t.Cleanup(sc.Close) + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) + require.NoError(t, err) + defer sd.Close() + sd.SetStateCacheForTest(sc) + + got := sd.CodeHashForAddr(roTx, addr[:], 20) + require.Equal(t, codeHash[:], got) + h, ok := sc.GetAddrCodeHash(addr[:]) + require.True(t, ok, "a snapshot-sourced record must seed the mapping") + require.Equal(t, [32]byte(codeHash), h) +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 1cd79b3599d..e1b9ad5fef4 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1372,31 +1372,37 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui } } - // Resolve from the committed layers (stateCache → MDBX/files) and populate - // the LRU. mem is intentionally not consulted here — it was checked above. - resolve := func() []byte { + // Resolve from the committed layers (stateCache → MDBX/files). mem is + // intentionally not consulted here — it was checked above. fromSnapshot + // reports whether the record was read from the tx snapshot. + resolve := func() ([]byte, bool) { if sd.stateCache != nil { if v, ok := sd.stateCache.Get(kv.AccountsDomain, addr); ok { - return accounts.DeserialiseV3CodeHash(v) + return accounts.DeserialiseV3CodeHash(v), false } } v, _, err := tx.GetLatest(kv.AccountsDomain, addr) - if err != nil || len(v) == 0 { - return nil + if err != nil { + return nil, false } - return accounts.DeserialiseV3CodeHash(v) + if len(v) == 0 { + return nil, true + } + return accounts.DeserialiseV3CodeHash(v), true } - h := resolve() - if sd.stateCache != nil { + h, fromSnapshot := resolve() + if fromSnapshot && 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. txNum is a - // conservative upper bound (>= the resolved account's write txNum), so - // the mapping drops on any unwind that reverts that account. + // Only a snapshot-sourced record (including the zero-hash sentinel for + // misses) may seed the mapping: the admission gate vouches for the tx's + // frontier, and a cache-sourced record can lag a just-committed flush, + // slipping pre-apply state past the gate. txNum is a conservative upper + // bound (>= the resolved account's write txNum), so the mapping drops + // on any unwind that reverts that account. if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(kv.AccountsDomain); ok { sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, snapshotEnd) } From d2ffe0ffdf2d35cae16c91794ca92d94d58a5433 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 14:25:43 +0200 Subject: [PATCH 33/85] db/kv/temporal: re-derive memoized domain frontiers on files-tx reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForceReopenUnderlyingFilesTx swaps tx.aggtx for a fresh files view, which can extend a domain's visible frontier, but Tx.visibleEnds kept the value memoized before the swap. A stale memo only understates (the frontier is monotone), so fills were merely over-rejected — and the current callers only exercise the RwTx path, which does not memoize — but the invariant was implicit. The Tx override now drops the memo after the swap so the next DomainVisibleEnd re-derives it against the reopened view. reset takes the memo mutex so an in-flight load cannot re-store pre-reset bits. --- db/kv/temporal/kv_temporal.go | 14 +++++++ db/kv/temporal/kv_temporal_test.go | 64 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index c97465d94fb..2d9ccd9b21d 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -310,6 +310,13 @@ func (v *domainVisibleEnds) load(tx *Tx, domain kv.Domain, bit uint32) (uint64, return v.ends[domain], state&availableBit != 0 } +// reset takes mu so an in-flight load can't re-store pre-reset bits. +func (v *domainVisibleEnds) reset() { + v.mu.Lock() + defer v.mu.Unlock() + v.state.Store(0) +} + func (tx *tx) ForceReopenUnderlyingFilesTx() { if tx.blocktx != nil { tx.blocktx.Close() @@ -320,6 +327,13 @@ func (tx *tx) ForceReopenUnderlyingFilesTx() { } tx.aggtx = tx.Agg().BeginFilesRo() } + +// ForceReopenUnderlyingFilesTx swaps in a fresh files view, which can extend +// the visible frontier — drop the memoized ends so they are re-derived. +func (tx *Tx) ForceReopenUnderlyingFilesTx() { + tx.tx.ForceReopenUnderlyingFilesTx() + tx.visibleEnds.reset() +} func (tx *tx) FreezeInfo() kv.FreezeInfo { return tx.aggtx } func (tx *tx) AggTx() any { return tx.aggtx } diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index 7d28aacea2d..10704a8bcd3 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -257,6 +257,70 @@ func TestTemporalTx_PinsBlockFilesView(t *testing.T) { require.NotNil(t, roTx2.(*Tx).blocktx) } +// A read-only temporal tx memoizes DomainVisibleEnd, while +// ForceReopenUnderlyingFilesTx swaps in a fresh files view that can extend the +// frontier — the memo must be re-derived after the swap. +func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { + t.Parallel() + ctx := t.Context() + + mdbxDb := memdb.NewTestDB(t, dbcfg.ChainDB) + dirs := datadir.New(t.TempDir()) + agg := state.NewTest(dirs).StepSize(1).MustOpen(ctx, mdbxDb) + defer agg.Close() + temporalDb, err := New(mdbxDb, agg, nil) + require.NoError(t, err) + defer temporalDb.Close() + + acc := common.HexToAddress("0x1234567890123456789012345678901234567890") + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + storageK := append(append([]byte{}, acc[:]...), slot[:]...) + + rwTtx1, err := temporalDb.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTtx1.Rollback() + sd, err := execctx.NewSharedDomains(ctx, rwTtx1, log.Root()) + require.NoError(t, err) + defer sd.Close() + require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTtx1, storageK, []byte{1}, 1, nil)) + require.NoError(t, sd.Flush(ctx, rwTtx1)) + require.NoError(t, rwTtx1.Commit()) + + roTtx, err := temporalDb.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTtx.Rollback() + end, ok := roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) + require.True(t, ok) + require.Equal(t, uint64(2), end) + + // Write past the RO tx's MVCC view and move the data into files, which are + // visible regardless of the DB snapshot. + for txNum := uint64(2); txNum <= 3; txNum++ { + rwTtx, err := temporalDb.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTtx.Rollback() + require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTtx, storageK, []byte{byte(txNum)}, txNum, nil)) + require.NoError(t, sd.Flush(ctx, rwTtx)) + require.NoError(t, rwTtx.Commit()) + } + require.NoError(t, agg.BuildFiles(3)) + + freshRoTtx, err := temporalDb.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshRoTtx.Rollback() + filesEnd := freshRoTtx.Debug().TxNumsInFiles(kv.StorageDomain) + require.Greater(t, filesEnd, uint64(2), "the new files must extend past the memoized frontier") + + end, ok = roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) + require.True(t, ok) + require.Equal(t, uint64(2), end, "the pinned files view cannot see the new files before reopen") + + roTtx.(*Tx).ForceReopenUnderlyingFilesTx() + end, ok = roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) + require.True(t, ok) + require.Equal(t, filesEnd, end, "the frontier must reflect the fresh files view after reopen") +} + func TestTemporalTx_RangeAsOf_StorageDomain(t *testing.T) { t.Parallel() ctx := t.Context() From 446621db44755212105056da2fab7373e2f839eb Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 14:35:31 +0200 Subject: [PATCH 34/85] db/kv/temporal: static-assert the visible-ends bitmask capacity domainVisibleEnds.state packs a loaded and an available bit per domain into a uint32, which would silently overflow past 16 domains; make growing kv.DomainLen beyond that a build failure instead. --- db/kv/temporal/kv_temporal.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 2d9ccd9b21d..0d5f117c354 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -283,6 +283,9 @@ type domainVisibleEnds struct { state atomic.Uint32 } +// state packs a loaded and an available bit per domain — compile-time capacity check. +var _ [32 - 2*int(kv.DomainLen)]struct{} + func (v *domainVisibleEnds) get(tx *Tx, domain kv.Domain) (uint64, bool) { bit := uint32(1) << uint32(domain) state := v.state.Load() From 653f2898f48638e5b36e92035ef9c96e6753b86b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 14:44:51 +0200 Subject: [PATCH 35/85] db/kv/temporal: race-test the DomainVisibleEnd memo fast path Each round opens a fresh read-only tx so the mutex-guarded first load races the lock-free fast path across goroutines and all domains, with per-domain results pinned against a single-goroutine baseline. Verified non-vacuous: reordering the ends publish after the state release in load() makes the test report the data race. --- db/kv/temporal/kv_temporal_test.go | 65 ++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index 10704a8bcd3..5e58100d4b2 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -2,6 +2,7 @@ package temporal import ( "encoding/binary" + "sync" "testing" "time" @@ -257,6 +258,70 @@ func TestTemporalTx_PinsBlockFilesView(t *testing.T) { require.NotNil(t, roTx2.(*Tx).blocktx) } +// DomainVisibleEnd's memo serves repeat readers lock-free while first loads +// run under the memo mutex. Fresh txs each round make the two paths +// interleave across goroutines; results must stay stable (run with -race). +func TestTemporalTx_DomainVisibleEndConcurrent(t *testing.T) { + t.Parallel() + ctx := t.Context() + + mdbxDb := memdb.NewTestDB(t, dbcfg.ChainDB) + dirs := datadir.New(t.TempDir()) + agg := state.NewTest(dirs).StepSize(1).MustOpen(ctx, mdbxDb) + defer agg.Close() + temporalDb, err := New(mdbxDb, agg, nil) + require.NoError(t, err) + defer temporalDb.Close() + + acc := common.HexToAddress("0x1234567890123456789012345678901234567890") + slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") + storageK := append(append([]byte{}, acc[:]...), slot[:]...) + + rwTtx, err := temporalDb.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTtx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, rwTtx, log.Root()) + require.NoError(t, err) + defer sd.Close() + require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTtx, storageK, []byte{1}, 1, nil)) + require.NoError(t, sd.Flush(ctx, rwTtx)) + require.NoError(t, rwTtx.Commit()) + + var expectedEnd [kv.DomainLen]uint64 + var expectedOk [kv.DomainLen]bool + baseTtx, err := temporalDb.BeginTemporalRo(ctx) + require.NoError(t, err) + defer baseTtx.Rollback() + for d := range kv.DomainLen { + expectedEnd[d], expectedOk[d] = baseTtx.Debug().DomainVisibleEnd(d) + } + baseTtx.Rollback() + require.Equal(t, uint64(2), expectedEnd[kv.StorageDomain]) + require.True(t, expectedOk[kv.StorageDomain]) + + for range 25 { + require.NoError(t, temporalDb.ViewTemporal(ctx, func(roTtx kv.TemporalTx) error { + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for range 4 { + for d := range kv.DomainLen { + end, ok := roTtx.Debug().DomainVisibleEnd(d) + if end != expectedEnd[d] || ok != expectedOk[d] { + t.Errorf("domain %v: got (%d, %t), want (%d, %t)", d, end, ok, expectedEnd[d], expectedOk[d]) + } + } + } + }() + } + wg.Wait() + return nil + })) + } +} + // A read-only temporal tx memoizes DomainVisibleEnd, while // ForceReopenUnderlyingFilesTx swaps in a fresh files view that can extend the // frontier — the memo must be re-derived after the swap. From f98dc13c7b665a05f5d92a7fdb71337527c24967 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 15:07:32 +0200 Subject: [PATCH 36/85] execution/cache: inline StateCache.Put's sole remaining insert path The private put helper's overwrite=false branch became unreachable when StateCache.PutIfAbsent was removed; Put was its only caller, always passing overwrite=true. --- execution/cache/state_cache.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 7b44039fdaa..47e1435342b 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -219,7 +219,11 @@ func (c *StateCache) deleteAddrCodeHash(addr []byte) { // 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) { - c.put(domain, key, value, txNum, true) + cache := c.caches[domain] + if cache == nil { + return + } + cache.Put(key, common.Copy(value), txNum) } // FillIfFresh conditionally inserts a snapshot read without replacing an @@ -256,18 +260,6 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea cache.PutIfAbsent(key, common.Copy(value), readTxNum) } -func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64, overwrite bool) { - cache := c.caches[domain] - if cache == nil { - return - } - if overwrite { - cache.Put(key, common.Copy(value), txNum) - } else { - cache.PutIfAbsent(key, common.Copy(value), txNum) - } -} - // Delete removes the data for the given domain and key. func (c *StateCache) Delete(domain kv.Domain, key []byte) { cache := c.caches[domain] From 6619446d01819757d697f76ab4b4af8e5b71a550 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 17 Jul 2026 15:42:01 +0200 Subject: [PATCH 37/85] db/state/execctx: run codehash routing tests in short mode --- db/state/execctx/codehash_routing_test.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index ae24780fa36..9b9de4d5716 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -76,9 +76,6 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { // interleaved between the read and the fill would leave a mapping derived from // the pre-apply record. func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { - if testing.Short() { - t.Skip() - } t.Parallel() ctx := t.Context() @@ -128,9 +125,6 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { // A record read from the tx snapshot (accounts-cache miss) is exactly what the // admission gate vouches for, so it still seeds the mapping. func TestCodeHashForAddr_SnapshotSourcedRecordSeedsMapping(t *testing.T) { - if testing.Short() { - t.Skip() - } t.Parallel() ctx := t.Context() From 25934efe114b396d3f847ae62a62c23fc985707b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 13:13:00 +0200 Subject: [PATCH 38/85] db, execution: rename snapshot to read view in fill admission --- db/kv/temporal/kv_temporal_test.go | 2 +- db/state/execctx/codehash_routing_test.go | 10 ++++----- db/state/execctx/domain_shared.go | 22 +++++++++---------- db/state/execctx/statecache_readfill_test.go | 6 ++--- .../statecache_rpc_integration_test.go | 2 +- execution/cache/cache_test.go | 14 ++++++------ execution/cache/state_cache.go | 20 ++++++++--------- execution/exec/blocks_read_ahead.go | 4 ++-- execution/exec/blocks_read_ahead_test.go | 10 ++++----- 9 files changed, 45 insertions(+), 45 deletions(-) diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index 5e58100d4b2..27fe24b8df1 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -359,7 +359,7 @@ func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { require.Equal(t, uint64(2), end) // Write past the RO tx's MVCC view and move the data into files, which are - // visible regardless of the DB snapshot. + // visible regardless of the DB read view. for txNum := uint64(2); txNum <= 3; txNum++ { rwTtx, err := temporalDb.BeginTemporalRw(ctx) require.NoError(t, err) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 9b9de4d5716..b0a15bebb5c 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -69,7 +69,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { }) } -// The addr→codeHash admission gate vouches for the tx snapshot's frontier, but +// The addr→codeHash admission gate vouches for the tx read view's frontier, but // resolve() may serve the account record from the shared accounts cache, which // lags a just-committed flush until the apply loop reaches the key. A // cache-sourced record must therefore never seed the mapping — an apply @@ -122,9 +122,9 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { require.False(t, ok, "a cache-sourced account record must not seed the addr→codeHash mapping") } -// A record read from the tx snapshot (accounts-cache miss) is exactly what the -// admission gate vouches for, so it still seeds the mapping. -func TestCodeHashForAddr_SnapshotSourcedRecordSeedsMapping(t *testing.T) { +// A record read from the tx's read view (accounts-cache miss) is exactly what +// the admission gate vouches for, so it still seeds the mapping. +func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { t.Parallel() ctx := t.Context() @@ -164,6 +164,6 @@ func TestCodeHashForAddr_SnapshotSourcedRecordSeedsMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) h, ok := sc.GetAddrCodeHash(addr[:]) - require.True(t, ok, "a snapshot-sourced record must seed the mapping") + require.True(t, ok, "a view-sourced record must seed the mapping") require.Equal(t, [32]byte(codeHash), h) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index e1b9ad5fef4..85f7e5c9546 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1131,7 +1131,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k if sd.stateCache != nil { v, cTxNum, ok := sd.stateCache.GetWithTxNum(domain, k) // The cache stamps txNums — divide to get the step the entry reflects. - // A negative uses the last txNum included by its snapshot frontier, not + // A negative uses the last txNum included by its read-view frontier, not // the step of a deletion. cStep := kv.Step(cTxNum / sd.StepSize()) if ok && !servableUnderBound(cStep, maxStep) { @@ -1208,12 +1208,12 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k return nil, 0, fmt.Errorf("storage %x read error: %w", k, err) } - // Snapshot freshness is rechecked while the fill is serialized against + // View freshness is rechecked while the fill is serialized against // committed cache updates. if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { - if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(domain); ok { + if visibleEnd, ok := tx.Debug().DomainVisibleEnd(domain); ok { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 - sd.stateCache.FillIfFresh(domain, k, v, readTxNum, snapshotEnd) + sd.stateCache.FillIfFresh(domain, k, v, readTxNum, visibleEnd) } } // Only cache a branch when the read's txN is known: a txN=0 entry would @@ -1373,8 +1373,8 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui } // Resolve from the committed layers (stateCache → MDBX/files). mem is - // intentionally not consulted here — it was checked above. fromSnapshot - // reports whether the record was read from the tx snapshot. + // intentionally not consulted here — it was checked above. fromReadView + // reports whether the record was read from the tx's read view. resolve := func() ([]byte, bool) { if sd.stateCache != nil { if v, ok := sd.stateCache.Get(kv.AccountsDomain, addr); ok { @@ -1391,20 +1391,20 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui return accounts.DeserialiseV3CodeHash(v), true } - h, fromSnapshot := resolve() - if fromSnapshot && sd.stateCache != nil { + h, fromReadView := resolve() + if fromReadView && sd.stateCache != nil { var fixed [32]byte if len(h) == 32 { copy(fixed[:], h) } - // Only a snapshot-sourced record (including the zero-hash sentinel for + // Only a view-sourced record (including the zero-hash sentinel for // misses) may seed the mapping: the admission gate vouches for the tx's // frontier, and a cache-sourced record can lag a just-committed flush, // slipping pre-apply state past the gate. txNum is a conservative upper // bound (>= the resolved account's write txNum), so the mapping drops // on any unwind that reverts that account. - if snapshotEnd, ok := tx.Debug().DomainVisibleEnd(kv.AccountsDomain); ok { - sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, snapshotEnd) + if visibleEnd, ok := tx.Debug().DomainVisibleEnd(kv.AccountsDomain); ok { + sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, visibleEnd) } } return h diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index d2bbc78cd36..205bb52a86b 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -208,7 +208,7 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { require.Equal(t, v3, got, "read-fill must not clobber the live entry") } -// A negative reflects transactions below the snapshot's exclusive frontier, +// A negative reflects transactions below the read view's exclusive frontier, // so its unwind stamp is the last included txNum. func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { t.Parallel() @@ -254,9 +254,9 @@ func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { sc.Unwind(visibleEnd) _, ok = sc.Get(kv.AccountsDomain, missing) - require.True(t, ok, "an unwind starting after the snapshot must preserve the negative") + require.True(t, ok, "an unwind starting after the read view must preserve the negative") sc.Unwind(visibleEnd - 1) _, ok = sc.Get(kv.AccountsDomain, missing) - require.False(t, ok, "an unwind of the snapshot's last included txNum must invalidate the negative") + require.False(t, ok, "an unwind of the view's last included txNum must invalidate the negative") } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 59efcb0b060..612caf21119 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -256,5 +256,5 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k freshDomains.SetStateCacheForTest(stateCache) got, _, err := freshDomains.GetLatest(domain, freshTx, key) require.NoError(t, err) - require.Empty(t, got, "the old RPC snapshot must not repopulate the shared cache after the deletion") + require.Empty(t, got, "the old RPC read view must not repopulate the shared cache after the deletion") } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index a435269fb91..51668224d3e 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -909,7 +909,7 @@ func TestStateCache_AppliedEndLifecycle(t *testing.T) { require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) } -func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { +func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) t.Cleanup(sc.Close) @@ -923,10 +923,10 @@ func TestStateCache_StaleSnapshotCannotFillAfterDelete(t *testing.T) { sc.FillIfFresh(kv.AccountsDomain, key, stale, 10, 11) _, ok = sc.Get(kv.AccountsDomain, key) - require.False(t, ok, "a snapshot older than the deletion must not fill afterward") + require.False(t, ok, "a view older than the deletion must not fill afterward") } -func TestStateCache_FileEndSnapshotCannotFillAtAppliedTx(t *testing.T) { +func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) t.Cleanup(sc.Close) @@ -937,7 +937,7 @@ func TestStateCache_FileEndSnapshotCannotFillAtAppliedTx(t *testing.T) { sc.FillIfFresh(kv.AccountsDomain, key, stale, 99, 100) _, ok := sc.Get(kv.AccountsDomain, key) - require.False(t, ok, "a [0,100) snapshot does not contain the applied tx 100") + require.False(t, ok, "a [0,100) view does not contain the applied tx 100") fresh := makeValue(2) sc.FillIfFresh(kv.AccountsDomain, key, fresh, 100, 101) @@ -956,18 +956,18 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { value := makeValue(1) for round := range 20000 { appliedTxNum := uint64(round*2 + 1) - snapshotEnd := appliedTxNum + 1 + visibleEnd := appliedTxNum + 1 sc.Apply(kv.AccountsDomain, progressKey, value, appliedTxNum) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - sc.Apply(kv.AccountsDomain, key, nil, snapshotEnd) + sc.Apply(kv.AccountsDomain, key, nil, visibleEnd) }() go func() { defer wg.Done() - sc.FillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, snapshotEnd) + sc.FillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, visibleEnd) }() wg.Wait() diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 47e1435342b..d531b7811e5 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -194,15 +194,15 @@ func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return cc.GetAddrCodeHash(addr) } -// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account snapshot. -func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, snapshotEnd uint64) { +// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account read view. +func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, visibleEnd uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if snapshotEnd < c.appliedEnd[kv.AccountsDomain] { + if visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } cc.PutAddrCodeHash(addr, h, txNum) @@ -226,9 +226,9 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 cache.Put(key, common.Copy(value), txNum) } -// FillIfFresh conditionally inserts a snapshot read without replacing an -// authoritative entry. Negative values use the snapshot's last visible txNum. -func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, snapshotEnd uint64) { +// FillIfFresh conditionally inserts a value read from a read view without +// replacing an authoritative entry. Negatives use the view's last included txNum. +func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd uint64) { cache := c.caches[domain] if cache == nil || (domain == kv.CodeDomain && len(value) == 0) { return @@ -241,7 +241,7 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if snapshotEnd < c.appliedEnd[domain] { + if visibleEnd < c.appliedEnd[domain] { return } @@ -253,8 +253,8 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea } if len(value) == 0 { readTxNum = 0 - if snapshotEnd > 0 { - readTxNum = snapshotEnd - 1 + if visibleEnd > 0 { + readTxNum = visibleEnd - 1 } } cache.PutIfAbsent(key, common.Copy(value), readTxNum) @@ -274,7 +274,7 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { var codeHash []byte if domain == kv.CodeDomain && len(value) > 0 { // Copy before hashing so the stored bytes and codeHash come from the - // same snapshot of the caller-owned buffer. + // same copy of the caller-owned buffer. value = common.Copy(value) codeHash = crypto.Keccak256(value) } diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 4e645b28ca1..63a34db6609 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -96,9 +96,9 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { v, step, err := cpg.TemporalGetter.GetLatest(name, k) if err == nil { - if snapshotEnd, ok := cpg.visibleEnd(name); ok { + if viewEnd, ok := cpg.visibleEnd(name); ok { readTxNum := (uint64(step)+1)*cpg.stepSize - 1 - cpg.sc.FillIfFresh(name, k, v, readTxNum, snapshotEnd) + cpg.sc.FillIfFresh(name, k, v, readTxNum, viewEnd) } } return v, step, err diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 22efb04c08d..e13ac7b5505 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -27,7 +27,7 @@ import ( "github.com/erigontech/erigon/execution/cache" ) -// stubTemporalGetter stands in for the committed-state snapshot a warmup +// stubTemporalGetter stands in for the committed-state read view a warmup // goroutine reads: every GetLatest returns the same fixed value. type stubTemporalGetter struct { v []byte @@ -51,8 +51,8 @@ func newTestStateCache() *cache.StateCache { // A warmup read-through must never replace a fresher entry an authoritative // writer (the FCU flush cache-apply) has already put: the warmup reads a -// pre-flush snapshot, so a laggard Put landing after the flush would pin stale -// state in the cache and corrupt the next block's execution. +// pre-flush read view, so a laggard Put landing after the flush would pin +// stale state in the cache and corrupt the next block's execution. func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") fresh := []byte("account-record-nonce-5") @@ -64,7 +64,7 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) - require.Equal(t, stale, v, "read-through must still return the snapshot value") + require.Equal(t, stale, v, "read-through must still return the view's value") got, ok := sc.Get(domain, key) require.True(t, ok, "domain %s", domain) @@ -162,7 +162,7 @@ func TestCachePopulatingGetterUnavailableVisibleEndNeverFills(t *testing.T) { require.False(t, ok, "no exact frontier — nothing may be cached") } -func TestCachePopulatingGetterStaleSnapshotDoesNotFill(t *testing.T) { +func TestCachePopulatingGetterStaleViewDoesNotFill(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() sc.Apply(kv.AccountsDomain, key, nil, 20) From 3f7945f026c836c04ea26b7a55ea16a1b1cbf5c3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 13:54:09 +0200 Subject: [PATCH 39/85] execution/cache, db/kv: clarify fill-admission comments --- db/kv/kv_interface.go | 4 ++-- execution/cache/state_cache.go | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index fd9350cd668..2c28d2faf9d 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -518,8 +518,8 @@ type TemporalDebugTx interface { HistoryStartFrom(domainName Domain) uint64 DomainProgress(domain Domain) (txNum uint64) - // DomainVisibleEnd returns the domain's exact exclusive frontier used for - // cache-fill admission. ok is false when an exact frontier is unavailable. + // DomainVisibleEnd returns the exact exclusive txNum bound of the tx's + // domain read view. ok is false when the backend cannot provide an exact bound. DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 2cf0e016e5f..2f1fd40deee 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -152,7 +152,8 @@ func (c *StateCache) GetCodeByHash(codeHash []byte) ([]byte, bool) { // PutCodeWithHash stores code populating both the addr-keyed path and the // codeHash-keyed codeHashToCode layer. Callers should prefer this over Put when they -// have the codeHash from the account record — avoids a redundant keccak. +// have the codeHash from the account record — avoids a redundant keccak. Like Put, +// it bypasses fill admission. func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -194,7 +195,9 @@ func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return cc.GetAddrCodeHash(addr) } -// PutAddrCodeHashIfFresh conditionally fills a mapping from a current account read view. +// PutAddrCodeHashIfFresh conditionally records an addr → codeHash mapping. +// The mapping derives from an account record, so admission checks the accounts +// frontier even though the mapping lives in the code cache. func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, visibleEnd uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -217,7 +220,8 @@ func (c *StateCache) deleteAddrCodeHash(addr []byte) { } // Put stores data for the given domain and key, stamped with the txNum the -// value reflects (for txNum/epoch unwind invalidation). +// value reflects (for txNum/epoch unwind invalidation). It bypasses fill +// admission: committed updates go through Apply, read fills through FillIfFresh. func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint64) { cache := c.caches[domain] if cache == nil { @@ -260,7 +264,8 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea cache.PutIfAbsent(key, bytes.Clone(value), readTxNum) } -// Delete removes the data for the given domain and key. +// Delete removes the data for the given domain and key. Authoritative +// deletions go through Apply, which also advances the fill-admission frontier. func (c *StateCache) Delete(domain kv.Domain, key []byte) { cache := c.caches[domain] if cache == nil { @@ -273,8 +278,8 @@ func (c *StateCache) Delete(domain kv.Domain, key []byte) { func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { var codeHash []byte if domain == kv.CodeDomain && len(value) > 0 { - // Copy before hashing so the stored bytes and codeHash come from the - // same copy of the caller-owned buffer. + // Clone before hashing so the stored bytes and their codeHash cannot + // diverge if the caller reuses its buffer. value = bytes.Clone(value) codeHash = crypto.Keccak256(value) } From 588b5f79548814f110decedfb91d2916690c3998 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 15:13:33 +0200 Subject: [PATCH 40/85] db/state/execctx: cache writable domain visible ends --- db/state/execctx/domain_shared.go | 41 +++++++++++- .../execctx/statecache_readfill_bench_test.go | 32 ++++++--- db/state/execctx/statecache_readfill_test.go | 66 +++++++++++++++++++ 3 files changed, 129 insertions(+), 10 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index ea9866a5cb3..36a6bb7af04 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -81,6 +81,38 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } +type domainVisibleEndMemo struct { + viewID uint64 + ends [kv.DomainLen]uint64 + loaded [kv.DomainLen]bool + ok [kv.DomainLen]bool +} + +func (m *domainVisibleEndMemo) get(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { + viewID := tx.ViewID() + if viewID != m.viewID { + m.viewID = viewID + m.loaded = [kv.DomainLen]bool{} + } + + if !m.loaded[domain] { + m.ends[domain], m.ok[domain] = tx.Debug().DomainVisibleEnd(domain) + m.loaded[domain] = true + } + return m.ends[domain], m.ok[domain] +} + +func (m *domainVisibleEndMemo) reset() { + m.loaded = [kv.DomainLen]bool{} +} + +func (sd *SharedDomains) domainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { + if _, ok := tx.(kv.TemporalRwTx); ok { + return sd.visibleEnds.get(tx, domain) + } + return tx.Debug().DomainVisibleEnd(domain) +} + func IsDomainAheadOfBlocks(ctx context.Context, tx kv.TemporalRwTx, logger log.Logger) bool { doms, err := NewSharedDomains(ctx, tx, logger) if doms != nil { @@ -126,6 +158,10 @@ type SharedDomains struct { // stateCache is an optional cache for state data (accounts, storage, code) stateCache *cache.StateCache + // Backing frontiers stay fixed while writes remain in mem; flush resets + // the memo after writing them into the transaction. + visibleEnds domainVisibleEndMemo + // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code // cache, reached via temporalGetter so an addr-keyed reader can serve a // code-by-hash read with the application's authoritative codehash. @@ -864,6 +900,7 @@ func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { } func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.FlushOption) error { + defer sd.visibleEnds.reset() if sd.sdCtx.HasPendingUpdate() { if ttx, ok := tx.(kv.TemporalTx); ok { if err := sd.FlushPendingUpdates(ctx, ttx); err != nil { @@ -1208,7 +1245,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // View freshness is rechecked while the fill is serialized against // committed cache updates. if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { - if visibleEnd, ok := tx.Debug().DomainVisibleEnd(domain); ok { + if visibleEnd, ok := sd.domainVisibleEnd(tx, domain); ok { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 sd.stateCache.FillIfFresh(domain, k, v, readTxNum, visibleEnd) } @@ -1400,7 +1437,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // slipping pre-apply state past the gate. txNum is a conservative upper // bound (>= the resolved account's write txNum), so the mapping drops // on any unwind that reverts that account. - if visibleEnd, ok := tx.Debug().DomainVisibleEnd(kv.AccountsDomain); ok { + if visibleEnd, ok := sd.domainVisibleEnd(tx, kv.AccountsDomain); ok { sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, visibleEnd) } } diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 5fe4dadeb66..8d13ae8face 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -66,17 +66,25 @@ func BenchmarkDomainVisibleEnd(b *testing.B) { // benchColdNegativeReads drives the full cold-negative SD read: the whole // miss stack, plus — when a cache is wired — the exact-frontier lookup and // freshness-checked fill. -func benchColdNegativeReads(b *testing.B, withCache bool) { +func benchColdNegativeReads(b *testing.B, withCache, writable bool) { db := benchSeedDb(b) ctx := b.Context() - roTx, err := db.BeginTemporalRo(ctx) + var tx kv.TemporalTx + var err error + if writable { + tx, err = db.BeginTemporalRw(ctx) + } else { + tx, err = db.BeginTemporalRo(ctx) + } require.NoError(b, err) - defer roTx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) + defer tx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, tx, log.New()) require.NoError(b, err) defer sd.Close() if withCache { - sd.SetStateCacheForTest(newSmallStateCache()) + stateCache := newSmallStateCache() + defer stateCache.Close() + sd.SetStateCacheForTest(stateCache) } key := make([]byte, 20) @@ -84,7 +92,7 @@ func benchColdNegativeReads(b *testing.B, withCache bool) { b.ResetTimer() for i := 0; i < b.N; i++ { binary.BigEndian.PutUint64(key[12:], uint64(i)+1) - v, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) + v, _, err := sd.GetLatest(kv.AccountsDomain, tx, key) if err != nil { b.Fatal(err) } @@ -94,7 +102,15 @@ func benchColdNegativeReads(b *testing.B, withCache bool) { } } -func BenchmarkGetLatestColdNegative(b *testing.B) { benchColdNegativeReads(b, true) } +func BenchmarkGetLatestColdNegative(b *testing.B) { benchColdNegativeReads(b, true, false) } // The baseline the stamp+fill cost adds to. -func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { benchColdNegativeReads(b, false) } +func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { + benchColdNegativeReads(b, false, false) +} + +func BenchmarkGetLatestColdNegativeRw(b *testing.B) { benchColdNegativeReads(b, true, true) } + +func BenchmarkGetLatestColdNegativeRwNoCache(b *testing.B) { + benchColdNegativeReads(b, false, true) +} diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 205bb52a86b..5662dfa7505 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -75,6 +75,72 @@ func newSmallStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } +type visibleEndCountingDebugTx struct { + kv.TemporalDebugTx + calls uint64 + last uint64 +} + +func (tx *visibleEndCountingDebugTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { + tx.calls++ + end, ok := tx.TemporalDebugTx.DomainVisibleEnd(domain) + tx.last = end + return end, ok +} + +type visibleEndCountingRwTx struct { + kv.TemporalRwTx + debug *visibleEndCountingDebugTx +} + +func (tx *visibleEndCountingRwTx) Debug() kv.TemporalDebugTx { + return tx.debug +} + +func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + baseTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer baseTx.Rollback() + debug := &visibleEndCountingDebugTx{TemporalDebugTx: baseTx.Debug()} + rwTx := &visibleEndCountingRwTx{TemporalRwTx: baseTx, debug: debug} + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + for i := byte(2); i <= 3; i++ { + missing := make([]byte, 20) + missing[0] = i + value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) + require.NoError(t, err) + require.Empty(t, value) + } + require.Equal(t, uint64(1), debug.calls) + initialEnd := debug.last + + written := make([]byte, 20) + written[0] = 4 + domains.SetTxNum(20) + require.NoError(t, domains.DomainPut(kv.AccountsDomain, rwTx, written, encAccount(2), 20, nil)) + require.NoError(t, domains.Flush(ctx, rwTx)) + + missing := make([]byte, 20) + missing[0] = 5 + value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) + require.NoError(t, err) + require.Empty(t, value) + require.Equal(t, uint64(2), debug.calls) + require.Greater(t, debug.last, initialEnd) +} + // During an in-flight unwind the mem overlay bounds reads of an affected key // by maxStep while MDBX still holds the not-yet-deleted dying row inside that // bound. A cache hit legitimately below the unwind floor then diverges from From f53ee44c958a5f959db09748e765b566ef2a5fb5 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 15:42:43 +0200 Subject: [PATCH 41/85] db/state/execctx: reset visible-end memo on unwind --- db/state/execctx/domain_shared.go | 6 ++-- db/state/execctx/statecache_readfill_test.go | 37 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 36a6bb7af04..e8d901ea6ca 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -158,8 +158,9 @@ type SharedDomains struct { // stateCache is an optional cache for state data (accounts, storage, code) stateCache *cache.StateCache - // Backing frontiers stay fixed while writes remain in mem; flush resets - // the memo after writing them into the transaction. + // Backing frontiers stay fixed while writes remain in mem: flush moves mem + // into the transaction and the paired aggregator-level unwind rewrites + // backing rows, so both reset the memo. visibleEnds domainVisibleEndMemo // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code @@ -683,6 +684,7 @@ func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumb // Unwind drops [txNumUnwindTo, ∞) func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][]kv.DomainEntryDiff) { + sd.visibleEnds.reset() sd.mem.Unwind(txNumUnwindTo, changeset) // Tx/epoch-aware unwind of the commitment BranchCache: every cached branch // whose bytes belong to the rolled-back window (txN at/above the unwind diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 5662dfa7505..fe8aeab7b85 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -141,6 +141,43 @@ func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { require.Greater(t, debug.last, initialEnd) } +// The paired aggregator-level unwind rewrites backing rows on the same +// transaction, so SharedDomains.Unwind must drop the memoized frontiers. +func TestReadFill_UnwindResetsWritableVisibleEndMemo(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + baseTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer baseTx.Rollback() + debug := &visibleEndCountingDebugTx{TemporalDebugTx: baseTx.Debug()} + rwTx := &visibleEndCountingRwTx{TemporalRwTx: baseTx, debug: debug} + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + missing := make([]byte, 20) + missing[0] = 2 + value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) + require.NoError(t, err) + require.Empty(t, value) + require.Equal(t, uint64(1), debug.calls) + + domains.Unwind(30, nil) + + missing[0] = 3 + value, _, err = domains.GetLatest(kv.AccountsDomain, rwTx, missing) + require.NoError(t, err) + require.Empty(t, value) + require.Equal(t, uint64(2), debug.calls, "unwind must drop the memoized frontier") +} + // During an in-flight unwind the mem overlay bounds reads of an affected key // by maxStep while MDBX still holds the not-yet-deleted dying row inside that // bound. A cache hit legitimately below the unwind floor then diverges from From 4004aaaa0f58c284cff0b7b43aafeef38477a86b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 16:11:31 +0200 Subject: [PATCH 42/85] db/state/execctx: make writable visible-end memo concurrency-safe --- db/state/execctx/domain_shared.go | 53 ++++++++++--- .../execctx/domain_visible_end_memo_test.go | 78 +++++++++++++++++++ 2 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 db/state/execctx/domain_visible_end_memo_test.go diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index e8d901ea6ca..663d0735700 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -81,29 +81,58 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } +// domainVisibleEndMemo caches DomainVisibleEnd per domain. Concurrent gets +// share a single view; view rotation happens in serial sections. type domainVisibleEndMemo struct { - viewID uint64 - ends [kv.DomainLen]uint64 - loaded [kv.DomainLen]bool - ok [kv.DomainLen]bool + ends [kv.DomainLen]atomic.Uint64 + mu sync.Mutex + viewID atomic.Uint64 + state atomic.Uint32 } +// state packs a loaded and an ok bit per domain — compile-time capacity check. +var _ [32 - 2*int(kv.DomainLen)]struct{} + +// The fast path reads viewID before state, and rotation clears state before +// publishing the new viewID, so a stale loaded bit can never pair with a +// fresh viewID. func (m *domainVisibleEndMemo) get(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { viewID := tx.ViewID() - if viewID != m.viewID { - m.viewID = viewID - m.loaded = [kv.DomainLen]bool{} + bit := uint32(1) << uint32(domain) + if m.viewID.Load() == viewID { + if state := m.state.Load(); state&bit != 0 { + return m.ends[domain].Load(), state&(bit<. + +package execctx + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/kv" +) + +type stubVisibleEndTx struct { + kv.TemporalTx + viewID uint64 +} + +func (tx *stubVisibleEndTx) ViewID() uint64 { return tx.viewID } +func (tx *stubVisibleEndTx) Debug() kv.TemporalDebugTx { return stubVisibleEndDebug{viewID: tx.viewID} } + +type stubVisibleEndDebug struct { + kv.TemporalDebugTx + viewID uint64 +} + +func (d stubVisibleEndDebug) DomainVisibleEnd(kv.Domain) (uint64, bool) { + return d.viewID * 100, true +} + +// Parallel-exec workers share one SharedDomains and one view, so the memo +// must tolerate concurrent gets interleaved with resets, and must re-derive +// after a sequential view rotation. +func TestDomainVisibleEndMemoConcurrent(t *testing.T) { + t.Parallel() + + var memo domainVisibleEndMemo + var wg sync.WaitGroup + for range 8 { + tx := &stubVisibleEndTx{viewID: 7} + wg.Go(func() { + for range 512 { + for d := range kv.DomainLen { + end, ok := memo.get(tx, d) + if !ok || end != 700 { + t.Errorf("domain %v: got (%d, %t)", d, end, ok) + return + } + } + } + }) + } + wg.Go(func() { + for range 512 { + memo.reset() + } + }) + wg.Wait() + + rotated := &stubVisibleEndTx{viewID: 8} + end, ok := memo.get(rotated, kv.AccountsDomain) + require.True(t, ok) + require.Equal(t, uint64(800), end) +} From ba5263bd6f325555be28f2965c76605e911ef75f Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 16:25:44 +0200 Subject: [PATCH 43/85] db/state/execctx: drop redundant unwind memo reset SharedDomains.Unwind only stages the unwind in mem; the backing rows change during Flush (flushLocked -> TemporalRwTx.Unwind), and flushMem already resets the memo after that. Between staging and flush the pre-unwind frontier is still the true backing frontier, so the staging-time reset only forced a redundant re-derivation. --- db/state/execctx/domain_shared.go | 6 ++-- db/state/execctx/statecache_readfill_test.go | 37 -------------------- 2 files changed, 2 insertions(+), 41 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 663d0735700..41e7ce9499c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -187,9 +187,8 @@ type SharedDomains struct { // stateCache is an optional cache for state data (accounts, storage, code) stateCache *cache.StateCache - // Backing frontiers stay fixed while writes remain in mem: flush moves mem - // into the transaction and the paired aggregator-level unwind rewrites - // backing rows, so both reset the memo. + // Backing frontiers stay fixed while writes and staged unwinds remain in + // mem; both reach the transaction during flush, which resets the memo. visibleEnds domainVisibleEndMemo // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code @@ -713,7 +712,6 @@ func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumb // Unwind drops [txNumUnwindTo, ∞) func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][]kv.DomainEntryDiff) { - sd.visibleEnds.reset() sd.mem.Unwind(txNumUnwindTo, changeset) // Tx/epoch-aware unwind of the commitment BranchCache: every cached branch // whose bytes belong to the rolled-back window (txN at/above the unwind diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index fe8aeab7b85..5662dfa7505 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -141,43 +141,6 @@ func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { require.Greater(t, debug.last, initialEnd) } -// The paired aggregator-level unwind rewrites backing rows on the same -// transaction, so SharedDomains.Unwind must drop the memoized frontiers. -func TestReadFill_UnwindResetsWritableVisibleEndMemo(t *testing.T) { - t.Parallel() - - const stepSize = uint64(16) - ctx := t.Context() - db := newTestDb(t, stepSize) - - baseTx, err := db.BeginTemporalRw(ctx) - require.NoError(t, err) - defer baseTx.Rollback() - debug := &visibleEndCountingDebugTx{TemporalDebugTx: baseTx.Debug()} - rwTx := &visibleEndCountingRwTx{TemporalRwTx: baseTx, debug: debug} - domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) - require.NoError(t, err) - defer domains.Close() - stateCache := newSmallStateCache() - t.Cleanup(stateCache.Close) - domains.SetStateCacheForTest(stateCache) - - missing := make([]byte, 20) - missing[0] = 2 - value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) - require.NoError(t, err) - require.Empty(t, value) - require.Equal(t, uint64(1), debug.calls) - - domains.Unwind(30, nil) - - missing[0] = 3 - value, _, err = domains.GetLatest(kv.AccountsDomain, rwTx, missing) - require.NoError(t, err) - require.Empty(t, value) - require.Equal(t, uint64(2), debug.calls, "unwind must drop the memoized frontier") -} - // During an in-flight unwind the mem overlay bounds reads of an affected key // by maxStep while MDBX still holds the not-yet-deleted dying row inside that // bound. A cache hit legitimately below the unwind floor then diverges from From 02d2eaa9ef01cdbe9d7657e55bd359c08b221d4e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 28 Jul 2026 16:56:52 +0200 Subject: [PATCH 44/85] db/state/execctx: keep visible-end memo coherent across views Validate lock-free reads with a sequence counter so concurrent view rotations cannot pair one view ID with another view's frontier. Add a concurrent multi-view regression test that reproduces the cross-view result. --- db/state/execctx/domain_shared.go | 50 +++++++++++-------- .../execctx/domain_visible_end_memo_test.go | 25 ++++++++++ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 41e7ce9499c..18f19a393d0 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -81,11 +81,12 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } -// domainVisibleEndMemo caches DomainVisibleEnd per domain. Concurrent gets -// share a single view; view rotation happens in serial sections. +// domainVisibleEndMemo caches DomainVisibleEnd per domain for one view at a time. +// Its sequence counter keeps lock-free reads coherent across view changes. type domainVisibleEndMemo struct { ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex + seq atomic.Uint64 viewID atomic.Uint64 state atomic.Uint32 } @@ -93,15 +94,16 @@ type domainVisibleEndMemo struct { // state packs a loaded and an ok bit per domain — compile-time capacity check. var _ [32 - 2*int(kv.DomainLen)]struct{} -// The fast path reads viewID before state, and rotation clears state before -// publishing the new viewID, so a stale loaded bit can never pair with a -// fresh viewID. func (m *domainVisibleEndMemo) get(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { viewID := tx.ViewID() bit := uint32(1) << uint32(domain) - if m.viewID.Load() == viewID { + seq := m.seq.Load() + if seq&1 == 0 && m.viewID.Load() == viewID { if state := m.state.Load(); state&bit != 0 { - return m.ends[domain].Load(), state&(bit< Date: Tue, 28 Jul 2026 17:37:15 +0200 Subject: [PATCH 45/85] db/kv/temporal: make memoized visible ends atomic A lock-free fast-path read of ends[domain] can overlap the re-store of the same slot after a reset; the publication via the state bitmask only orders the first write. Atomic accesses close the gap for any interleaving instead of relying on reopen staying a serial section. Measured cost: one load-acquire on arm64 (~2% on the memoized read), plain load on x86-64. --- db/kv/temporal/kv_temporal.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index dae732d8803..da0551adcc9 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -277,7 +277,9 @@ type RwTx struct { } type domainVisibleEnds struct { - ends [kv.DomainLen]uint64 + // ends is atomic so a lock-free read can overlap a reset-and-reload of + // the same slot without a data race. + ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 } @@ -289,7 +291,7 @@ func (v *domainVisibleEnds) get(tx *Tx, domain kv.Domain) (uint64, bool) { bit := uint32(1) << uint32(domain) state := v.state.Load() if state&bit != 0 { - return v.ends[domain], state&(bit< Date: Tue, 4 Aug 2026 12:44:41 +0200 Subject: [PATCH 46/85] execution/cache, db/state/execctx, execution/exec: access StateCache through ReadView/Applier handles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StateCache itself no longer exposes data methods. Reads and admission-gated fills go through a ReadView bound to one tx's read view — the view resolves the frontier internally, so a fill can no longer be paired with another tx's frontier — and committed updates, unwinds and clears go through the Applier handle held by the SharedDomains flush/unwind path. Hot read paths stay allocation-free: getters build their view once, and the plain GetLatest wrappers pass a read-only view, binding a frontier only on the miss path. The cold negative fill through the plain wrappers gains one allocation (frontier boxing), amortized against the backing read it follows. --- db/state/execctx/codehash_routing_test.go | 10 +- db/state/execctx/domain_shared.go | 92 ++++++--- db/state/execctx/export_test.go | 1 + db/state/execctx/flush_storage_cache_test.go | 4 +- db/state/execctx/statecache_readfill_test.go | 38 +++- .../statecache_rpc_integration_test.go | 12 +- execution/cache/cache_test.go | 132 ++++++------- execution/cache/state_cache.go | 80 ++++---- execution/cache/view.go | 177 ++++++++++++++++++ execution/exec/blocks_read_ahead.go | 23 +-- execution/exec/blocks_read_ahead_test.go | 59 +++--- 11 files changed, 432 insertions(+), 196 deletions(-) create mode 100644 execution/cache/view.go diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index b0a15bebb5c..a0203e36d98 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -45,7 +45,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - sc.PutAddrCodeHashIfFresh(addr[:], staleArr, 0, 0) + sc.View(frontierAt(0)).SeedAddrCodeHash(addr[:], staleArr, 0) t.Run("empty in-batch account wins (codeHash-no-code repro)", func(t *testing.T) { acc := accounts.Account{Nonce: 7, CodeHash: accounts.EmptyCodeHash} @@ -103,9 +103,9 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { require.NoError(t, seedSD.Commit(ctx, seedTx)) seedSD.Close() - _, ok := sc.Get(kv.AccountsDomain, addr[:]) + _, ok := sc.View(nil).Get(kv.AccountsDomain, addr[:]) require.True(t, ok, "the committed record must be served by the accounts cache") - _, ok = sc.GetAddrCodeHash(addr[:]) + _, ok = sc.View(nil).GetAddrCodeHash(addr[:]) require.False(t, ok, "the flush apply must leave the derived mapping empty") roTx, err := db.BeginTemporalRo(ctx) @@ -118,7 +118,7 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - _, ok = sc.GetAddrCodeHash(addr[:]) + _, ok = sc.View(nil).GetAddrCodeHash(addr[:]) require.False(t, ok, "a cache-sourced account record must not seed the addr→codeHash mapping") } @@ -163,7 +163,7 @@ func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - h, ok := sc.GetAddrCodeHash(addr[:]) + h, ok := sc.View(nil).GetAddrCodeHash(addr[:]) require.True(t, ok, "a view-sourced record must seed the mapping") require.Equal(t, [32]byte(codeHash), h) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index f55a497028f..10581294fdd 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -152,6 +152,31 @@ func (sd *SharedDomains) domainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (u return tx.Debug().DomainVisibleEnd(domain) } +// sdFrontier adapts one (SharedDomains, tx) pair to cache.Frontier: writable +// txs go through the SD's flush-coherent memo, read-only txs use their own +// tx-local memo. +type sdFrontier struct { + sd *SharedDomains + tx kv.TemporalTx +} + +func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { + return f.sd.domainVisibleEnd(f.tx, domain) +} + +// cacheViewFor binds the shared state cache to tx's read view. Boxing the +// frontier allocates, so per-read paths hold the view in their getter instead +// of rebuilding it per call. +func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { + if sd.stateCache == nil { + return cache.ReadView{} + } + return sd.stateCache.View(sdFrontier{sd: sd, tx: tx}) +} + +// cacheReader is a read-only view (no fill rights); safe on a nil cache. +func (sd *SharedDomains) cacheReader() cache.ReadView { return sd.stateCache.View(nil) } + func IsDomainAheadOfBlocks(ctx context.Context, tx kv.TemporalRwTx, logger log.Logger) bool { doms, err := NewSharedDomains(ctx, tx, logger) if doms != nil { @@ -194,8 +219,10 @@ type SharedDomains struct { // to read from the FCU's published SD without writing to it. parent *SharedDomains - // stateCache is an optional cache for state data (accounts, storage, code) - stateCache *cache.StateCache + // stateCache is an optional cache for state data (accounts, storage, code); + // cacheApplier is its authoritative writer handle (flush/unwind only). + stateCache *cache.StateCache + cacheApplier cache.Applier // Backing frontiers stay fixed while writes and staged unwinds remain in // mem; both reach the transaction during flush, which resets the memo. @@ -485,6 +512,9 @@ func (sd *SharedDomains) domainPutNoLock(domain kv.Domain, roTx kv.TemporalTx, k type temporalGetter struct { sd *SharedDomains tx kv.TemporalTx + // view binds the shared state cache to tx's read view once per getter, + // keeping the per-read path allocation-free. + view cache.ReadView // 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, @@ -494,7 +524,7 @@ type temporalGetter struct { } func (gt *temporalGetter) GetLatest(name kv.Domain, k []byte) (v []byte, step kv.Step, err error) { - return gt.sd.getLatestMetered(name, gt.tx, k, gt.m) + return gt.sd.getLatestMetered(name, gt.tx, k, gt.m, gt.view) } // GetLatestContext is the context-aware read: it records into the per-worker, @@ -504,7 +534,7 @@ func (gt *temporalGetter) GetLatest(name kv.Domain, k []byte) (v []byte, step kv // lock. Optional method — callers type-assert for it (mirrors the existing // AggregatorRoTx.MeteredGetLatest pattern). 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, kvmetrics.MetricsFromContext(ctx)) + return gt.sd.getLatestMetered(name, gt.tx, k, kvmetrics.MetricsFromContext(ctx), gt.view) } // GetCodeSize returns the length of the code at addr without loading the @@ -538,13 +568,13 @@ func (gt *temporalGetter) StepsInFiles(entitySet ...kv.Domain) kv.Step { } func (sd *SharedDomains) AsGetter(tx kv.TemporalTx) kv.TemporalGetter { - return &temporalGetter{sd: sd, tx: tx} + return &temporalGetter{sd: sd, tx: tx, view: sd.cacheViewFor(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} + return &temporalGetter{sd: sd, tx: tx, view: sd.cacheViewFor(tx)} } // AsGetterMetered returns a getter that records reads into the caller's own @@ -552,7 +582,7 @@ func (sd *SharedDomains) AsGetterNoMetrics(tx kv.TemporalTx) kv.TemporalGetter { // caller hands it off via MergeMetrics at task end (a lock per task, not per // read) and allocates a fresh instance. Used by parallel-exec workers. func (sd *SharedDomains) AsGetterMetered(tx kv.TemporalTx, m *kvmetrics.DomainMetrics) kv.TemporalGetter { - return &temporalGetter{sd: sd, tx: tx, m: m} + return &temporalGetter{sd: sd, tx: tx, m: m, view: sd.cacheViewFor(tx)} } // MergeMetrics hands a boundary producer's accumulator to BOTH sinks: the @@ -741,12 +771,10 @@ func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][ } } // Invalidate the state cache for everything above the unwind point. txNum/epoch - // based and diffset-free (see StateCache.Unwind), so it runs unconditionally — + // based and diffset-free (see Applier.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 sd.stateCache != nil { - sd.stateCache.Unwind(txNumUnwindTo) - } + sd.cacheApplier.Unwind(txNumUnwindTo) } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -808,7 +836,7 @@ func (sd *SharedDomains) Logger() log.Logger { return sd.logger } // Coherence is structural, enforced by the architecture rather than by // remembering to call this: app components reach state only through the SD, and // the SD owns cache population (on flush) and invalidation (sd.Unwind → -// stateCache.Unwind). It is not *additionally* type-enforced only because the +// Applier.Unwind). It is not *additionally* type-enforced only because the // cache crosses the app/storage boundary — the storage layer can't depend on an // app-level cache type. The single desync vector is a component that // deliberately bypasses the SD (raw domain reads + direct cache writes, e.g. @@ -818,6 +846,7 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { return } sd.stateCache = stateCache + sd.cacheApplier = stateCache.Applier() } // SetCodeStore sets the persistent codehash-keyed code cache. @@ -1119,7 +1148,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun } continue } - sd.stateCache.Apply(u.domain, u.key, u.val, u.txN) + sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) } return nil } @@ -1127,7 +1156,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // 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) + return sd.getLatestMetered(domain, tx, k, nil, sd.cacheReader()) } // GetLatestContext is the context-aware read for callers that read on behalf of @@ -1136,7 +1165,7 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) // 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, kvmetrics.MetricsFromContext(ctx)) + return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheReader()) } // servableUnderBound gates a cached entry against an in-flight unwind's @@ -1153,7 +1182,7 @@ func servableUnderBound(cStep, maxStep kv.Step) bool { // 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 *kvmetrics.DomainMetrics) (v []byte, step kv.Step, err error) { +func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k []byte, wm *kvmetrics.DomainMetrics, view cache.ReadView) (v []byte, step kv.Step, err error) { if tx == nil { return nil, 0, errors.New("sd.GetLatest: unexpected nil tx") } @@ -1205,7 +1234,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // stateCache holds committed values shared across domain readers. if sd.stateCache != nil { - v, cTxNum, ok := sd.stateCache.GetWithTxNum(domain, k) + v, cTxNum, ok := view.GetWithTxNum(domain, k) // The cache stamps txNums — divide to get the step the entry reflects. // A negative uses the last txNum included by its read-view frontier, not // the step of a deletion. @@ -1283,11 +1312,16 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // View freshness is rechecked while the fill is serialized against // committed cache updates. - if sd.stateCache != nil && sd.stateCache.GetCache(domain) != nil { - if visibleEnd, ok := sd.domainVisibleEnd(tx, domain); ok { - readTxNum := (uint64(step)+1)*sd.StepSize() - 1 - sd.stateCache.FillIfFresh(domain, k, v, readTxNum, visibleEnd) + if sd.stateCache != nil && sd.stateCache.Caches(domain) { + readTxNum := (uint64(step)+1)*sd.StepSize() - 1 + fillView := view + if !fillView.CanFill() { + // Read-only view from the plain GetLatest wrappers: bind a frontier + // here, on the miss path, where the boxing amortizes against the + // backing read it follows. + fillView = sd.cacheViewFor(tx) } + fillView.Fill(domain, k, v, readTxNum) } // Only cache a branch when the read's txN is known: a txN=0 entry would // be treated as immortal by UnwindTo, so skip the Put rather than insert @@ -1329,13 +1363,13 @@ func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64 // the size is in the size cache, return without loading bytes. if sd.stateCache != nil { if codeHash := sd.codeHashForAddr(tx, addr, txNum); len(codeHash) > 0 { - if size, ok := sd.stateCache.GetCodeSizeByHash(codeHash); ok { + if size, ok := sd.cacheReader().GetCodeSizeByHash(codeHash); ok { return size, true, nil } - if cv, ok := sd.stateCache.GetCodeByHash(codeHash); ok { + if cv, ok := sd.cacheReader().GetCodeByHash(codeHash); ok { // txNum is a conservative upper bound: >= the live code's write // txNum, so the size drops on any unwind that drops the code. - sd.stateCache.PutCodeSizeByHash(codeHash, len(cv), txNum) + sd.cacheReader().FillCodeSize(codeHash, len(cv), txNum) return len(cv), true, nil } } @@ -1381,7 +1415,7 @@ func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([ if sd.stateCache != nil || sd.codeStore != nil { if codeHash = sd.codeHashForAddr(tx, addr, txNum); len(codeHash) > 0 { if sd.stateCache != nil { - if cv, ok := sd.stateCache.GetCodeByHash(codeHash); ok { + if cv, ok := sd.cacheReader().GetCodeByHash(codeHash); ok { return cv, true, nil } } @@ -1437,7 +1471,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // (flush-invalidated). The zero-hash sentinel means "no code / missing // account" (negative cache). if sd.stateCache != nil { - if h, ok := sd.stateCache.GetAddrCodeHash(addr); ok { + if h, ok := sd.cacheReader().GetAddrCodeHash(addr); ok { if h == ([32]byte{}) { return nil } @@ -1450,7 +1484,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // reports whether the record was read from the tx's read view. resolve := func() ([]byte, bool) { if sd.stateCache != nil { - if v, ok := sd.stateCache.Get(kv.AccountsDomain, addr); ok { + if v, ok := sd.cacheReader().Get(kv.AccountsDomain, addr); ok { return accounts.DeserialiseV3CodeHash(v), false } } @@ -1476,9 +1510,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // slipping pre-apply state past the gate. txNum is a conservative upper // bound (>= the resolved account's write txNum), so the mapping drops // on any unwind that reverts that account. - if visibleEnd, ok := sd.domainVisibleEnd(tx, kv.AccountsDomain); ok { - sd.stateCache.PutAddrCodeHashIfFresh(addr, fixed, txNum, visibleEnd) - } + sd.cacheViewFor(tx).SeedAddrCodeHash(addr, fixed, txNum) } return h } diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index 74607252d79..77012f0b033 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -18,4 +18,5 @@ func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // — without mutating the process-global flag (which would race t.Parallel tests). func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { sd.stateCache = sc + sd.cacheApplier = sc.Applier() } diff --git a/db/state/execctx/flush_storage_cache_test.go b/db/state/execctx/flush_storage_cache_test.go index 4df663dfd7a..fd9feb429a0 100644 --- a/db/state/execctx/flush_storage_cache_test.go +++ b/db/state/execctx/flush_storage_cache_test.go @@ -77,14 +77,14 @@ func TestCommit_UpdatesStorageStateCache(t *testing.T) { // First commit: the storage callback must fire and populate the cache. commit(1, val1, nil) - got, ok := sc.Get(kv.StorageDomain, key) + got, ok := sc.View(nil).Get(kv.StorageDomain, key) require.True(t, ok, "storage cache must be populated by the commit callback") require.Equal(t, val1, got) // Overwrite in a second tx: the callback must fire again and refresh the // entry — not leave the stale val1 behind. commit(stepSize+1, val2, val1) - got, ok = sc.Get(kv.StorageDomain, key) + got, ok = sc.View(nil).Get(kv.StorageDomain, key) require.True(t, ok) require.Equal(t, val2, got, "commit must refresh the storage cache; stale value served on hit was the bug") } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 5662dfa7505..59da87b06da 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -18,6 +18,7 @@ package execctx_test import ( "encoding/binary" + "math" "testing" "github.com/c2h5oh/datasize" @@ -75,6 +76,22 @@ func newSmallStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } +func frontierAt(end uint64) cache.Frontier { + return cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) +} + +// seed places an entry with an exact txNum stamp through the public fill API +// without moving the applied frontier. A positive passes admission at any +// applied end; a negative is stamped frontier-1 by the fill path, so it must +// be seeded while the applied end is at most txNum+1. +func seed(sc *cache.StateCache, domain kv.Domain, k, v []byte, txNum uint64) { + end := uint64(math.MaxUint64) + if len(v) == 0 { + end = txNum + 1 + } + sc.View(frontierAt(end)).Fill(domain, k, v, txNum) +} + type visibleEndCountingDebugTx struct { kv.TemporalDebugTx calls uint64 @@ -165,9 +182,10 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { defer sd.Close() sd.SetStateCacheForTest(sc) - // A live cache entry below the unwind floor: the restored (correct) value. - sc.Put(kv.AccountsDomain, key, v1, 5) sd.Unwind(10, &diffs) // in-flight: mem publishes maxStep=1; MDBX still holds the step-1 row + // A live cache entry below the unwind floor: the restored (correct) value, + // as a post-unwind fill would insert it. + seed(sc, kv.AccountsDomain, key, v1, 5) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -222,8 +240,8 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) defer sd2.Close() sd2.SetStateCacheForTest(sc) - sc.Put(kv.AccountsDomain, key, nil, 2) sd2.Unwind(3, &diffs) + seed(sc, kv.AccountsDomain, key, nil, 2) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -263,13 +281,13 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { // A live (current-epoch) entry above the read bound: the maxStep gate turns // the hit into a miss, so the read falls through to the bounded DB read. v3 := encAccount(3) - sc.Put(kv.AccountsDomain, key, v3, 40) + seed(sc, kv.AccountsDomain, key, v3, 40) v, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) require.NoError(t, err) require.Equal(t, v2, v, "fall-through read serves the maxStep-bounded DB row") - got, ok := sc.Get(kv.AccountsDomain, key) + got, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, v3, got, "read-fill must not clobber the live entry") } @@ -315,14 +333,14 @@ func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { v, _, err := sd2.GetLatest(kv.AccountsDomain, roTx, missing) require.NoError(t, err) require.Empty(t, v) - _, ok = sc.Get(kv.AccountsDomain, missing) + _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) require.True(t, ok, "the negative result must be cached") - sc.Unwind(visibleEnd) - _, ok = sc.Get(kv.AccountsDomain, missing) + sc.Applier().Unwind(visibleEnd) + _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) require.True(t, ok, "an unwind starting after the read view must preserve the negative") - sc.Unwind(visibleEnd - 1) - _, ok = sc.Get(kv.AccountsDomain, missing) + sc.Applier().Unwind(visibleEnd - 1) + _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) require.False(t, ok, "an unwind of the view's last included txNum must invalidate the negative") } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index a746bd154af..26c8a8232f0 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -111,7 +111,7 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { require.NoError(t, err) require.Equal(t, code, got) - cached, ok := stateCache.Get(kv.CodeDomain, contractAddr) + cached, ok := stateCache.View(nil).Get(kv.CodeDomain, contractAddr) require.True(t, ok, "an account-only deletion must not block unrelated code fills") require.Equal(t, code, cached) } @@ -160,16 +160,16 @@ func TestSharedDomainsNegativeCacheEntryUsesLastVisibleTxNum(t *testing.T) { require.NoError(t, err) require.Empty(t, got) - cached, ok := stateCache.Get(kv.AccountsDomain, missingKey) + cached, ok := stateCache.View(nil).Get(kv.AccountsDomain, missingKey) require.True(t, ok) require.Empty(t, cached) - stateCache.Unwind(visibleEnd) - _, ok = stateCache.Get(kv.AccountsDomain, missingKey) + stateCache.Applier().Unwind(visibleEnd) + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, missingKey) require.True(t, ok, "a negative observed before the unwind floor must remain cached") - stateCache.Unwind(visibleEnd - 1) - _, ok = stateCache.Get(kv.AccountsDomain, missingKey) + stateCache.Applier().Unwind(visibleEnd - 1) + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, missingKey) require.False(t, ok, "a negative observed at the unwind floor must be invalidated") } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index a0ba69650d0..3ef6952855d 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -424,22 +424,22 @@ func TestStateCache_NewStateCache(t *testing.T) { require.NotNil(t, c) // Account, Storage, Code, Commitment should be initialized - assert.NotNil(t, c.GetCache(kv.AccountsDomain)) - assert.NotNil(t, c.GetCache(kv.StorageDomain)) - assert.NotNil(t, c.GetCache(kv.CodeDomain)) + assert.NotNil(t, c.getCache(kv.AccountsDomain)) + assert.NotNil(t, c.getCache(kv.StorageDomain)) + assert.NotNil(t, c.getCache(kv.CodeDomain)) // Other domains should be nil - assert.Nil(t, c.GetCache(kv.ReceiptDomain)) - assert.Nil(t, c.GetCache(kv.RCacheDomain)) + assert.Nil(t, c.getCache(kv.ReceiptDomain)) + assert.Nil(t, c.getCache(kv.RCacheDomain)) } func TestStateCache_NewDefaultStateCache(t *testing.T) { c := closeOnCleanup(t, NewDefaultStateCache()) require.NotNil(t, c) - assert.NotNil(t, c.GetCache(kv.AccountsDomain)) - assert.NotNil(t, c.GetCache(kv.StorageDomain)) - assert.NotNil(t, c.GetCache(kv.CodeDomain)) + assert.NotNil(t, c.getCache(kv.AccountsDomain)) + assert.NotNil(t, c.getCache(kv.StorageDomain)) + assert.NotNil(t, c.getCache(kv.CodeDomain)) } func TestStateCache_GetPut_Account(t *testing.T) { @@ -449,13 +449,13 @@ func TestStateCache_GetPut_Account(t *testing.T) { value := makeValue(1) // Get non-existent - v, ok := c.Get(kv.AccountsDomain, addr) + v, ok := c.get(kv.AccountsDomain, addr) assert.False(t, ok) assert.Nil(t, v) // Put and Get - c.Put(kv.AccountsDomain, addr, value, 0) - v, ok = c.Get(kv.AccountsDomain, addr) + c.put(kv.AccountsDomain, addr, value, 0) + v, ok = c.get(kv.AccountsDomain, addr) assert.True(t, ok) assert.Equal(t, value, v) } @@ -468,8 +468,8 @@ func TestStateCache_GetPut_Storage(t *testing.T) { key[51] = 1 value := makeValue(1) - c.Put(kv.StorageDomain, key, value, 0) - v, ok := c.Get(kv.StorageDomain, key) + c.put(kv.StorageDomain, key, value, 0) + v, ok := c.get(kv.StorageDomain, key) assert.True(t, ok) assert.Equal(t, value, v) } @@ -480,8 +480,8 @@ func TestStateCache_GetPut_Code(t *testing.T) { addr := makeAddr(1) code := makeCode(1) - c.Put(kv.CodeDomain, addr, code, 0) - v, ok := c.Get(kv.CodeDomain, addr) + c.put(kv.CodeDomain, addr, code, 0) + v, ok := c.get(kv.CodeDomain, addr) assert.True(t, ok) assert.Equal(t, code, v) } @@ -490,8 +490,8 @@ func TestStateCache_GetPut_UnsupportedDomain(t *testing.T) { c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) // ReceiptDomain is not supported - c.Put(kv.ReceiptDomain, makeAddr(1), makeValue(1), 0) - v, ok := c.Get(kv.ReceiptDomain, makeAddr(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) } @@ -500,10 +500,10 @@ func TestStateCache_Delete(t *testing.T) { c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) addr := makeAddr(1) - c.Put(kv.AccountsDomain, addr, makeValue(1), 0) - c.Delete(kv.AccountsDomain, addr) + c.put(kv.AccountsDomain, addr, makeValue(1), 0) + c.deleteKey(kv.AccountsDomain, addr) - _, ok := c.Get(kv.AccountsDomain, addr) + _, ok := c.get(kv.AccountsDomain, addr) assert.False(t, ok) } @@ -517,9 +517,9 @@ func TestStateCache_PutEmpty_ThenGet_IsCacheHit(t *testing.T) { key[0] = 0x1d key[51] = 0xa2 - c.Put(kv.StorageDomain, key, nil, 0) + c.put(kv.StorageDomain, key, nil, 0) - v, ok := c.Get(kv.StorageDomain, key) + v, ok := c.get(kv.StorageDomain, key) assert.True(t, ok, "Get after Put(nil) must be a cache hit, not a miss") assert.Empty(t, v, "cached value for a deleted key must be empty") } @@ -532,9 +532,9 @@ func TestStateCache_PutEmptySlice_ThenGet_IsCacheHit(t *testing.T) { key[0] = 0x1d key[51] = 0xa2 - c.Put(kv.StorageDomain, key, []byte{}, 0) + c.put(kv.StorageDomain, key, []byte{}, 0) - v, ok := c.Get(kv.StorageDomain, key) + v, ok := c.get(kv.StorageDomain, key) assert.True(t, ok, "Get after Put([]byte{}) must be a cache hit") assert.Empty(t, v) } @@ -543,21 +543,21 @@ func TestStateCache_Delete_UnsupportedDomain(t *testing.T) { c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) // Should not panic - c.Delete(kv.ReceiptDomain, makeAddr(1)) + c.deleteKey(kv.ReceiptDomain, makeAddr(1)) } func TestStateCache_Clear(t *testing.T) { c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) - 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.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() + c.clear() - _, ok1 := c.Get(kv.AccountsDomain, makeAddr(1)) - _, ok2 := c.Get(kv.StorageDomain, makeAddr(2)) - _, ok3 := c.Get(kv.CodeDomain, makeAddr(3)) + _, ok1 := c.get(kv.AccountsDomain, makeAddr(1)) + _, ok2 := c.get(kv.StorageDomain, makeAddr(2)) + _, ok3 := c.get(kv.CodeDomain, makeAddr(3)) assert.False(t, ok1) assert.False(t, ok2) @@ -568,10 +568,10 @@ func TestStateCache_GetCache_OutOfBounds(t *testing.T) { c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) // Domain >= DomainLen should return nil - cache := c.GetCache(kv.DomainLen) + cache := c.getCache(kv.DomainLen) assert.Nil(t, cache) - cache = c.GetCache(kv.Domain(100)) + cache = c.getCache(kv.Domain(100)) assert.Nil(t, cache) } @@ -641,13 +641,13 @@ func TestStateCache_DomainIsolation(t *testing.T) { storageData := []byte("storage") codeData := []byte{0x60, 0x00, 0x60, 0x00} // valid code - c.Put(kv.AccountsDomain, addr, accountData, 0) - c.Put(kv.StorageDomain, addr, storageData, 0) - c.Put(kv.CodeDomain, addr, codeData, 0) + 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) - v3, ok3 := c.Get(kv.CodeDomain, addr) + v1, ok1 := c.get(kv.AccountsDomain, addr) + v2, ok2 := c.get(kv.StorageDomain, addr) + v3, ok3 := c.get(kv.CodeDomain, addr) assert.True(t, ok1) assert.True(t, ok2) @@ -896,15 +896,15 @@ func TestStateCache_AppliedEndLifecycle(t *testing.T) { t.Cleanup(sc.Close) require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) - sc.Apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 20) - sc.Apply(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) + sc.apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 20) + sc.apply(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) require.Equal(t, uint64(21), sc.appliedEnd[kv.AccountsDomain]) require.Zero(t, sc.appliedEnd[kv.StorageDomain]) - sc.Unwind(15) + sc.unwind(15) require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain]) - sc.Clear() + sc.clear() require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) } @@ -915,13 +915,13 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { key := makeAddr(1) stale := makeValue(1) - sc.Apply(kv.AccountsDomain, key, stale, 10) - sc.Apply(kv.AccountsDomain, key, nil, 20) - _, ok := sc.Get(kv.AccountsDomain, key) + sc.apply(kv.AccountsDomain, key, stale, 10) + sc.apply(kv.AccountsDomain, key, nil, 20) + _, ok := sc.get(kv.AccountsDomain, key) require.False(t, ok, "an authoritative deletion must physically remove the entry") - sc.FillIfFresh(kv.AccountsDomain, key, stale, 10, 11) - _, ok = sc.Get(kv.AccountsDomain, key) + sc.fillIfFresh(kv.AccountsDomain, key, stale, 10, 11) + _, ok = sc.get(kv.AccountsDomain, key) require.False(t, ok, "a view older than the deletion must not fill afterward") } @@ -932,15 +932,15 @@ func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { key := makeAddr(1) stale := makeValue(1) - sc.Apply(kv.AccountsDomain, key, nil, 100) + sc.apply(kv.AccountsDomain, key, nil, 100) - sc.FillIfFresh(kv.AccountsDomain, key, stale, 99, 100) - _, ok := sc.Get(kv.AccountsDomain, key) + sc.fillIfFresh(kv.AccountsDomain, key, stale, 99, 100) + _, ok := sc.get(kv.AccountsDomain, key) require.False(t, ok, "a [0,100) view does not contain the applied tx 100") fresh := makeValue(2) - sc.FillIfFresh(kv.AccountsDomain, key, fresh, 100, 101) - got, ok := sc.Get(kv.AccountsDomain, key) + sc.fillIfFresh(kv.AccountsDomain, key, fresh, 100, 101) + got, ok := sc.get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, fresh, got) } @@ -956,21 +956,21 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { for round := range 20000 { appliedTxNum := uint64(round*2 + 1) visibleEnd := appliedTxNum + 1 - sc.Apply(kv.AccountsDomain, progressKey, value, appliedTxNum) + sc.apply(kv.AccountsDomain, progressKey, value, appliedTxNum) var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() - sc.Apply(kv.AccountsDomain, key, nil, visibleEnd) + sc.apply(kv.AccountsDomain, key, nil, visibleEnd) }() go func() { defer wg.Done() - sc.FillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, visibleEnd) + sc.fillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, visibleEnd) }() wg.Wait() - _, ok := sc.Get(kv.AccountsDomain, key) + _, ok := sc.get(kv.AccountsDomain, key) require.False(t, ok, "round %d: stale fill survived the authoritative delete", round) } } @@ -983,12 +983,12 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { addr := makeAddr(1) var h [32]byte h[0] = 0xaa - sc.PutAddrCodeHashIfFresh(addr, h, 10, 0) - _, ok := sc.GetAddrCodeHash(addr) + sc.seedAddrCodeHash(addr, h, 10, 0) + _, ok := sc.getAddrCodeHash(addr) require.True(t, ok) - sc.Apply(kv.CodeDomain, addr, nil, 20) - _, ok = sc.GetAddrCodeHash(addr) + sc.apply(kv.CodeDomain, addr, nil, 20) + _, ok = sc.getAddrCodeHash(addr) require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } @@ -1000,12 +1000,12 @@ func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { addr := makeAddr(1) code := makeCode(1) - sc.Apply(kv.CodeDomain, addr, code, 10) - _, ok := sc.Get(kv.CodeDomain, addr) + sc.apply(kv.CodeDomain, addr, code, 10) + _, ok := sc.get(kv.CodeDomain, addr) require.True(t, ok) - sc.Apply(kv.AccountsDomain, addr, nil, 20) - _, ok = sc.Get(kv.CodeDomain, addr) + sc.apply(kv.AccountsDomain, addr, nil, 20) + _, ok = sc.get(kv.CodeDomain, addr) require.False(t, ok, "an account deletion must drop the addr→code binding") } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index f109d4ff3a4..f5a9a237d18 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -50,6 +50,10 @@ const ( // Uses an array indexed by kv.Domain. Only Account, Storage, and Code domains // are supported; other indices are nil. // +// StateCache itself exposes no data methods: reads and admission-gated fills +// go through a ReadView bound to one tx's read view, committed updates through +// the Applier handle (see view.go). +// // Account and Storage use GenericCache. // Code uses CodeCache (two-level for deduplication). type StateCache struct { @@ -113,10 +117,10 @@ func NewDefaultStateCache() *StateCache { ) } -// Get retrieves data for the given domain and key. +// get retrieves data for the given domain and key. // Returns (value, true) on cache hit — including (nil, true) for cached negatives — // and (nil, false) on cache miss. -func (c *StateCache) Get(domain kv.Domain, key []byte) ([]byte, bool) { +func (c *StateCache) get(domain kv.Domain, key []byte) ([]byte, bool) { cache := c.caches[domain] if cache == nil { return nil, false @@ -124,9 +128,9 @@ func (c *StateCache) Get(domain kv.Domain, key []byte) ([]byte, bool) { return cache.Get(key) } -// GetWithTxNum is Get plus the txNum the cached value reflects, so the read +// getWithTxNum is get plus the txNum the cached value reflects, so the read // path can bound a hit by step against an in-flight unwind's maxStep. -func (c *StateCache) GetWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, bool) { +func (c *StateCache) getWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, bool) { cache := c.caches[domain] if cache == nil { return nil, 0, false @@ -134,7 +138,7 @@ func (c *StateCache) GetWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, return cache.GetWithTxNum(key) } -// GetCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), +// 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). // @@ -142,7 +146,7 @@ func (c *StateCache) GetWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, // for EXTCODESIZE / EXTCODEHASH / CALL targets. Lets many-addrs-one-code // patterns (proxies, factory clones, ERC-20 holders) share a single codeHashToCode // entry. -func (c *StateCache) GetCodeByHash(codeHash []byte) ([]byte, bool) { +func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return nil, false @@ -150,11 +154,9 @@ func (c *StateCache) GetCodeByHash(codeHash []byte) ([]byte, bool) { return cc.GetByCodeHash(codeHash) } -// PutCodeWithHash stores code populating both the addr-keyed path and the -// codeHash-keyed codeHashToCode layer. Callers should prefer this over Put when they -// have the codeHash from the account record — avoids a redundant keccak. Like Put, -// it bypasses fill admission. -func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) { +// putCodeWithHash stores code populating both the addr-keyed path and the +// codeHash-keyed codeHashToCode layer, bypassing fill admission. +func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return @@ -162,10 +164,10 @@ func (c *StateCache) PutCodeWithHash(addr, code, codeHash []byte, txNum uint64) cc.PutWithCodeHash(addr, bytes.Clone(code), codeHash, txNum) } -// GetCodeSizeByHash returns the size of code by its Ethereum codeHash +// 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(codeHash []byte) (int, bool) { +func (c *StateCache) getCodeSizeByHash(codeHash []byte) (int, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return 0, false @@ -173,10 +175,10 @@ func (c *StateCache) GetCodeSizeByHash(codeHash []byte) (int, bool) { return cc.GetCodeSizeByCodeHash(codeHash) } -// PutCodeSizeByHash records the code size for a given codeHash. Useful when +// putCodeSizeByHash records the code size for a given codeHash. 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(codeHash []byte, size int, txNum uint64) { +func (c *StateCache) putCodeSizeByHash(codeHash []byte, size int, txNum uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return @@ -184,9 +186,9 @@ func (c *StateCache) PutCodeSizeByHash(codeHash []byte, size int, txNum uint64) cc.PutCodeSizeByCodeHash(codeHash, size, txNum) } -// GetAddrCodeHash returns the Ethereum codeHash for addr without an +// getAddrCodeHash returns the Ethereum codeHash for addr without an // account-domain round-trip. The hash is zero when ok is false. -func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { +func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return [32]byte{}, false @@ -194,10 +196,10 @@ func (c *StateCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return cc.GetAddrCodeHash(addr) } -// PutAddrCodeHashIfFresh conditionally records an addr → codeHash mapping. +// seedAddrCodeHash conditionally records an addr → codeHash mapping. // The mapping derives from an account record, so admission checks the accounts // frontier even though the mapping lives in the code cache. -func (c *StateCache) PutAddrCodeHashIfFresh(addr []byte, h [32]byte, txNum, visibleEnd uint64) { +func (c *StateCache) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return @@ -218,10 +220,11 @@ func (c *StateCache) deleteAddrCodeHash(addr []byte) { cc.DeleteAddrCodeHash(addr) } -// Put stores data for the given domain and key, stamped with the txNum the +// put stores data for the given domain and key, stamped with the txNum the // value reflects (for txNum/epoch unwind invalidation). It bypasses fill -// admission: committed updates go through Apply, read fills through FillIfFresh. -func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint64) { +// admission: committed updates go through Applier.Apply, read fills through +// ReadView.Fill. +func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64) { cache := c.caches[domain] if cache == nil { return @@ -229,9 +232,9 @@ func (c *StateCache) Put(domain kv.Domain, key []byte, value []byte, txNum uint6 cache.Put(key, bytes.Clone(value), txNum) } -// FillIfFresh conditionally inserts a value read from a read view without +// fillIfFresh conditionally inserts a value read from a read view without // replacing an authoritative entry. Negatives use the view's last included txNum. -func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd uint64) { +func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd uint64) { cache := c.caches[domain] if cache == nil || (domain == kv.CodeDomain && len(value) == 0) { return @@ -263,9 +266,9 @@ func (c *StateCache) FillIfFresh(domain kv.Domain, key []byte, value []byte, rea cache.PutIfAbsent(key, bytes.Clone(value), readTxNum) } -// Delete removes the data for the given domain and key. Authoritative -// deletions go through Apply, which also advances the fill-admission frontier. -func (c *StateCache) Delete(domain kv.Domain, key []byte) { +// deleteKey removes the data for the given domain and key. Authoritative +// deletions go through apply, which also advances the fill-admission frontier. +func (c *StateCache) deleteKey(domain kv.Domain, key []byte) { cache := c.caches[domain] if cache == nil { return @@ -273,8 +276,8 @@ func (c *StateCache) Delete(domain kv.Domain, key []byte) { cache.Delete(key) } -// Apply makes a committed domain update authoritative for subsequent fills. -func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { +// apply makes a committed domain update authoritative for subsequent fills. +func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { var codeHash []byte if domain == kv.CodeDomain && len(value) > 0 { // Clone before hashing so the stored bytes and their codeHash cannot @@ -296,7 +299,7 @@ func (c *StateCache) Apply(domain kv.Domain, key, value []byte, txNum uint64) { putOrDelete(cache, key, value, txNum) c.deleteAddrCodeHash(key) if len(value) == 0 { - c.Delete(kv.CodeDomain, key) + c.deleteKey(kv.CodeDomain, key) } case kv.CodeDomain: if len(value) == 0 { @@ -328,8 +331,8 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { } } -// Clear removes all mutable entries from all caches. -func (c *StateCache) Clear() { +// clear removes all mutable entries from all caches. +func (c *StateCache) clear() { c.admissionMu.Lock() defer c.admissionMu.Unlock() for _, cache := range c.caches { @@ -350,12 +353,12 @@ func (c *StateCache) Close() { } } -// Unwind invalidates, across all caches, entries reflecting state above +// unwind invalidates, across all caches, entries reflecting state above // unwindToTxNum on a now-dead fork. Diffset-free and O(1): every cache (the // GenericCaches and the CodeCache, all layers) bumps an epoch + lowers a floor // and drops stale entries lazily on read. This is the sole cache-invalidation // path on unwind — the executor never touches the cache during forward execution. -func (c *StateCache) Unwind(unwindToTxNum uint64) { +func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() for _, cache := range c.caches { @@ -368,9 +371,12 @@ func (c *StateCache) Unwind(unwindToTxNum uint64) { } } -// GetCache returns the cache for the given domain. -// Returns nil if the domain is not supported. -func (c *StateCache) GetCache(domain kv.Domain) Cache { +// Caches reports whether the given domain has a cache attached. +func (c *StateCache) Caches(domain kv.Domain) bool { + return domain < kv.DomainLen && c.caches[domain] != nil +} + +func (c *StateCache) getCache(domain kv.Domain) Cache { if domain >= kv.DomainLen { return nil } diff --git a/execution/cache/view.go b/execution/cache/view.go new file mode 100644 index 00000000000..a850d836812 --- /dev/null +++ b/execution/cache/view.go @@ -0,0 +1,177 @@ +// 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 ( + "github.com/erigontech/erigon/db/kv" +) + +// Frontier reports the exclusive txNum bound of one transaction's read view +// per domain. ok=false means the backend has no exact frontier for the domain +// (remote, history-disabled); fills sourced from such a view are skipped. +type Frontier interface { + DomainVisibleEnd(domain kv.Domain) (visibleEnd uint64, ok bool) +} + +// FrontierFunc adapts a function to the Frontier interface. +type FrontierFunc func(domain kv.Domain) (visibleEnd uint64, ok bool) + +func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return f(domain) } + +// ReadView is the read-and-fill handle of a StateCache, bound to one +// transaction's read view: values filled through it are vouched for by that +// view's frontier alone, and it must not outlive the transaction. A nil +// frontier gives a read-only view (fills become no-ops). The zero value is +// inert: reads miss, fills no-op. +// +// A ReadView does not isolate reads: the cache holds latest-applied state, so +// a hit can be newer than the view — the same direction the exec overlay +// already serves. The cache's invariant is monotonicity (content never +// regresses behind the applied frontier), enforced on the fill side. +// Snapshot-isolated caching is kvcache's job (node/shards). +type ReadView struct { + c *StateCache + frontier Frontier +} + +// View creates a ReadView vouched for by f. Pass nil for a read-only view. +func (c *StateCache) View(f Frontier) ReadView { return ReadView{c: c, frontier: f} } + +// Get retrieves data for the given domain and key. +// Returns (value, true) on cache hit — including (nil, true) for cached negatives — +// and (nil, false) on cache miss. +func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { + if v.c == nil { + return nil, false + } + return v.c.get(domain, key) +} + +// GetWithTxNum is Get plus the txNum the cached value reflects, so the read +// path can bound a hit by step against an in-flight unwind's maxStep. +func (v ReadView) GetWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, bool) { + if v.c == nil { + return nil, 0, false + } + return v.c.getWithTxNum(domain, key) +} + +// GetCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), +// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss. +func (v ReadView) GetCodeByHash(codeHash []byte) ([]byte, bool) { + if v.c == nil { + return nil, false + } + return v.c.getCodeByHash(codeHash) +} + +// GetCodeSizeByHash returns the cached code length for codeHash. +func (v ReadView) GetCodeSizeByHash(codeHash []byte) (int, bool) { + if v.c == nil { + return 0, false + } + return v.c.getCodeSizeByHash(codeHash) +} + +// GetAddrCodeHash returns the Ethereum codeHash for addr without an +// account-domain round-trip. The hash is zero when ok is false. +func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { + if v.c == nil { + return [32]byte{}, false + } + return v.c.getAddrCodeHash(addr) +} + +// CanFill reports whether this view carries a frontier, i.e. Fill and +// SeedAddrCodeHash can admit values through it. +func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } + +// Fill offers a value read from this view without replacing an authoritative +// entry. Admission is checked against the view's frontier for the domain; +// views without an exact frontier skip the fill. +func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uint64) { + if v.c == nil || v.frontier == nil { + return + } + visibleEnd, ok := v.frontier.DomainVisibleEnd(domain) + if !ok { + return + } + v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd) +} + +// SeedAddrCodeHash offers an addr → codeHash mapping derived from an account +// record read from this view, so admission checks the accounts frontier even +// though the mapping lives in the code cache. +func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { + if v.c == nil || v.frontier == nil { + return + } + visibleEnd, ok := v.frontier.DomainVisibleEnd(kv.AccountsDomain) + if !ok { + return + } + v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd) +} + +// FillCodeSize records the code length for codeHash. Content-addressed and +// immutable for a given hash, so it needs no admission and works on a +// read-only view. +func (v ReadView) FillCodeSize(codeHash []byte, size int, txNum uint64) { + if v.c == nil { + return + } + v.c.putCodeSizeByHash(codeHash, size, txNum) +} + +// Applier is the authoritative writer handle of a StateCache: committed +// updates, unwinds and clears. Exactly one holder is expected — the +// SharedDomains flush/unwind path. The zero value is a no-op. +type Applier struct { + c *StateCache +} + +// Applier creates the writer handle. +func (c *StateCache) Applier() Applier { return Applier{c: c} } + +// Apply makes a committed domain update authoritative for subsequent fills: +// it advances the domain's applied frontier and mutates the cache in the same +// critical section, so a fill from an older read view can never land on top. +func (a Applier) Apply(domain kv.Domain, key, value []byte, txNum uint64) { + if a.c == nil { + return + } + a.c.apply(domain, key, value, txNum) +} + +// Unwind invalidates, across all caches, entries reflecting state above +// unwindToTxNum on a now-dead fork, and lowers the applied frontiers. +func (a Applier) Unwind(unwindToTxNum uint64) { + if a.c == nil { + return + } + a.c.unwind(unwindToTxNum) +} + +// Clear removes all mutable entries from all caches and resets the applied +// frontiers. +func (a Applier) Clear() { + if a.c == nil { + return + } + a.c.clear() +} diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index fed334f0abb..87c44d3664b 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -72,18 +72,17 @@ 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. +// cachePopulatingGetter wraps a kv.TemporalGetter and fills a StateCache +// ReadView 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. // // Code reads also populate the content-addressed and size-cache layers. type cachePopulatingGetter struct { kv.TemporalGetter - sc *cache.StateCache - stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) - visibleEnd func(kv.Domain) (uint64, bool) + view cache.ReadView + stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) } func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { @@ -91,16 +90,14 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter return ttx } debug := ttx.Debug() - return &cachePopulatingGetter{TemporalGetter: ttx, sc: sc, stepSize: debug.StepSize(), visibleEnd: debug.DomainVisibleEnd} + return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(debug), stepSize: debug.StepSize()} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { v, step, err := cpg.TemporalGetter.GetLatest(name, k) if err == nil { - if viewEnd, ok := cpg.visibleEnd(name); ok { - readTxNum := (uint64(step)+1)*cpg.stepSize - 1 - cpg.sc.FillIfFresh(name, k, v, readTxNum, viewEnd) - } + readTxNum := (uint64(step)+1)*cpg.stepSize - 1 + cpg.view.Fill(name, k, v, readTxNum) } return v, step, err } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index e13ac7b5505..b5d153feef3 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -49,6 +49,12 @@ func newTestStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } +// seedFill places an entry with an exact txNum stamp through the public fill +// API without moving the applied frontier. +func seedFill(sc *cache.StateCache, domain kv.Domain, k, v []byte, txNum uint64) { + sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return txNum + 1, true })).Fill(domain, k, v, txNum) +} + // A warmup read-through must never replace a fresher entry an authoritative // writer (the FCU flush cache-apply) has already put: the warmup reads a // pre-flush read view, so a laggard Put landing after the flush would pin @@ -59,14 +65,14 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { stale := []byte("account-record-nonce-4") for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - sc.Put(domain, key, fresh, 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + seedFill(sc, domain, key, fresh, 54) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) require.Equal(t, stale, v, "read-through must still return the view's value") - got, ok := sc.Get(domain, key) + got, ok := sc.View(nil).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, fresh, got, "domain %s: warmup must not clobber the fresher entry", domain) } @@ -79,13 +85,13 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { freshCode := []byte{0xaa, 0x01, 0x02, 0x03} staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache() - sc.PutCodeWithHash(addr, freshCode, crypto.Keccak256(freshCode), 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + seedFill(sc, kv.CodeDomain, addr, freshCode, 54) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) - got, ok := sc.Get(kv.CodeDomain, addr) + got, ok := sc.View(nil).Get(kv.CodeDomain, addr) require.True(t, ok) require.Equal(t, freshCode, got, "warmup must not rebind addr to older code") } @@ -98,31 +104,31 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache() - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) - got, ok := sc.Get(domain, key) + got, ok := sc.View(nil).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, val, got, "domain %s", domain) } sc := newTestStateCache() - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) - got, ok := sc.Get(kv.CodeDomain, key) + got, ok := sc.View(nil).Get(kv.CodeDomain, key) require.True(t, ok) require.Equal(t, code, got) - got, ok = sc.GetCodeByHash(crypto.Keccak256(code)) + got, ok = sc.View(nil).GetCodeByHash(crypto.Keccak256(code)) require.True(t, ok) require.Equal(t, code, got) // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache() - cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, visibleEnd: emptyVisibleEnd} + cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - got, ok = sc.Get(kv.AccountsDomain, key) + got, ok = sc.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok) require.Empty(t, got) } @@ -132,20 +138,20 @@ func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{ - TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, - visibleEnd: func(kv.Domain) (uint64, bool) { return visibleEnd, true }, + TemporalGetter: stubTemporalGetter{v: nil}, stepSize: 1_562_500, + view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return visibleEnd, true })), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.Get(kv.AccountsDomain, key) + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Unwind(visibleEnd) - _, ok = sc.Get(kv.AccountsDomain, key) + sc.Applier().Unwind(visibleEnd) + _, ok = sc.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok, "a negative observed before the unwind floor must remain cached") - sc.Unwind(visibleEnd - 1) - _, ok = sc.Get(kv.AccountsDomain, key) + sc.Applier().Unwind(visibleEnd - 1) + _, ok = sc.View(nil).Get(kv.AccountsDomain, key) require.False(t, ok, "a negative observed at the unwind floor must be invalidated") } @@ -153,29 +159,28 @@ func TestCachePopulatingGetterUnavailableVisibleEndNeverFills(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() cpg := &cachePopulatingGetter{ - TemporalGetter: stubTemporalGetter{v: nil}, sc: sc, stepSize: 1_562_500, - visibleEnd: func(kv.Domain) (uint64, bool) { return 0, false }, + TemporalGetter: stubTemporalGetter{v: nil}, stepSize: 1_562_500, + view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 0, false })), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.Get(kv.AccountsDomain, key) + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.False(t, ok, "no exact frontier — nothing may be cached") } func TestCachePopulatingGetterStaleViewDoesNotFill(t *testing.T) { key := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") sc := newTestStateCache() - sc.Apply(kv.AccountsDomain, key, nil, 20) + sc.Applier().Apply(kv.AccountsDomain, key, nil, 20) cpg := &cachePopulatingGetter{ TemporalGetter: stubTemporalGetter{v: []byte("pre-delete-record")}, - sc: sc, stepSize: 1_562_500, - visibleEnd: func(kv.Domain) (uint64, bool) { return 11, true }, + view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true })), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.Get(kv.AccountsDomain, key) + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.False(t, ok) } From 6ace1583972e0404cb6d5e87288996dbaf1a8d78 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 13:00:51 +0200 Subject: [PATCH 47/85] execution/cache: package doc with the StateCache contract State what the cache is (latest applied values, monotone behind the applied frontier, not a snapshot), how it is accessed (ReadView per tx read view, Applier for flush/unwind), and where snapshot-isolated caching lives (kvcache). Also replace the last 'snapshot' wording left from the old read-view terminology. --- execution/cache/cache.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index fd59c16f2d5..a90244ebcb8 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -14,6 +14,22 @@ // You should have received a copy of the GNU Lesser General Public License // along with Erigon. If not, see . +// Package cache provides the process-global caches of latest committed state. +// +// StateCache holds the newest applied value per key for the accounts, storage +// and code domains, so repeated GetLatest reads skip the file-accessor/MDBX +// stack. It is not a snapshot and gives readers no isolation: a hit can be +// newer than the reader's tx (snapshot-isolated caching is kvcache's job, +// node/shards). Its invariant is monotonicity: content never regresses behind +// what has been applied. +// +// StateCache itself has no data methods. A ReadView — bound to one tx's read +// view and not outliving it — serves reads and fills (cache writes made on +// behalf of a database reader after a miss); admission compares the view's +// frontier, the highest txNum its tx can see, against the applied end, under +// the same lock applies take. The Applier handle, held by the SharedDomains +// flush/unwind path, performs the authoritative writes: committed updates, +// unwinds, clears. package cache // Cache is the interface for domain caches. @@ -31,7 +47,7 @@ type Cache interface { Put(key []byte, value []byte, txNum uint64) // PutIfAbsent is Put except that a live entry for key is left untouched - // (a stale one is replaced) — for prefetch writers, whose snapshot may + // (a stale one is replaced) — for fill writers, whose read view may // already be superseded by an authoritative Put. PutIfAbsent(key []byte, value []byte, txNum uint64) From cc9620335a6cb0d002f8e11b76e57a38edbd8d0d Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 13:17:34 +0200 Subject: [PATCH 48/85] execution/execmodule, db/state/execctx: build the RPC CacheView getter once per view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CacheView.Get/GetCode constructed a fresh SharedDomains getter on every call — one allocation per read, two now that the getter carries the cache ReadView. Build it once in Cache.View; per-read cost on the embedded-RPC path drops back to zero extra allocations. Also hoist the repeated cacheReader() in GetCodeSize. --- db/state/execctx/domain_shared.go | 7 ++++--- execution/execmodule/exec_module.go | 23 +++++++++++------------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 10581294fdd..51e86ce0e6d 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1363,13 +1363,14 @@ func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64 // the size is in the size cache, return without loading bytes. if sd.stateCache != nil { if codeHash := sd.codeHashForAddr(tx, addr, txNum); len(codeHash) > 0 { - if size, ok := sd.cacheReader().GetCodeSizeByHash(codeHash); ok { + reader := sd.cacheReader() + if size, ok := reader.GetCodeSizeByHash(codeHash); ok { return size, true, nil } - if cv, ok := sd.cacheReader().GetCodeByHash(codeHash); ok { + if cv, ok := reader.GetCodeByHash(codeHash); ok { // txNum is a conservative upper bound: >= the live code's write // txNum, so the size drops on any unwind that drops the code. - sd.cacheReader().FillCodeSize(codeHash, len(cv), txNum) + reader.FillCodeSize(codeHash, len(cv), txNum) return len(cv), true, nil } } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 6bf8ed4f6c7..e4b565377bc 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -128,7 +128,11 @@ func (c *Cache) View(_ context.Context, tx kv.TemporalTx) (kvcache.CacheView, er context = c.publishedSD() } - return &CacheView{context: context, tx: tx}, nil + view := &CacheView{context: context, tx: tx, getter: tx} + if context != nil { + view.getter = context.AsGetter(tx) + } + return view, nil } func (c *Cache) OnNewBlock(sc *remoteproto.StateChangeBatch) {} func (c *Cache) Evict() int { return 0 } @@ -140,26 +144,21 @@ func (c *Cache) ValidateCurrentRoot(_ context.Context, _ kv.TemporalTx) (*kvcach type CacheView struct { context *execctx.SharedDomains tx kv.TemporalTx + // getter is built once per view: it carries the per-tx cache ReadView, so + // per-read getter construction would cost an allocation on every call. + getter kv.TemporalGetter } func (c *CacheView) Get(k []byte) ([]byte, error) { - var getter kv.TemporalGetter = c.tx - if c.context != nil { - getter = c.context.AsGetter(c.tx) - } if len(k) == 20 { - v, _, err := getter.GetLatest(kv.AccountsDomain, k) + v, _, err := c.getter.GetLatest(kv.AccountsDomain, k) return v, err } - v, _, err := getter.GetLatest(kv.StorageDomain, k) + v, _, err := c.getter.GetLatest(kv.StorageDomain, k) return v, err } func (c *CacheView) GetCode(k []byte) ([]byte, error) { - var getter kv.TemporalGetter = c.tx - if c.context != nil { - getter = c.context.AsGetter(c.tx) - } - v, _, err := getter.GetLatest(kv.CodeDomain, k) + v, _, err := c.getter.GetLatest(kv.CodeDomain, k) return v, err } From 30f2ac2346d55f5ec7dd8be114005e9114a183f8 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 13:36:15 +0200 Subject: [PATCH 49/85] execution/cache: STATE_CACHE_FILLS switch for apply-only mode STATE_CACHE_FILLS=false disables the admission-gated read fills, leaving flush applies as the only cache writer. This is the A/B lever for measuring what read fills contribute (hit rate, MGas/s, RPC latency) and an operational kill switch. --- execution/cache/cache_test.go | 27 +++++++++++++++++++++++++++ execution/cache/state_cache.go | 8 ++++++++ execution/cache/view.go | 4 ++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 3ef6952855d..10033768590 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1071,3 +1071,30 @@ func TestDomainCache_ClearAtomicWithPut_NoSizeDrift(t *testing.T) { require.Equal(t, wantSize, c.SizeBytes(), "round %d: size accounting drifted", round) } } + +// STATE_CACHE_FILLS=false turns off the admission-gated read fills (apply-only +// mode): the A/B lever for measuring what fills contribute, and the ops kill +// switch. Applies keep working. +func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { + t.Setenv("STATE_CACHE_FILLS", "false") + b := 1 * datasize.MB + c := NewStateCache(b, b, b, b) + defer c.Close() + + key := make([]byte, 20) + key[0] = 0xaa + view := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 100, true })) + + view.Fill(kv.AccountsDomain, key, []byte("value"), 10) + _, ok := c.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "fills must be disabled") + + view.SeedAddrCodeHash(key, [32]byte{1}, 10) + _, ok = c.View(nil).GetAddrCodeHash(key) + require.False(t, ok, "mapping seeds must be disabled") + + c.Applier().Apply(kv.AccountsDomain, key, []byte("applied"), 20) + got, ok := c.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "applies must keep working") + require.Equal(t, []byte("applied"), got) +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index f5a9a237d18..2c3e93ac558 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -62,6 +62,10 @@ type StateCache struct { // against concurrent read-fills, which recheck freshness under RLock. admissionMu sync.RWMutex appliedEnd [kv.DomainLen]uint64 + // disableFills (STATE_CACHE_FILLS=false) turns off the admission-gated + // read fills, leaving applies as the only writer ("apply-only" mode) — + // an A/B lever and an operational kill switch. + disableFills bool } // NewStateCache creates a new StateCache with the specified byte capacities. @@ -71,6 +75,10 @@ type StateCache struct { func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { mode := stateCacheModeFromEnv() sc := &StateCache{} + if !dbg.EnvBool("STATE_CACHE_FILLS", true) { + sc.disableFills = true + log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only flush applies populate the cache") + } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) sc.caches[kv.CodeDomain] = NewCodeCache(codeBytes, addrBytes) diff --git a/execution/cache/view.go b/execution/cache/view.go index a850d836812..0d5237dbe22 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -104,7 +104,7 @@ func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } // entry. Admission is checked against the view's frontier for the domain; // views without an exact frontier skip the fill. func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uint64) { - if v.c == nil || v.frontier == nil { + if v.c == nil || v.c.disableFills || v.frontier == nil { return } visibleEnd, ok := v.frontier.DomainVisibleEnd(domain) @@ -118,7 +118,7 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin // record read from this view, so admission checks the accounts frontier even // though the mapping lives in the code cache. func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { - if v.c == nil || v.frontier == nil { + if v.c == nil || v.c.disableFills || v.frontier == nil { return } visibleEnd, ok := v.frontier.DomainVisibleEnd(kv.AccountsDomain) From d34d775f28c288076cec1b44dd297929ff06b5af Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 14:01:04 +0200 Subject: [PATCH 50/85] execution/cache: keep the admission frontier across Clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear drops cache entries; it does not rewind canonical state. Zeroing appliedEnd let a still-live older ReadView refill pre-apply data into the emptied cache — a deleted value could come back through Clear. Keep the frontiers instead: fresh views sit at or above them, so fills never starve, while pre-apply views stay rejected. Canonical-state rewinds remain Unwind's job, which lowers the frontiers deliberately. --- execution/cache/cache_test.go | 28 +++++++++++++++++++++++++++- execution/cache/state_cache.go | 5 +++-- execution/cache/view.go | 4 ++-- 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 10033768590..75627ce2902 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -905,7 +905,8 @@ func TestStateCache_AppliedEndLifecycle(t *testing.T) { require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain]) sc.clear() - require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) + require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain], + "clear drops entries, not admission history") } func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { @@ -1098,3 +1099,28 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { require.True(t, ok, "applies must keep working") require.Equal(t, []byte("applied"), got) } + +// Clearing entries does not rewind canonical state, so the admission frontier +// must survive Clear: a still-live older ReadView must not refill pre-apply +// data into the emptied cache. +func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + oldView := sc.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true })) + + sc.Applier().Apply(kv.AccountsDomain, key, nil, 20) // canonical delete + sc.Applier().Clear() + + oldView.Fill(kv.AccountsDomain, key, []byte("pre-delete"), 10) + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "a pre-apply view must not resurrect the deleted value through Clear") + + freshView := sc.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 21, true })) + freshView.Fill(kv.AccountsDomain, key, []byte("current"), 20) + got, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "a view at the applied frontier must still fill after Clear") + require.Equal(t, []byte("current"), got) +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 2c3e93ac558..2e9626928cf 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -339,7 +339,9 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { } } -// clear removes all mutable entries from all caches. +// clear removes all mutable entries from all caches. The admission frontier +// survives: clearing drops entries, it does not rewind canonical state, and a +// zeroed frontier would let a still-live older ReadView refill pre-apply data. func (c *StateCache) clear() { c.admissionMu.Lock() defer c.admissionMu.Unlock() @@ -348,7 +350,6 @@ func (c *StateCache) clear() { cache.Clear() } } - c.appliedEnd = [kv.DomainLen]uint64{} } // Close releases every sub-cache's slot in the shared memory envelope so later diff --git a/execution/cache/view.go b/execution/cache/view.go index 0d5237dbe22..1ed8bd646df 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -167,8 +167,8 @@ func (a Applier) Unwind(unwindToTxNum uint64) { a.c.unwind(unwindToTxNum) } -// Clear removes all mutable entries from all caches and resets the applied -// frontiers. +// Clear removes all mutable entries from all caches. The applied frontiers +// survive — clearing is not a canonical-state rewind (that is Unwind). func (a Applier) Clear() { if a.c == nil { return From 91ccbe50c220a2553da1cb199c849d89911067e8 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 14:29:54 +0200 Subject: [PATCH 51/85] execution/cache, execution/execmodule: apply-only switch covers every reader write; doc precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STATE_CACHE_FILLS=false now also disables FillCodeSize — content-addressed entries need no admission, but they are still reader writes, and apply-only mode means readers write nothing. Fix the frontier docs to the exclusive-end definition (a view with frontier N sees txNums < N), stop calling nil-frontier views read-only, correct Applier's single-holder claim, update drainReadAhead's rationale (fill admission now covers the flush direction; only the unwind direction remains), reuse the per-view getter in CacheView.HasStorage, and drop the dead putCodeWithHash. --- db/state/execctx/domain_shared.go | 3 ++- execution/cache/cache.go | 5 +++-- execution/cache/cache_test.go | 5 +++++ execution/cache/state_cache.go | 10 ---------- execution/cache/view.go | 15 ++++++++------- execution/execmodule/exec_module.go | 15 ++++++--------- 6 files changed, 24 insertions(+), 29 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 51e86ce0e6d..ab8fbfeb7b4 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -174,7 +174,8 @@ func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { return sd.stateCache.View(sdFrontier{sd: sd, tx: tx}) } -// cacheReader is a read-only view (no fill rights); safe on a nil cache. +// cacheReader is a frontier-less view: admission-gated fills are disabled, +// content-addressed fills still work. Safe on a nil cache. func (sd *SharedDomains) cacheReader() cache.ReadView { return sd.stateCache.View(nil) } func IsDomainAheadOfBlocks(ctx context.Context, tx kv.TemporalRwTx, logger log.Logger) bool { diff --git a/execution/cache/cache.go b/execution/cache/cache.go index a90244ebcb8..279e76dc5f6 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -26,8 +26,9 @@ // StateCache itself has no data methods. A ReadView — bound to one tx's read // view and not outliving it — serves reads and fills (cache writes made on // behalf of a database reader after a miss); admission compares the view's -// frontier, the highest txNum its tx can see, against the applied end, under -// the same lock applies take. The Applier handle, held by the SharedDomains +// frontier — the exclusive txNum end of what its tx can see, so a view with +// frontier N sees txNums < N — against the applied end, under the same lock +// applies take. The Applier handle, held by the SharedDomains // flush/unwind path, performs the authoritative writes: committed updates, // unwinds, clears. package cache diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 75627ce2902..4e080ec625c 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1094,6 +1094,11 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { _, ok = c.View(nil).GetAddrCodeHash(key) require.False(t, ok, "mapping seeds must be disabled") + codeHash := crypto.Keccak256([]byte{0xaa, 1, 2, 3}) + view.FillCodeSize(codeHash, 4, 10) + _, ok = c.View(nil).GetCodeSizeByHash(codeHash) + require.False(t, ok, "content-addressed fills must be disabled too: the switch means no reader writes at all") + c.Applier().Apply(kv.AccountsDomain, key, []byte("applied"), 20) got, ok := c.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok, "applies must keep working") diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 2e9626928cf..14e48754dec 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -162,16 +162,6 @@ func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { return cc.GetByCodeHash(codeHash) } -// putCodeWithHash stores code populating both the addr-keyed path and the -// codeHash-keyed codeHashToCode layer, bypassing fill admission. -func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64) { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok { - return - } - cc.PutWithCodeHash(addr, bytes.Clone(code), codeHash, txNum) -} - // 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. diff --git a/execution/cache/view.go b/execution/cache/view.go index 1ed8bd646df..022138ad435 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -35,7 +35,8 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // ReadView is the read-and-fill handle of a StateCache, bound to one // transaction's read view: values filled through it are vouched for by that // view's frontier alone, and it must not outlive the transaction. A nil -// frontier gives a read-only view (fills become no-ops). The zero value is +// frontier disables the admission-gated fills (Fill, SeedAddrCodeHash); +// FillCodeSize is content-addressed and works on any view. The zero value is // inert: reads miss, fills no-op. // // A ReadView does not isolate reads: the cache holds latest-applied state, so @@ -48,7 +49,7 @@ type ReadView struct { frontier Frontier } -// View creates a ReadView vouched for by f. Pass nil for a read-only view. +// View creates a ReadView vouched for by f. A nil f disables admission-gated fills. func (c *StateCache) View(f Frontier) ReadView { return ReadView{c: c, frontier: f} } // Get retrieves data for the given domain and key. @@ -129,18 +130,18 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { } // FillCodeSize records the code length for codeHash. Content-addressed and -// immutable for a given hash, so it needs no admission and works on a -// read-only view. +// immutable for a given hash, so it needs no admission and no frontier — but +// it is still a reader write, so the fills switch covers it. func (v ReadView) FillCodeSize(codeHash []byte, size int, txNum uint64) { - if v.c == nil { + if v.c == nil || v.c.disableFills { return } v.c.putCodeSizeByHash(codeHash, size, txNum) } // Applier is the authoritative writer handle of a StateCache: committed -// updates, unwinds and clears. Exactly one holder is expected — the -// SharedDomains flush/unwind path. The zero value is a no-op. +// updates, unwinds and clears. It belongs to the authoritative mutation path +// — the SharedDomains flush/unwind code. The zero value is a no-op. type Applier struct { c *StateCache } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index e4b565377bc..8d14ddb6630 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -173,11 +173,7 @@ func (c *CacheView) GetAsOf(key []byte, ts uint64) (v []byte, ok bool, err error } func (c *CacheView) HasStorage(address common.Address) (bool, error) { - var getter kv.TemporalGetter = c.tx - if c.context != nil { - getter = c.context.AsGetter(c.tx) - } - _, _, hasStorage, err := getter.HasPrefix(kv.StorageDomain, address[:]) + _, _, hasStorage, err := c.getter.HasPrefix(kv.StorageDomain, address[:]) return hasStorage, err } @@ -385,11 +381,12 @@ func (e *ExecModule) canonicalHash(ctx context.Context, tx kv.Tx, blockNumber ui // drainReadAhead blocks until any in-flight block-assembly warmup finishes. // warmBody is fire-and-forget and populates the shared state/branch caches; if -// it is still running when an unwind bumps the cache epoch, it can Put a +// it is still running when an unwind bumps the cache epoch, it can fill a // pre-unwind (dead-fork) value stamped with the post-unwind epoch — IsStale then -// returns false and the stale value is served as canonical (wrong root). A -// laggard Put can likewise land after a flush's cache-apply and pin the -// pre-flush snapshot. Call before any unwind epoch-bump or flush cache-apply. +// returns false and the stale value is served as canonical (wrong root). Fill +// admission does not cover this direction (an unwind lowers the applied +// frontier, so a pre-unwind view passes) — see the tracking issue for +// two-sided admission. Call before any unwind epoch-bump. func (e *ExecModule) drainReadAhead() { if e.readAheader == nil { return From f3d8b0d0365be14eeda042dd0cface8844773c5b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 15:09:21 +0200 Subject: [PATCH 52/85] db/kv/temporal, db/state/execctx: name the visible-end memo bits The packed state word's two halves were addressed with inline shift arithmetic that a reviewer could not parse. Name them: loadedBit says ends[domain] holds a memoized value, okBit is the memoized ok answer of DomainVisibleEnd, and the shared visibleEndBits helper derives both. Also explain why the pair lives in one word (a single atomic load returns a consistent pair) and what the array-size assert checks. --- db/kv/temporal/kv_temporal.go | 29 ++++++++++++++++++----------- db/state/execctx/domain_shared.go | 28 ++++++++++++++++++---------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 34e286f92b7..408222aec9a 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -284,34 +284,41 @@ type domainVisibleEnds struct { state atomic.Uint32 } -// state packs a loaded and an available bit per domain — compile-time capacity check. +// state packs two bits per domain into one word so a single atomic load +// returns a consistent (loaded, ok) pair: loadedBit says ends[domain] is +// memoized, okBit is the memoized ok answer of DomainVisibleEnd. The array +// size asserts at compile time that both halves fit in uint32. var _ [32 - 2*int(kv.DomainLen)]struct{} +func visibleEndBits(domain kv.Domain) (loadedBit, okBit uint32) { + loadedBit = uint32(1) << uint32(domain) + return loadedBit, loadedBit << uint32(kv.DomainLen) +} + func (v *domainVisibleEnds) get(tx *Tx, domain kv.Domain) (uint64, bool) { - bit := uint32(1) << uint32(domain) + loadedBit, okBit := visibleEndBits(domain) state := v.state.Load() - if state&bit != 0 { - return v.ends[domain].Load(), state&(bit< Date: Tue, 4 Aug 2026 15:16:10 +0200 Subject: [PATCH 53/85] execution/execmodule: drain read-ahead only before an actual unwind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updateForkChoice drained in-flight warmup at the top of every FCU — duplicate and non-reorg FCUs included — waiting for the whole warmup on the tip hot path. Fill admission now orders warmup fills against the flush cache-apply, so the forward direction needs no drain; only the unwind epoch bump does (a pre-unwind view passes admission once the applied frontier is lowered). Move the drain into unwindIfNeeded's unwind branch, matching unwindToCommonCanonical and SetHead, which already drain there. No new warmup can start while the FCU holds the semaphore, so the later drain covers the same window. Also correct the drainReadAhead doc: warmup fills only the state cache, not the branch cache. --- execution/execmodule/exec_module.go | 2 +- execution/execmodule/forkchoice.go | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 8d14ddb6630..3f7388965ee 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -380,7 +380,7 @@ func (e *ExecModule) canonicalHash(ctx context.Context, tx kv.Tx, blockNumber ui } // drainReadAhead blocks until any in-flight block-assembly warmup finishes. -// warmBody is fire-and-forget and populates the shared state/branch caches; if +// warmBody is fire-and-forget and fills the shared state cache; if // it is still running when an unwind bumps the cache epoch, it can fill a // pre-unwind (dead-fork) value stamped with the post-unwind epoch — IsStale then // returns false and the stale value is served as canonical (wrong root). Fill diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 0285068d8bc..1b64a91d37d 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -279,6 +279,12 @@ func (e *ExecModule) unwindIfNeeded( Status: ExecutionStatusReorgTooDeep, }, nil } + // Pre-unwind warmup fills would survive the unwind's epoch bump as live + // entries (see drainReadAhead); no new warmup can start while this FCU + // holds the semaphore, so draining here covers the whole unwind. Forward + // FCUs skip the drain: fill admission orders fills against the flush + // cache-apply. + e.drainReadAhead() if err := e.pipelineExecutor.UnwindTo(unwindTarget, stagedsync.ForkChoice, tx); err != nil { return nil, err } @@ -360,12 +366,6 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa }) defer cleanupBeforeSemaRelease() - // Drain any warmup a preceding newPayload spawned: its Puts reflect a - // pre-FCU snapshot and must land before this FCU's unwind epoch-bump and - // flush cache-apply, not after them (no new warmup starts while we hold - // the semaphore). - e.drainReadAhead() - var validationError string // Open a RO tx as the base for all reads. Writes accumulate in the block From 610e3e6e8d90a33c56e519da87377379040a1f73 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 15:21:18 +0200 Subject: [PATCH 54/85] execution/execmodule: keep the FCU-entry drain, correct its comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The drain relocation (unwind-branch only) is a latency optimization, not part of the stale-fill fix — it moves to a stacked PR. Keep the drain at FCU entry here, but fix its comment: the old text claimed the drain protected the flush cache-apply, which fill admission now covers; only the unwind epoch-bump direction still needs it. --- execution/execmodule/forkchoice.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 1b64a91d37d..93b61132df9 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -279,12 +279,6 @@ func (e *ExecModule) unwindIfNeeded( Status: ExecutionStatusReorgTooDeep, }, nil } - // Pre-unwind warmup fills would survive the unwind's epoch bump as live - // entries (see drainReadAhead); no new warmup can start while this FCU - // holds the semaphore, so draining here covers the whole unwind. Forward - // FCUs skip the drain: fill admission orders fills against the flush - // cache-apply. - e.drainReadAhead() if err := e.pipelineExecutor.UnwindTo(unwindTarget, stagedsync.ForkChoice, tx); err != nil { return nil, err } @@ -366,6 +360,11 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa }) defer cleanupBeforeSemaRelease() + // Drain any warmup a preceding newPayload spawned: a fill from a pre-unwind + // view would survive this FCU's possible unwind epoch-bump as a live entry + // (see drainReadAhead). No new warmup starts while we hold the semaphore. + e.drainReadAhead() + var validationError string // Open a RO tx as the base for all reads. Writes accumulate in the block From e50f8019710af7d2e4fa81b4ef5040f1196564a5 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:07:32 +0200 Subject: [PATCH 55/85] execution/cache: gate code fills on the accounts frontier too An addr-keyed code entry derives from the account: an account deletion drops it without advancing the code frontier, so a view predating the deletion passed admission and could refill it. The sequence is reachable through the cache API, though not through SharedDomains, whose DomainDel pairs the account deletion with a code-domain apply that advances the frontier. Check the accounts frontier on code fills (as SeedAddrCodeHash already does), making the invariant caller-independent; advancing the code frontier instead would reject every code fill until the next deploy, since per-domain frontiers move only on that domain's writes. Also pin the SD pairing with an end-to-end account-deletion test, note the pairing in apply's accounts branch, explain why CommitmentDomain gets no cache, and document where code negatives are cached. --- .../statecache_rpc_integration_test.go | 73 +++++++++++++++++++ execution/cache/cache_test.go | 29 ++++++++ execution/cache/state_cache.go | 47 ++++++++---- execution/cache/view.go | 13 +++- 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 26c8a8232f0..0f5c5de4f8b 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -263,3 +263,76 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k require.NoError(t, err) require.Empty(t, got, "the old RPC read view must not repopulate the shared cache after the deletion") } + +// The account-deletion mirror of TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode. +// DomainDel(AccountsDomain) cascades a code-domain delete at the SD layer, so the +// flush applies it and the code frontier advances past every pre-deletion view — +// and the cache-level code-fill admission also checks the accounts frontier. This +// pins both layers: losing either must not let a pre-deletion RPC view refill the +// deleted account's code. +func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + + budget := 1 * datasize.MB + stateCache := cache.NewStateCache(budget, budget, budget, budget) + t.Cleanup(stateCache.Close) + + addr := make([]byte, 20) + addr[0] = 0xab + code := []byte{0xaa, 1, 2, 3} + account := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + Balance: *uint256.NewInt(1), + CodeHash: accounts.InternCodeHash(crypto.Keccak256Hash(code)), + }) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + defer seedDomains.Close() + seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetTxNum(10) + require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, addr, account, 10, nil)) + require.NoError(t, seedDomains.DomainPut(kv.CodeDomain, seedTx, addr, code, 10, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + deleteTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer deleteTx.Rollback() + deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) + require.NoError(t, err) + defer deleteDomains.Close() + deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetTxNum(20) + require.NoError(t, deleteDomains.DomainDel(kv.AccountsDomain, deleteTx, addr, 20, account)) + + events := shards.NewEvents() + events.PublishOverlay(deleteDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) + events.PublishOverlay(nil) + deleteDomains.Close() + + _, ok := stateCache.View(nil).Get(kv.CodeDomain, addr) + require.False(t, ok, "the account deletion must drop the cached code entry") + + got, err := rpcView.GetCode(addr) + require.NoError(t, err) + require.Equal(t, code, got, "the pre-deletion view still reads the code from its own tx") + + _, ok = stateCache.View(nil).Get(kv.CodeDomain, addr) + require.False(t, ok, "a pre-deletion RPC view must not refill the deleted account's code") +} diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 4e080ec625c..7a016c69a18 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1129,3 +1129,32 @@ func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { require.True(t, ok, "a view at the applied frontier must still fill after Clear") require.Equal(t, []byte("current"), got) } + +// An addr-keyed code entry derives from the account: an account deletion drops +// it without advancing the code frontier, so code-fill admission must check the +// accounts frontier too — otherwise a pre-deletion view refills the dead code. +func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { + b := 1 * datasize.MB + c := NewStateCache(b, b, b, b) + t.Cleanup(c.Close) + addr, code := makeAddr(1), makeCode(1) + other, otherCode := makeAddr(2), makeCode(2) + + c.Applier().Apply(kv.CodeDomain, addr, code, 100) + c.Applier().Apply(kv.AccountsDomain, addr, nil, 200) + + stale := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 101, true })) + stale.Fill(kv.CodeDomain, addr, code, 100) + _, ok := c.View(nil).Get(kv.CodeDomain, addr) + require.False(t, ok, "code of a deleted account must not be refillable from a pre-deletion view") + + fresh := c.View(FrontierFunc(func(d kv.Domain) (uint64, bool) { + if d == kv.AccountsDomain { + return 201, true + } + return 101, true + })) + fresh.Fill(kv.CodeDomain, other, otherCode, 100) + _, ok = c.View(nil).Get(kv.CodeDomain, other) + require.True(t, ok, "unrelated code fills from a current view must stay admitted") +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 14e48754dec..0cbafe7dc72 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -82,6 +82,9 @@ func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.Byt sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) sc.caches[kv.CodeDomain] = NewCodeCache(codeBytes, addrBytes) + // CommitmentDomain deliberately gets no cache: commitment data lives in the + // BranchCache, and the nil slot short-circuits every StateCache path for it + // (including writes of commitmentdb.KeyCommitmentState). return sc } @@ -230,31 +233,19 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 cache.Put(key, bytes.Clone(value), txNum) } -// fillIfFresh conditionally inserts a value read from a read view without -// replacing an authoritative entry. Negatives use the view's last included txNum. +// fillIfFresh conditionally inserts an accounts or storage value read from a +// read view without replacing an authoritative entry. Negatives use the view's +// last included txNum. Code goes through fillCodeIfFresh. func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd uint64) { cache := c.caches[domain] - if cache == nil || (domain == kv.CodeDomain && len(value) == 0) { + if cache == nil { return } - - var codeHash []byte - if domain == kv.CodeDomain { - codeHash = crypto.Keccak256(value) - } - c.admissionMu.RLock() defer c.admissionMu.RUnlock() if visibleEnd < c.appliedEnd[domain] { return } - - if domain == kv.CodeDomain { - if codeCache, ok := cache.(*CodeCache); ok { - codeCache.PutWithCodeHashIfAbsent(key, bytes.Clone(value), codeHash, readTxNum) - } - return - } if len(value) == 0 { readTxNum = 0 if visibleEnd > 0 { @@ -264,6 +255,25 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea cache.PutIfAbsent(key, bytes.Clone(value), readTxNum) } +// fillCodeIfFresh is fillIfFresh for the code domain. An addr-keyed code entry +// derives from the account — an account deletion drops it without advancing the +// code frontier — so admission also checks the accounts frontier. Code +// negatives are not cached here: "no code" is cached at the addr→codeHash +// mapping instead (the zero-hash sentinel seeded by SeedAddrCodeHash). +func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibleEnd, accountsVisibleEnd uint64) { + codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) + if !ok || len(value) == 0 { + return + } + codeHash := crypto.Keccak256(value) + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { + return + } + codeCache.PutWithCodeHashIfAbsent(key, bytes.Clone(value), codeHash, readTxNum) +} + // deleteKey removes the data for the given domain and key. Authoritative // deletions go through apply, which also advances the fill-admission frontier. func (c *StateCache) deleteKey(domain kv.Domain, key []byte) { @@ -297,6 +307,11 @@ func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { putOrDelete(cache, key, value, txNum) c.deleteAddrCodeHash(key) if len(value) == 0 { + // SharedDomains pairs an account deletion with a code-domain apply; + // that paired apply is what advances the code frontier — this cascade + // only drops the entry. Code-fill admission also checks the accounts + // frontier (fillCodeIfFresh), so the cache holds even for a caller + // that does not pair the deletes. c.deleteKey(kv.CodeDomain, key) } case kv.CodeDomain: diff --git a/execution/cache/view.go b/execution/cache/view.go index 022138ad435..7cfff345322 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -103,7 +103,10 @@ func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } // Fill offers a value read from this view without replacing an authoritative // entry. Admission is checked against the view's frontier for the domain; -// views without an exact frontier skip the fill. +// views without an exact frontier skip the fill. A code fill also checks the +// accounts frontier: an addr-keyed code entry derives from the account — an +// account deletion drops it without advancing the code frontier — so a view +// that predates the deletion must not refill it (mirrors SeedAddrCodeHash). func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uint64) { if v.c == nil || v.c.disableFills || v.frontier == nil { return @@ -112,6 +115,14 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin if !ok { return } + if domain == kv.CodeDomain { + accountsEnd, ok := v.frontier.DomainVisibleEnd(kv.AccountsDomain) + if !ok { + return + } + v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd) + return + } v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd) } From 706b091faef4079723390c26f49f62bb9c1b05f2 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Tue, 4 Aug 2026 21:11:50 +0700 Subject: [PATCH 56/85] execution/cache: failing test for the unwind fill-readmission window An unwind lowers appliedEnd, which re-admits exactly the views that still hold the discarded fork in their own snapshot: the same fill is rejected before the reorg and accepted after it. Marks the #22463 direction with a red test. --- execution/cache/cache_test.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 7a016c69a18..83a45fa9ff1 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -926,6 +926,30 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { require.False(t, ok, "a view older than the deletion must not fill afterward") } +// An unwind lowers the applied frontier, which re-admits exactly the views that +// still hold the discarded fork in their own snapshot: a fill rejected before +// the reorg is accepted after it, putting the dead fork's value back. +func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + canonical, fork := makeValue(1), makeValue(2) + sc.apply(kv.AccountsDomain, key, canonical, 40) + sc.apply(kv.AccountsDomain, key, fork, 100) + + sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + sc.unwind(50) + _, ok := sc.get(kv.AccountsDomain, key) + require.False(t, ok, "the unwind must evict the fork's value") + + // A view opened before the reorg still carries frontier 101. + sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + _, ok = sc.get(kv.AccountsDomain, key) + require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") +} + func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) From 055812b380aea3e2df46d99b405a72ad1fd35e78 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:14:19 +0200 Subject: [PATCH 57/85] db/kv: document DomainProgress as best-effort, DomainVisibleEnd as exact The two per-domain progress numbers have silently different boundary semantics; the fence steers correctness uses to the exact one. Follow-up convergence of the callers is tracked in #23004. --- db/kv/kv_interface.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 176e8834d48..78c4fdd76ef 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -517,6 +517,10 @@ type TemporalDebugTx interface { // HistoryStartFrom return the earliest known txnum in history of a given domain HistoryStartFrom(domainName Domain) uint64 + // DomainProgress is a best-effort progress number for reporting: it mixes + // an exclusive files end with an inclusive DB txNum (so it is ±1 depending + // on which side wins) and falls back to step granularity when history is + // disabled. For an exact bound use DomainVisibleEnd. DomainProgress(domain Domain) (txNum uint64) // DomainVisibleEnd returns the exact exclusive txNum bound of the tx's // domain read view. ok is false when the backend cannot provide an exact bound. From b059321928933c5d6eb6074be79802903618ab38 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:17:38 +0200 Subject: [PATCH 58/85] Revert "execution/cache: failing test for the unwind fill-readmission window" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relocated, not rejected: the unwind direction is explicitly out of this PR's scope (tracked in #22463), and a red test on the branch keeps CI red. The test moves — authorship preserved — to the stacked #22463 draft PR, where the two-sided-admission fix will turn it green. This reverts commit 706b091fae0da70c3fc9163c9dcfa04e59f43092. --- execution/cache/cache_test.go | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 83a45fa9ff1..7a016c69a18 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -926,30 +926,6 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { require.False(t, ok, "a view older than the deletion must not fill afterward") } -// An unwind lowers the applied frontier, which re-admits exactly the views that -// still hold the discarded fork in their own snapshot: a fill rejected before -// the reorg is accepted after it, putting the dead fork's value back. -func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - - key := makeAddr(1) - canonical, fork := makeValue(1), makeValue(2) - sc.apply(kv.AccountsDomain, key, canonical, 40) - sc.apply(kv.AccountsDomain, key, fork, 100) - - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) - sc.unwind(50) - _, ok := sc.get(kv.AccountsDomain, key) - require.False(t, ok, "the unwind must evict the fork's value") - - // A view opened before the reorg still carries frontier 101. - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) - _, ok = sc.get(kv.AccountsDomain, key) - require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") -} - func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) From a3cc38c1bb8ab318d2d3b454029cd3658eb40794 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:34:26 +0200 Subject: [PATCH 59/85] execution/cache, execution/execmodule, db/state: thread the getter's ReadView through the code read paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetCodeSize, GetCode and codeHashForAddr rebuilt frontier-less views and rebound a frontier on their cold seed path; getter callers already hold a frontier-carrying view, so pass it through — public signatures unchanged, plain callers get a frontier-less view and upgrade only at the seed, like getLatestMetered. Also: drop the unused CacheView.tx field, state at visibleEnd why both the files and the DB component are required (GetLatest reads their union), widen the disableFills comment to every reader fill, and stop claiming CommitmentDomain is initialized in the cache test. --- db/state/execctx/domain_shared.go | 43 +++++++++++++++++++---------- db/state/execctx/export_test.go | 2 +- db/state/inverted_index.go | 4 +++ execution/cache/cache_test.go | 2 +- execution/cache/state_cache.go | 6 ++-- execution/execmodule/exec_module.go | 3 +- 6 files changed, 38 insertions(+), 22 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index ee6cb7d63ef..448b01cdb64 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -555,7 +555,7 @@ func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, // so the existing kv.TemporalGetter interface is unchanged. txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, error) { - return gt.sd.GetCodeSize(gt.tx, addr, txNum) + return gt.sd.getCodeSize(gt.tx, gt.view, addr, txNum) } // GetCode returns contract code via the content-addressed fast path (see @@ -565,7 +565,7 @@ func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, err // (they resolve prevVal through GetLatest, which is addr-keyed). txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCode(addr []byte, txNum uint64) ([]byte, bool, error) { - return gt.sd.GetCode(gt.tx, addr, txNum) + return gt.sd.getCode(gt.tx, gt.view, addr, txNum) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -1364,6 +1364,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // 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, txNum uint64) (int, bool, error) { + return sd.getCodeSize(tx, sd.cacheReader(), addr, txNum) +} + +func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1371,15 +1375,14 @@ func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64 // 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 codeHash := sd.codeHashForAddr(tx, addr, txNum); len(codeHash) > 0 { - reader := sd.cacheReader() - if size, ok := reader.GetCodeSizeByHash(codeHash); ok { + if codeHash := sd.codeHashForAddr(tx, view, addr, txNum); len(codeHash) > 0 { + if size, ok := view.GetCodeSizeByHash(codeHash); ok { return size, true, nil } - if cv, ok := reader.GetCodeByHash(codeHash); ok { + if cv, ok := view.GetCodeByHash(codeHash); ok { // txNum is a conservative upper bound: >= the live code's write // txNum, so the size drops on any unwind that drops the code. - reader.FillCodeSize(codeHash, len(cv), txNum) + view.FillCodeSize(codeHash, len(cv), txNum) return len(cv), true, nil } } @@ -1388,7 +1391,7 @@ func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64 // Cold path: authoritative read via the normal SD.GetLatest chain. // Populates L1, codeHashToCode, and (via PutWithCodeHash) the size layer for // future callers. - v, _, err := sd.GetLatest(kv.CodeDomain, tx, addr) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) if err != nil { return 0, false, err } @@ -1413,6 +1416,10 @@ func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64 // the write. Setters therefore resolve prevVal through GetLatest, which is // addr-keyed (domain-faithful); only getters use this codeHash shortcut. func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([]byte, bool, error) { + return sd.getCode(tx, sd.cacheReader(), addr, txNum) +} + +func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1423,9 +1430,9 @@ func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([ // a stateObject's stale snapshot) is reorg-safe. var codeHash []byte if sd.stateCache != nil || sd.codeStore != nil { - if codeHash = sd.codeHashForAddr(tx, addr, txNum); len(codeHash) > 0 { + if codeHash = sd.codeHashForAddr(tx, view, addr, txNum); len(codeHash) > 0 { if sd.stateCache != nil { - if cv, ok := sd.cacheReader().GetCodeByHash(codeHash); ok { + if cv, ok := view.GetCodeByHash(codeHash); ok { return cv, true, nil } } @@ -1438,7 +1445,7 @@ func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([ } // Cold path: authoritative addr-keyed read (also populates the caches). - v, _, err := sd.GetLatest(kv.CodeDomain, tx, addr) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) if err != nil { return nil, false, err } @@ -1460,7 +1467,7 @@ func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([ // unwind invalidation). It is passed in by the caller — never read from the // shared sd.txNum, which a parallel exec worker on this read path must not // touch (the exec loop advances it concurrently). -func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum uint64) []byte { +func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) []byte { if len(addr) == 0 { return nil } @@ -1481,7 +1488,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // (flush-invalidated). The zero-hash sentinel means "no code / missing // account" (negative cache). if sd.stateCache != nil { - if h, ok := sd.cacheReader().GetAddrCodeHash(addr); ok { + if h, ok := view.GetAddrCodeHash(addr); ok { if h == ([32]byte{}) { return nil } @@ -1494,7 +1501,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // reports whether the record was read from the tx's read view. resolve := func() ([]byte, bool) { if sd.stateCache != nil { - if v, ok := sd.cacheReader().Get(kv.AccountsDomain, addr); ok { + if v, ok := view.Get(kv.AccountsDomain, addr); ok { return accounts.DeserialiseV3CodeHash(v), false } } @@ -1520,7 +1527,13 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // slipping pre-apply state past the gate. txNum is a conservative upper // bound (>= the resolved account's write txNum), so the mapping drops // on any unwind that reverts that account. - sd.cacheViewFor(tx).SeedAddrCodeHash(addr, fixed, txNum) + seedView := view + if !seedView.CanFill() { + // Frontier-less view from the plain wrappers: bind one on this cold + // seed path, where the boxing amortizes against the account read. + seedView = sd.cacheViewFor(tx) + } + seedView.SeedAddrCodeHash(addr, fixed, txNum) } return h } diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index 77012f0b033..868dacd5a98 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -9,7 +9,7 @@ import ( // external test package (which cannot import db/state to build a SharedDomains // internally without an import cycle). func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum uint64) []byte { - return sd.codeHashForAddr(tx, addr, txNum) + return sd.codeHashForAddr(tx, sd.cacheReader(), addr, txNum) } // SetStateCacheForTest attaches a cache unconditionally, bypassing the diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index 89d4aafed8d..194a53d1e2c 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1255,6 +1255,10 @@ func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { return max(iit.files.EndTxNum(), iit.ii.maxTxNumInDB(tx)) } +// visibleEnd is the exclusive txNum bound of what this view can see: the max +// of its two components, because GetLatest reads their union. Both sides are +// required — on a snapshot-synced or fully-pruned datadir the DB side is empty +// and the files carry the whole bound. func (iit *InvertedIndexRoTx) visibleEnd(tx kv.Tx) uint64 { dbEnd, ok := iit.ii.lastTxNumInDB(tx) if ok && dbEnd < math.MaxUint64 { diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 7a016c69a18..273d8c4ef3d 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -423,7 +423,7 @@ func TestStateCache_NewStateCache(t *testing.T) { c := closeOnCleanup(t, NewStateCache(10, 20, 30, 40)) require.NotNil(t, c) - // Account, Storage, Code, Commitment should be initialized + // Account, Storage, Code should be initialized assert.NotNil(t, c.getCache(kv.AccountsDomain)) assert.NotNil(t, c.getCache(kv.StorageDomain)) assert.NotNil(t, c.getCache(kv.CodeDomain)) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 0cbafe7dc72..8d336b5e687 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -62,9 +62,9 @@ type StateCache struct { // against concurrent read-fills, which recheck freshness under RLock. admissionMu sync.RWMutex appliedEnd [kv.DomainLen]uint64 - // disableFills (STATE_CACHE_FILLS=false) turns off the admission-gated - // read fills, leaving applies as the only writer ("apply-only" mode) — - // an A/B lever and an operational kill switch. + // disableFills (STATE_CACHE_FILLS=false) turns off every reader fill + // (including the content-addressed ones), leaving applies as the only + // writer ("apply-only" mode) — an A/B lever and an operational kill switch. disableFills bool } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 3f7388965ee..453754d85c7 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -128,7 +128,7 @@ func (c *Cache) View(_ context.Context, tx kv.TemporalTx) (kvcache.CacheView, er context = c.publishedSD() } - view := &CacheView{context: context, tx: tx, getter: tx} + view := &CacheView{context: context, getter: tx} if context != nil { view.getter = context.AsGetter(tx) } @@ -143,7 +143,6 @@ func (c *Cache) ValidateCurrentRoot(_ context.Context, _ kv.TemporalTx) (*kvcach type CacheView struct { context *execctx.SharedDomains - tx kv.TemporalTx // getter is built once per view: it carries the per-tx cache ReadView, so // per-read getter construction would cost an allocation on every call. getter kv.TemporalGetter From a0bd16225256ea8f91717600923583f876ccb495 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:43:56 +0200 Subject: [PATCH 60/85] execution/cache, db/kv/temporal: state the frontier monotonicity invariant Fill admission is safe against torn memo reads only because a view's frontier never decreases in a process that fills the cache: the DB component is frozen at tx begin, files reopens only extend it, and the visibility-lowering aggregator APIs (Unalign, ReloadFiles, dependency toggles) run only in tooling flows that do not wire a StateCache. State this at the Frontier contract (implementations may understate, never overstate) and at the memo whose lock-free reads rely on it. --- db/kv/temporal/kv_temporal.go | 8 +++++++- execution/cache/view.go | 4 ++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 408222aec9a..639aa6214d4 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -278,7 +278,13 @@ type RwTx struct { type domainVisibleEnds struct { // ends is atomic so a lock-free read can overlap a reset-and-reload of - // the same slot without a data race. + // the same slot without a data race. A torn read (state bit from one + // generation, end from another) can only be stale-low, which merely + // over-rejects fills: a view's frontier never decreases in a process that + // fills the cache — the DB component is frozen at tx begin, and a files + // reopen only extends it, since the visibility-lowering aggregator APIs + // (Unalign, ReloadFiles, dependency toggles) run only in tooling flows + // that do not wire a StateCache. ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/execution/cache/view.go b/execution/cache/view.go index 7cfff345322..f8ce95f750b 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -23,6 +23,10 @@ import ( // Frontier reports the exclusive txNum bound of one transaction's read view // per domain. ok=false means the backend has no exact frontier for the domain // (remote, history-disabled); fills sourced from such a view are skipped. +// +// An implementation may report a stale-low bound — that only over-rejects +// fills — but must never overstate what its tx can currently read: admission +// safety rests on that. type Frontier interface { DomainVisibleEnd(domain kv.Domain) (visibleEnd uint64, ok bool) } From e3279d2a1ddc158c2f5a756442169461604fb032 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 16:57:01 +0200 Subject: [PATCH 61/85] db/state, execution/cache, common/dbg: enforce the no-cache-with-visibility-lowering invariant; meter code reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontier-monotonicity invariant held only by wiring accident: the CustomTrace stage calls Unalign and is kept out of node processes solely by being commented out of default_stages.go. Make it enforceable — NewStateCache marks the process, Unalign/UnalignIdx panic when a cache is wired — so re-enabling the stage fails loudly instead of silently breaking fill admission. Also thread the getter's metrics into the code read paths, which had always passed nil through the GetLatest wrapper. --- common/dbg/experiments.go | 15 +++++++++++++-- db/state/aggregator.go | 11 +++++++++++ db/state/aggregator_align_test.go | 14 ++++++++++++++ db/state/execctx/domain_shared.go | 16 ++++++++-------- execution/cache/cache_test.go | 9 +++++++++ execution/cache/state_cache.go | 3 +++ 6 files changed, 58 insertions(+), 10 deletions(-) diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go index ecf06414c0a..75299cc5c77 100644 --- a/common/dbg/experiments.go +++ b/common/dbg/experiments.go @@ -27,6 +27,7 @@ import ( "runtime/pprof" "strings" "sync" + "sync/atomic" "time" "unique" @@ -180,8 +181,18 @@ func PruneTotalDifficulty() bool { return pruneTotalDifficulty } // CLI-override setters for the performance toggles that also have env-var // twins. The env var sets the initial value at package init; the CLI layer // calls these at node startup only when the user explicitly set the flag. -func SetIgnoreBAL(b bool) { IgnoreBAL = b } -func SetUseStateCache(b bool) { UseStateCache = b } +func SetIgnoreBAL(b bool) { IgnoreBAL = b } +func SetUseStateCache(b bool) { UseStateCache = b } + +// stateCacheWired records that this process constructed a StateCache. The +// aggregator's visibility-lowering APIs assert against it: fill admission +// relies on view frontiers never decreasing, which holds only while no flow +// both fills the cache and lowers visible file ends. +var stateCacheWired atomic.Bool + +func WireStateCache() { stateCacheWired.Store(true) } +func StateCacheWired() bool { return stateCacheWired.Load() } +func SetStateCacheWired(b bool) { stateCacheWired.Store(b) } func SetReadAhead(b bool) { ReadAhead = b } func SetExec3Workers(n int) { Exec3Workers = n } func SetNoPrune(b bool) { noPrune = b } diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 40cab597597..baa4822e5ad 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -523,6 +523,7 @@ func (a *Aggregator) DisableAllDependencies() { // // Accounts, storage and code hold the state a rebuild reads: unaligning one panics. func (a *Aggregator) Unalign(d kv.Domain) (realign func()) { + assertNoStateCache() switch d { case kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain: panic(fmt.Sprintf("assert: %s holds the state a rebuild reads, it can not lag it", d)) @@ -533,6 +534,7 @@ func (a *Aggregator) Unalign(d kv.Domain) (realign func()) { // UnalignIdx is Unalign for a standalone index. func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { + assertNoStateCache() for id, ii := range a.standaloneIIs() { if ii.Name == name { a.setUnalignedIdx(id, true) @@ -542,6 +544,15 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } +// assertNoStateCache: unaligning (and the later realign) lowers visible file +// ends, and StateCache fill admission relies on view frontiers never +// decreasing. Only flows without a wired cache (tooling) may do it. +func assertNoStateCache() { + if dbg.StateCacheWired() { + panic("assert: visibility lowering with a wired StateCache breaks fill-admission monotonicity") + } +} + func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 3df1e407143..c7e1739fb30 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -19,6 +19,8 @@ package state import ( "testing" + "github.com/erigontech/erigon/common/dbg" + "github.com/stretchr/testify/require" "github.com/erigontech/erigon/db/datadir" @@ -159,3 +161,15 @@ func TestUnalign_RejectsStateDomain(t *testing.T) { require.Panics(t, func() { agg.Unalign(d) }, "domain %s", d) } } + +// Fill admission relies on view frontiers never decreasing, which holds only +// while no process both fills a StateCache and lowers visible file ends — +// Unalign must refuse to run beside a wired cache. +func TestUnalign_PanicsWithWiredStateCache(t *testing.T) { + dbg.SetStateCacheWired(true) + t.Cleanup(func() { dbg.SetStateCacheWired(false) }) + + _, agg := testDbAndAggregatorv3(t, alignStepSize) + require.Panics(t, func() { agg.Unalign(kv.ReceiptDomain) }) + require.Panics(t, func() { agg.UnalignIdx(kv.LogAddrIdx) }) +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 448b01cdb64..b776e5850c7 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -555,7 +555,7 @@ func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, // so the existing kv.TemporalGetter interface is unchanged. txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, error) { - return gt.sd.getCodeSize(gt.tx, gt.view, addr, txNum) + return gt.sd.getCodeSize(gt.tx, gt.view, gt.m, addr, txNum) } // GetCode returns contract code via the content-addressed fast path (see @@ -565,7 +565,7 @@ func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, err // (they resolve prevVal through GetLatest, which is addr-keyed). txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCode(addr []byte, txNum uint64) ([]byte, bool, error) { - return gt.sd.getCode(gt.tx, gt.view, addr, txNum) + return gt.sd.getCode(gt.tx, gt.view, gt.m, addr, txNum) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -1364,10 +1364,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // 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, txNum uint64) (int, bool, error) { - return sd.getCodeSize(tx, sd.cacheReader(), addr, txNum) + return sd.getCodeSize(tx, sd.cacheReader(), nil, addr, txNum) } -func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) (int, bool, error) { +func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, wm *kvmetrics.DomainMetrics, addr []byte, txNum uint64) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1391,7 +1391,7 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // Cold path: authoritative read via the normal SD.GetLatest chain. // Populates L1, codeHashToCode, and (via PutWithCodeHash) the size layer for // future callers. - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, wm, view) if err != nil { return 0, false, err } @@ -1416,10 +1416,10 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // the write. Setters therefore resolve prevVal through GetLatest, which is // addr-keyed (domain-faithful); only getters use this codeHash shortcut. func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([]byte, bool, error) { - return sd.getCode(tx, sd.cacheReader(), addr, txNum) + return sd.getCode(tx, sd.cacheReader(), nil, addr, txNum) } -func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) ([]byte, bool, error) { +func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, wm *kvmetrics.DomainMetrics, addr []byte, txNum uint64) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1445,7 +1445,7 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []b } // Cold path: authoritative addr-keyed read (also populates the caches). - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, wm, view) if err != nil { return nil, false, err } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 273d8c4ef3d..a6a1a2679f1 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -19,6 +19,7 @@ package cache import ( "bytes" "encoding/binary" + "github.com/erigontech/erigon/common/dbg" "sync" "testing" @@ -1158,3 +1159,11 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { _, ok = c.View(nil).Get(kv.CodeDomain, other) require.True(t, ok, "unrelated code fills from a current view must stay admitted") } + +func TestNewStateCacheMarksProcessWired(t *testing.T) { + b := 1 * datasize.MB + c := NewStateCache(b, b, b, b) + t.Cleanup(c.Close) + require.True(t, dbg.StateCacheWired(), + "constructing a cache must forbid visibility-lowering aggregator APIs in this process") +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 8d336b5e687..9f397c47b6c 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -74,6 +74,9 @@ type StateCache struct { // is not gated by this knob. func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { mode := stateCacheModeFromEnv() + // Fill admission relies on view frontiers never decreasing; the + // aggregator's visibility-lowering APIs assert against this marker. + dbg.WireStateCache() sc := &StateCache{} if !dbg.EnvBool("STATE_CACHE_FILLS", true) { sc.disableFills = true From f8c965f9c2bb7ab65fb7a52fe5983c7c721816b4 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 17:22:15 +0200 Subject: [PATCH 62/85] db/state, execution/cache, common/dbg: narrow and complete the visibility guard The wired-cache marker was over-broad and under-enforced: it fired for apply-only caches (which have no fills to poison) and it missed the realign closure and the other visibility-lowering entry points. Assert at the un/re-align mutation chokepoint plus EnableAllDependencies and ReloadFiles, skip the marker when STATE_CACHE_FILLS=false, leave Close and OpenFolder unguarded (shutdown and startup are not fill windows), and make the comments name exactly what asserts and why the marker is process-sticky. Also revert the getter-metrics threading from the previous commit: it is unrelated to fill admission and moves to its own stacked PR. --- common/dbg/experiments.go | 10 ++++++---- db/state/aggregator.go | 16 +++++++++++----- db/state/aggregator_align_test.go | 15 +++++++++++++++ db/state/execctx/domain_shared.go | 16 ++++++++-------- execution/cache/cache_test.go | 14 ++++++++++++++ execution/cache/state_cache.go | 9 ++++++--- 6 files changed, 60 insertions(+), 20 deletions(-) diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go index 75299cc5c77..8423a9468ac 100644 --- a/common/dbg/experiments.go +++ b/common/dbg/experiments.go @@ -184,10 +184,12 @@ func PruneTotalDifficulty() bool { return pruneTotalDifficulty } func SetIgnoreBAL(b bool) { IgnoreBAL = b } func SetUseStateCache(b bool) { UseStateCache = b } -// stateCacheWired records that this process constructed a StateCache. The -// aggregator's visibility-lowering APIs assert against it: fill admission -// relies on view frontiers never decreasing, which holds only while no flow -// both fills the cache and lowers visible file ends. +// stateCacheWired records that this process constructed a fill-enabled +// StateCache. The aggregator's visibility-lowering entry points assert +// against it: fill admission relies on view frontiers never decreasing, +// which holds only while no flow both fills a cache and lowers visible file +// ends. Deliberately sticky for the process lifetime — the hazard is about +// mixing flows in one process, not about a particular cache's lifetime. var stateCacheWired atomic.Bool func WireStateCache() { stateCacheWired.Store(true) } diff --git a/db/state/aggregator.go b/db/state/aggregator.go index baa4822e5ad..d689295a14f 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -501,6 +501,7 @@ func (a *Aggregator) EnableAllDependencies() { if a.checker == nil { return } + assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.checker.Enable() @@ -523,7 +524,6 @@ func (a *Aggregator) DisableAllDependencies() { // // Accounts, storage and code hold the state a rebuild reads: unaligning one panics. func (a *Aggregator) Unalign(d kv.Domain) (realign func()) { - assertNoStateCache() switch d { case kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain: panic(fmt.Sprintf("assert: %s holds the state a rebuild reads, it can not lag it", d)) @@ -534,7 +534,6 @@ func (a *Aggregator) Unalign(d kv.Domain) (realign func()) { // UnalignIdx is Unalign for a standalone index. func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { - assertNoStateCache() for id, ii := range a.standaloneIIs() { if ii.Name == name { a.setUnalignedIdx(id, true) @@ -544,9 +543,13 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// assertNoStateCache: unaligning (and the later realign) lowers visible file -// ends, and StateCache fill admission relies on view frontiers never -// decreasing. Only flows without a wired cache (tooling) may do it. +// assertNoStateCache guards the entry points that can lower visible file +// ends — un/re-aligning (both directions of the toggle can shrink someone's +// ceiling), re-enabling the dependency checker, and a folder rescan — because +// StateCache fill admission relies on view frontiers never decreasing. Only +// flows without a fill-enabled cache (tooling) may lower visibility. Close and +// OpenFolder are deliberately unguarded: shutdown and startup are not fill +// windows. func assertNoStateCache() { if dbg.StateCacheWired() { panic("assert: visibility lowering with a wired StateCache breaks fill-admission monotonicity") @@ -554,6 +557,7 @@ func assertNoStateCache() { } func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { + assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.unalignedDomain[d] = v @@ -561,6 +565,7 @@ func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { } func (a *Aggregator) setUnalignedIdx(id int, v bool) { + assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.unalignedIdx[id] = v @@ -667,6 +672,7 @@ func (a *Aggregator) openFolder() error { } func (a *Aggregator) ReloadFiles() error { + assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.closeDirtyFiles() diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index c7e1739fb30..bd14d053585 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -173,3 +173,18 @@ func TestUnalign_PanicsWithWiredStateCache(t *testing.T) { require.Panics(t, func() { agg.Unalign(kv.ReceiptDomain) }) require.Panics(t, func() { agg.UnalignIdx(kv.LogAddrIdx) }) } + +// The recheck must also cover the realign closure and the other +// visibility-lowering entry points: a cache wired between Unalign and realign +// still breaks frontier monotonicity. +func TestVisibilityLowering_RechecksAtEveryEntryPoint(t *testing.T) { + dbg.SetStateCacheWired(false) + _, agg := testDbAndAggregatorv3(t, alignStepSize) + realign := agg.Unalign(kv.ReceiptDomain) // no cache yet: allowed + + dbg.SetStateCacheWired(true) + t.Cleanup(func() { dbg.SetStateCacheWired(false) }) + require.Panics(t, func() { realign() }) + require.Panics(t, func() { agg.EnableAllDependencies() }) + require.Panics(t, func() { _ = agg.ReloadFiles() }) +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b776e5850c7..448b01cdb64 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -555,7 +555,7 @@ func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, // so the existing kv.TemporalGetter interface is unchanged. txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, error) { - return gt.sd.getCodeSize(gt.tx, gt.view, gt.m, addr, txNum) + return gt.sd.getCodeSize(gt.tx, gt.view, addr, txNum) } // GetCode returns contract code via the content-addressed fast path (see @@ -565,7 +565,7 @@ func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, err // (they resolve prevVal through GetLatest, which is addr-keyed). txNum is the // caller's read txNum, used to stamp any cache entry it populates. func (gt *temporalGetter) GetCode(addr []byte, txNum uint64) ([]byte, bool, error) { - return gt.sd.getCode(gt.tx, gt.view, gt.m, addr, txNum) + return gt.sd.getCode(gt.tx, gt.view, addr, txNum) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -1364,10 +1364,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // 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, txNum uint64) (int, bool, error) { - return sd.getCodeSize(tx, sd.cacheReader(), nil, addr, txNum) + return sd.getCodeSize(tx, sd.cacheReader(), addr, txNum) } -func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, wm *kvmetrics.DomainMetrics, addr []byte, txNum uint64) (int, bool, error) { +func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1391,7 +1391,7 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, wm * // Cold path: authoritative read via the normal SD.GetLatest chain. // Populates L1, codeHashToCode, and (via PutWithCodeHash) the size layer for // future callers. - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, wm, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) if err != nil { return 0, false, err } @@ -1416,10 +1416,10 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, wm * // the write. Setters therefore resolve prevVal through GetLatest, which is // addr-keyed (domain-faithful); only getters use this codeHash shortcut. func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([]byte, bool, error) { - return sd.getCode(tx, sd.cacheReader(), nil, addr, txNum) + return sd.getCode(tx, sd.cacheReader(), addr, txNum) } -func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, wm *kvmetrics.DomainMetrics, addr []byte, txNum uint64) ([]byte, bool, error) { +func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1445,7 +1445,7 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, wm *kvme } // Cold path: authoritative addr-keyed read (also populates the caches). - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, wm, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) if err != nil { return nil, false, err } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index a6a1a2679f1..5cf16f677f7 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1167,3 +1167,17 @@ func TestNewStateCacheMarksProcessWired(t *testing.T) { require.True(t, dbg.StateCacheWired(), "constructing a cache must forbid visibility-lowering aggregator APIs in this process") } + +// An apply-only cache (STATE_CACHE_FILLS=false) has no fill for a lowered +// frontier to poison, so it must not forbid visibility lowering. +func TestApplyOnlyCacheDoesNotMarkProcessWired(t *testing.T) { + t.Setenv("STATE_CACHE_FILLS", "false") + was := dbg.StateCacheWired() + dbg.SetStateCacheWired(false) + t.Cleanup(func() { dbg.SetStateCacheWired(was) }) + + b := 1 * datasize.MB + c := NewStateCache(b, b, b, b) + t.Cleanup(c.Close) + require.False(t, dbg.StateCacheWired()) +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 9f397c47b6c..2398adb6bbf 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -74,13 +74,16 @@ type StateCache struct { // is not gated by this knob. func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { mode := stateCacheModeFromEnv() - // Fill admission relies on view frontiers never decreasing; the - // aggregator's visibility-lowering APIs assert against this marker. - dbg.WireStateCache() sc := &StateCache{} if !dbg.EnvBool("STATE_CACHE_FILLS", true) { sc.disableFills = true log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only flush applies populate the cache") + } else { + // Fill admission relies on view frontiers never decreasing; the + // aggregator's visibility-lowering entry points assert against this + // marker. Apply-only caches skip it — with no fills there is nothing + // for a lowered frontier to poison. + dbg.WireStateCache() } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) From d426502bdc67f0e47a1798a76994f4ef98a5a884 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 17:32:36 +0200 Subject: [PATCH 63/85] db/state/execctx: correct the Flush cache contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc claimed callers that flush and commit themselves get a cold-but-correct cache. That holds only for an actually-cold cache: Flush neither applies nor invalidates, so a populated cache keeps serving pre-flush values for the flushed keys — and Commit collects its cache updates only from its own flush, so a plain Flush's keys would never be applied at all. State the real contract: attached-cache callers must route every flush through Commit. All current cache-attached callers already do. --- db/state/execctx/domain_shared.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 448b01cdb64..4683f6193fd 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -968,9 +968,14 @@ func (sd *SharedDomains) Close() { // conservative upper-bound txNum. It is that txNum stamp, not population // timing, that keeps the cache correct: an unwind lowers the floor so every // entry reflecting a now-dead fork is evicted, and mem-first masking means a -// later in-memory write shadows a stale cached read. Callers that flush a tx -// they commit themselves get a cache-safe (cold-but-correct) result; use -// Commit to also keep the cache warm. +// later in-memory write shadows a stale cached read. +// +// An SD with an attached state cache must route every flush through Commit: +// Flush neither applies nor invalidates, so a populated cache would keep +// serving pre-flush values for the flushed keys after the caller's own +// commit — and Commit collects its cache updates only from its own flush, so +// an earlier plain Flush's keys would never be applied. Cache-less callers +// may Flush and commit themselves. func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { defer mxFlushTook.ObserveDuration(time.Now()) return sd.flushMem(ctx, tx) From 2b9c83100034fc3466dc6a47e3381f38a1565b90 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 17:37:12 +0200 Subject: [PATCH 64/85] execution/cache, execution/execmodule, db: comment precision pass Frontier-less (not read-only) view at the cold-fill upgrade; SharedDomains cache-ownership paragraph now names read-ahead as the one direct filler; drainReadAhead states its invariant instead of pointing at a tracking issue; the monotonicity invariant is qualified as forward-direction (unwinds invalidate by epoch and floor); the temporal memo cites the enforced assert instead of inventorying call sites. --- db/kv/temporal/kv_temporal.go | 8 ++++---- db/state/execctx/domain_shared.go | 17 +++++++++-------- execution/cache/cache.go | 5 +++-- execution/cache/view.go | 5 +++-- execution/execmodule/exec_module.go | 5 ++--- 5 files changed, 21 insertions(+), 19 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 639aa6214d4..f8bd6c57bbb 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -281,10 +281,10 @@ type domainVisibleEnds struct { // the same slot without a data race. A torn read (state bit from one // generation, end from another) can only be stale-low, which merely // over-rejects fills: a view's frontier never decreases in a process that - // fills the cache — the DB component is frozen at tx begin, and a files - // reopen only extends it, since the visibility-lowering aggregator APIs - // (Unalign, ReloadFiles, dependency toggles) run only in tooling flows - // that do not wire a StateCache. + // fills a cache — the DB component is frozen at tx begin, and a files + // reopen only extends it, an invariant the aggregator enforces by + // asserting its visibility-lowering entry points against the wired-cache + // marker. ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 4683f6193fd..23079efcfb1 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -954,11 +954,12 @@ func (sd *SharedDomains) Close() { sd.sdCtx = nil } -// The state caches (the account/storage StateCache and the commitment -// BranchCache) are an internal implementation detail of SharedDomains. No -// external entity accesses or mutates them directly — callers drive state -// through Flush / Commit / GetLatest / DomainPut, and the cache lifecycle -// (population, invalidation, commit-gating) is owned entirely here. +// SharedDomains owns the cache lifecycle for the account/storage StateCache +// and the commitment BranchCache: population, invalidation and commit-gating +// all happen here, and callers drive state through Flush / Commit / +// GetLatest / DomainPut. The one exception is read-ahead warmup, which fills +// the StateCache directly through its own ReadView, under the same +// admission. // Flush writes the in-memory batch into tx without committing. It deliberately // does NOT touch the caches: plain Flush leaves the commit to the caller (who @@ -1330,9 +1331,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k readTxNum := (uint64(step)+1)*sd.StepSize() - 1 fillView := view if !fillView.CanFill() { - // Read-only view from the plain GetLatest wrappers: bind a frontier - // here, on the miss path, where the boxing amortizes against the - // backing read it follows. + // Frontier-less view from the plain GetLatest wrappers: bind a + // frontier here, on the miss path, where the boxing amortizes + // against the backing read it follows. fillView = sd.cacheViewFor(tx) } fillView.Fill(domain, k, v, readTxNum) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index 279e76dc5f6..e35e7c03105 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -20,8 +20,9 @@ // and code domains, so repeated GetLatest reads skip the file-accessor/MDBX // stack. It is not a snapshot and gives readers no isolation: a hit can be // newer than the reader's tx (snapshot-isolated caching is kvcache's job, -// node/shards). Its invariant is monotonicity: content never regresses behind -// what has been applied. +// node/shards). In the forward direction its invariant is monotonicity: +// content never regresses behind what has been applied. Unwinds invalidate +// by epoch and floor instead. // // StateCache itself has no data methods. A ReadView — bound to one tx's read // view and not outliving it — serves reads and fills (cache writes made on diff --git a/execution/cache/view.go b/execution/cache/view.go index f8ce95f750b..17524e1bbb3 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -45,8 +45,9 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // // A ReadView does not isolate reads: the cache holds latest-applied state, so // a hit can be newer than the view — the same direction the exec overlay -// already serves. The cache's invariant is monotonicity (content never -// regresses behind the applied frontier), enforced on the fill side. +// already serves. In the forward direction the cache's invariant is +// monotonicity (content never regresses behind the applied frontier), +// enforced on the fill side; unwinds invalidate by epoch and floor. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { c *StateCache diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 453754d85c7..f9ea9166543 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -383,9 +383,8 @@ func (e *ExecModule) canonicalHash(ctx context.Context, tx kv.Tx, blockNumber ui // it is still running when an unwind bumps the cache epoch, it can fill a // pre-unwind (dead-fork) value stamped with the post-unwind epoch — IsStale then // returns false and the stale value is served as canonical (wrong root). Fill -// admission does not cover this direction (an unwind lowers the applied -// frontier, so a pre-unwind view passes) — see the tracking issue for -// two-sided admission. Call before any unwind epoch-bump. +// admission does not cover this direction: an unwind lowers the applied +// frontier, so a pre-unwind view passes. Call before any unwind epoch-bump. func (e *ExecModule) drainReadAhead() { if e.readAheader == nil { return From 07d03c7c958d7b6726857604c4661276df20415f Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 18:11:44 +0200 Subject: [PATCH 65/85] db/state, execution/cache, execution/execmodule, cmd/integration: guard visibility lowering per aggregator, at the recalc chokepoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-global wired-cache marker broke TestCustomTraceReceiptDomain in CI: any test constructing a StateCache forbade Unalign for every aggregator in the binary, and the custom-trace harness legitimately wires an exec module over the same DB it then re-traces. Model the capability where it lives instead: wiring a fill-enabled cache calls Aggregator.ForbidVisibilityLowering, and recalcVisibleFiles panics only on the transition that actually breaks fill admission — a cached state domain's visible end decreasing. Raising visibility (unaligning a lagging entity) stays allowed, every lowering path is covered by construction (un/re-align, dependency re-enable, folder rescan, and any future one), and Close clears the flag since shutdown is not a fill window. --- cmd/integration/commands/stages.go | 3 ++ common/dbg/experiments.go | 12 -------- db/kv/temporal/kv_temporal.go | 5 ++-- db/state/aggregator.go | 45 +++++++++++++++++------------ db/state/aggregator_align_test.go | 39 ++++++++++--------------- execution/cache/cache_test.go | 24 +++++---------- execution/cache/state_cache.go | 13 +++++---- execution/execmodule/exec_module.go | 5 ++++ 8 files changed, 67 insertions(+), 79 deletions(-) diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index a7145dc4268..7706c05d104 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -846,6 +846,9 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn doms.SetInMemHistoryReads(false) doms.SetStateCache(stateCache) doms.SetCodeStore(codeStore) + if stateCache != nil && stateCache.FillsEnabled() { + db.(dbstate.HasAgg).Agg().(*dbstate.Aggregator).ForbidVisibilityLowering() + } s, err := st.StageState(stages.Execution, tx, initialCycle, false) if err != nil { diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go index 8423a9468ac..16bb2cc56ba 100644 --- a/common/dbg/experiments.go +++ b/common/dbg/experiments.go @@ -27,7 +27,6 @@ import ( "runtime/pprof" "strings" "sync" - "sync/atomic" "time" "unique" @@ -184,17 +183,6 @@ func PruneTotalDifficulty() bool { return pruneTotalDifficulty } func SetIgnoreBAL(b bool) { IgnoreBAL = b } func SetUseStateCache(b bool) { UseStateCache = b } -// stateCacheWired records that this process constructed a fill-enabled -// StateCache. The aggregator's visibility-lowering entry points assert -// against it: fill admission relies on view frontiers never decreasing, -// which holds only while no flow both fills a cache and lowers visible file -// ends. Deliberately sticky for the process lifetime — the hazard is about -// mixing flows in one process, not about a particular cache's lifetime. -var stateCacheWired atomic.Bool - -func WireStateCache() { stateCacheWired.Store(true) } -func StateCacheWired() bool { return stateCacheWired.Load() } -func SetStateCacheWired(b bool) { stateCacheWired.Store(b) } func SetReadAhead(b bool) { ReadAhead = b } func SetExec3Workers(n int) { Exec3Workers = n } func SetNoPrune(b bool) { noPrune = b } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index f8bd6c57bbb..6be7861ae8f 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -282,9 +282,8 @@ type domainVisibleEnds struct { // generation, end from another) can only be stale-low, which merely // over-rejects fills: a view's frontier never decreases in a process that // fills a cache — the DB component is frozen at tx begin, and a files - // reopen only extends it, an invariant the aggregator enforces by - // asserting its visibility-lowering entry points against the wired-cache - // marker. + // reopen only extends it, an invariant the aggregator enforces once a + // fill-enabled cache is wired over it (ForbidVisibilityLowering). ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/db/state/aggregator.go b/db/state/aggregator.go index d689295a14f..da7a55591dc 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -88,8 +88,14 @@ type Aggregator struct { oldestVisible *aggregatorVisible // unaligned entities are left out of the shared visible-file ceiling while tooling // regenerates them. Guarded by dirtyFilesLock. - unalignedDomain [kv.DomainLen]bool - unalignedIdx [kv.StandaloneIdxLen]bool + unalignedDomain [kv.DomainLen]bool + unalignedIdx [kv.StandaloneIdxLen]bool + // visibilityLoweringForbidden: a fill-enabled StateCache is wired over + // this aggregator, and its fill admission relies on view frontiers never + // decreasing. recalcVisibleFiles refuses to lower the cached state + // domains' visible ends while set; Close clears it (shutdown is not a + // fill window). + visibilityLoweringForbidden atomic.Bool snapshotBuildSema *semaphore.Weighted disableHistory bool @@ -501,7 +507,6 @@ func (a *Aggregator) EnableAllDependencies() { if a.checker == nil { return } - assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.checker.Enable() @@ -543,21 +548,12 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// assertNoStateCache guards the entry points that can lower visible file -// ends — un/re-aligning (both directions of the toggle can shrink someone's -// ceiling), re-enabling the dependency checker, and a folder rescan — because -// StateCache fill admission relies on view frontiers never decreasing. Only -// flows without a fill-enabled cache (tooling) may lower visibility. Close and -// OpenFolder are deliberately unguarded: shutdown and startup are not fill -// windows. -func assertNoStateCache() { - if dbg.StateCacheWired() { - panic("assert: visibility lowering with a wired StateCache breaks fill-admission monotonicity") - } -} +// ForbidVisibilityLowering marks this aggregator as backing a fill-enabled +// StateCache: from then on recalcVisibleFiles panics instead of lowering a +// cached state domain's visible end, whichever entry point caused it. +func (a *Aggregator) ForbidVisibilityLowering() { a.visibilityLoweringForbidden.Store(true) } func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { - assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.unalignedDomain[d] = v @@ -565,7 +561,6 @@ func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { } func (a *Aggregator) setUnalignedIdx(id int, v bool) { - assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.unalignedIdx[id] = v @@ -672,7 +667,6 @@ func (a *Aggregator) openFolder() error { } func (a *Aggregator) ReloadFiles() error { - assertNoStateCache() a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.closeDirtyFiles() @@ -700,6 +694,7 @@ func (a *Aggregator) WaitForFiles() { } func (a *Aggregator) Close() { + a.visibilityLoweringForbidden.Store(false) // shutdown is not a fill window a.WaitForFiles() if !a.background.BeginClose() { // idempotent: safe to call Close multiple times return @@ -1869,6 +1864,20 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { } next.minimaxTxNum = next.stateMinimaxTxNum() + if a.visibilityLoweringForbidden.Load() { + prev := a.visible.Load() + for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + if prev.d[d] == nil || next.d[d] == nil { + continue + } + prevEnd := visibleFiles(prev.d[d].files).EndTxNum() + nextEnd := visibleFiles(next.d[d].files).EndTxNum() + if nextEnd < prevEnd { + panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a fill-enabled StateCache is wired — fill admission relies on view frontiers never decreasing", d, prevEnd, nextEnd)) + } + } + } + old := a.visible.Load() old.retired = retired old.next = next diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index bd14d053585..ce59a28015c 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -19,8 +19,6 @@ package state import ( "testing" - "github.com/erigontech/erigon/common/dbg" - "github.com/stretchr/testify/require" "github.com/erigontech/erigon/db/datadir" @@ -162,29 +160,22 @@ func TestUnalign_RejectsStateDomain(t *testing.T) { } } -// Fill admission relies on view frontiers never decreasing, which holds only -// while no process both fills a StateCache and lowers visible file ends — -// Unalign must refuse to run beside a wired cache. -func TestUnalign_PanicsWithWiredStateCache(t *testing.T) { - dbg.SetStateCacheWired(true) - t.Cleanup(func() { dbg.SetStateCacheWired(false) }) - +// Fill admission relies on view frontiers never decreasing. Raising +// visibility (unaligning a lagging entity) is allowed even on a forbidden +// aggregator; the transition that lowers a cached state domain's visible end +// (here: realigning while receipt still lags, which drops the shared ceiling) +// must panic, whichever entry point caused it. +func TestVisibilityLowering_ForbiddenAggregatorPanicsOnLoweringOnly(t *testing.T) { + t.Parallel() _, agg := testDbAndAggregatorv3(t, alignStepSize) - require.Panics(t, func() { agg.Unalign(kv.ReceiptDomain) }) - require.Panics(t, func() { agg.UnalignIdx(kv.LogAddrIdx) }) -} -// The recheck must also cover the realign closure and the other -// visibility-lowering entry points: a cache wired between Unalign and realign -// still breaks frontier monotonicity. -func TestVisibilityLowering_RechecksAtEveryEntryPoint(t *testing.T) { - dbg.SetStateCacheWired(false) - _, agg := testDbAndAggregatorv3(t, alignStepSize) - realign := agg.Unalign(kv.ReceiptDomain) // no cache yet: allowed + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateDomainFiles(t, "receipt", agg.Dirs(), []testFileRange{{0, 1}}) + require.NoError(t, agg.OpenFolder()) - dbg.SetStateCacheWired(true) - t.Cleanup(func() { dbg.SetStateCacheWired(false) }) - require.Panics(t, func() { realign() }) - require.Panics(t, func() { agg.EnableAllDependencies() }) - require.Panics(t, func() { _ = agg.ReloadFiles() }) + agg.ForbidVisibilityLowering() + realign := agg.Unalign(kv.ReceiptDomain) // raises the ceiling: allowed + require.Panics(t, func() { realign() }, "realigning a still-lagging receipt lowers the state domains' ends") } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 5cf16f677f7..42e0e740eab 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -19,7 +19,6 @@ package cache import ( "bytes" "encoding/binary" - "github.com/erigontech/erigon/common/dbg" "sync" "testing" @@ -1160,24 +1159,17 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { require.True(t, ok, "unrelated code fills from a current view must stay admitted") } -func TestNewStateCacheMarksProcessWired(t *testing.T) { - b := 1 * datasize.MB - c := NewStateCache(b, b, b, b) - t.Cleanup(c.Close) - require.True(t, dbg.StateCacheWired(), - "constructing a cache must forbid visibility-lowering aggregator APIs in this process") -} - // An apply-only cache (STATE_CACHE_FILLS=false) has no fill for a lowered -// frontier to poison, so it must not forbid visibility lowering. -func TestApplyOnlyCacheDoesNotMarkProcessWired(t *testing.T) { +// frontier to poison; wire-up code keys the aggregator forbid on this. +func TestApplyOnlyCacheReportsFillsDisabled(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") - was := dbg.StateCacheWired() - dbg.SetStateCacheWired(false) - t.Cleanup(func() { dbg.SetStateCacheWired(was) }) - b := 1 * datasize.MB c := NewStateCache(b, b, b, b) t.Cleanup(c.Close) - require.False(t, dbg.StateCacheWired()) + require.False(t, c.FillsEnabled()) + + t.Setenv("STATE_CACHE_FILLS", "true") + c2 := NewStateCache(b, b, b, b) + t.Cleanup(c2.Close) + require.True(t, c2.FillsEnabled()) } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 2398adb6bbf..b634c2f6dd1 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -78,12 +78,6 @@ func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.Byt if !dbg.EnvBool("STATE_CACHE_FILLS", true) { sc.disableFills = true log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only flush applies populate the cache") - } else { - // Fill admission relies on view frontiers never decreasing; the - // aggregator's visibility-lowering entry points assert against this - // marker. Apply-only caches skip it — with no fills there is nothing - // for a lowered frontier to poison. - dbg.WireStateCache() } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) @@ -193,6 +187,13 @@ func (c *StateCache) putCodeSizeByHash(codeHash []byte, size int, txNum uint64) cc.PutCodeSizeByCodeHash(codeHash, size, txNum) } +// FillsEnabled reports whether reader fills are active (STATE_CACHE_FILLS). +// Wire-up code uses it to decide whether the backing aggregator must forbid +// visibility lowering: fill admission relies on view frontiers never +// decreasing, and apply-only caches have nothing for a lowered frontier to +// poison. +func (c *StateCache) FillsEnabled() bool { return !c.disableFills } + // getAddrCodeHash returns the Ethereum codeHash for addr without an // account-domain round-trip. The hash is zero when ok is false. func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index f9ea9166543..de199dbd05b 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -36,6 +36,7 @@ import ( "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" + dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/bal" "github.com/erigontech/erigon/execution/builder" @@ -261,6 +262,10 @@ func NewExecModule( if domainCache == nil { domainCache = cache.NewDefaultStateCache() } + if domainCache.FillsEnabled() { + // Fill admission relies on view frontiers never decreasing. + db.(dbstate.HasAgg).Agg().(*dbstate.Aggregator).ForbidVisibilityLowering() + } var codeStore *cache.CodeStore if dbg.UseCodeStore { codeStore = cache.NewCodeStore(cache.DefaultCodeStoreMemBytes, cache.DefaultCodeStoreTableBytes) From e8a1422a1bec36f16b8e8918b1a339d135fc7357 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 18:14:31 +0200 Subject: [PATCH 66/85] db/state: gofmt aggregator.go --- db/state/aggregator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index da7a55591dc..0a7b8d1f224 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -96,7 +96,7 @@ type Aggregator struct { // domains' visible ends while set; Close clears it (shutdown is not a // fill window). visibilityLoweringForbidden atomic.Bool - snapshotBuildSema *semaphore.Weighted + snapshotBuildSema *semaphore.Weighted disableHistory bool branchCacheDisabled bool From 4523761162dddb906ccfcf818d70166105d1e373 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 21:00:24 +0200 Subject: [PATCH 67/85] common/dbg: restore setter block formatting --- common/dbg/experiments.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/dbg/experiments.go b/common/dbg/experiments.go index 16bb2cc56ba..ecf06414c0a 100644 --- a/common/dbg/experiments.go +++ b/common/dbg/experiments.go @@ -180,9 +180,8 @@ func PruneTotalDifficulty() bool { return pruneTotalDifficulty } // CLI-override setters for the performance toggles that also have env-var // twins. The env var sets the initial value at package init; the CLI layer // calls these at node startup only when the user explicitly set the flag. -func SetIgnoreBAL(b bool) { IgnoreBAL = b } -func SetUseStateCache(b bool) { UseStateCache = b } - +func SetIgnoreBAL(b bool) { IgnoreBAL = b } +func SetUseStateCache(b bool) { UseStateCache = b } func SetReadAhead(b bool) { ReadAhead = b } func SetExec3Workers(n int) { Exec3Workers = n } func SetNoPrune(b bool) { noPrune = b } From 4ab8a365b555e292d3b66466b8440a9e3d4efd4b Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 22:39:05 +0200 Subject: [PATCH 68/85] db/state/execctx, execution/execmodule, cmd/integration: one home for the cache-aggregator guard The ForbidVisibilityLowering wiring was repeated at both cache wire-up sites. Centralize the cast, the fills-enabled condition and the rationale in execctx.GuardAggregatorForCache, duck-typed so the storage layer and the cache stay decoupled; future wire-ups have one named function to call instead of a pattern to copy. --- cmd/integration/commands/stages.go | 4 +--- db/state/execctx/domain_shared.go | 19 +++++++++++++++++++ execution/execmodule/exec_module.go | 6 +----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index 7706c05d104..cd3b743f852 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -846,9 +846,7 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn doms.SetInMemHistoryReads(false) doms.SetStateCache(stateCache) doms.SetCodeStore(codeStore) - if stateCache != nil && stateCache.FillsEnabled() { - db.(dbstate.HasAgg).Agg().(*dbstate.Aggregator).ForbidVisibilityLowering() - } + execctx.GuardAggregatorForCache(db, stateCache) s, err := st.StageState(stages.Execution, tx, initialCycle, false) if err != nil { diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 23079efcfb1..72b91bc30a9 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -858,6 +858,25 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { sd.cacheApplier = stateCache.Applier() } +// GuardAggregatorForCache forbids visibility lowering on db's aggregator when +// sc is a fill-enabled StateCache: fill admission relies on view frontiers +// never decreasing. This is the one place that binds the invariant — call it +// wherever a fill-enabled cache is wired over a DB. Duck-typed so the storage +// layer need not know the cache type (and vice versa); a DB without an +// aggregator is a no-op. +func GuardAggregatorForCache(db any, sc *cache.StateCache) { + if sc == nil || !sc.FillsEnabled() { + return + } + h, ok := db.(interface{ Agg() any }) + if !ok { + return + } + if f, ok := h.Agg().(interface{ ForbidVisibilityLowering() }); ok { + f.ForbidVisibilityLowering() + } +} + // SetCodeStore sets the persistent codehash-keyed code cache. func (sd *SharedDomains) SetCodeStore(codeStore *cache.CodeStore) { sd.codeStore = codeStore diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index de199dbd05b..f52f087a446 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -36,7 +36,6 @@ import ( "github.com/erigontech/erigon/db/kv/dbutils" "github.com/erigontech/erigon/db/kv/kvcache" "github.com/erigontech/erigon/db/rawdb" - dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/bal" "github.com/erigontech/erigon/execution/builder" @@ -262,10 +261,7 @@ func NewExecModule( if domainCache == nil { domainCache = cache.NewDefaultStateCache() } - if domainCache.FillsEnabled() { - // Fill admission relies on view frontiers never decreasing. - db.(dbstate.HasAgg).Agg().(*dbstate.Aggregator).ForbidVisibilityLowering() - } + execctx.GuardAggregatorForCache(db, domainCache) var codeStore *cache.CodeStore if dbg.UseCodeStore { codeStore = cache.NewCodeStore(cache.DefaultCodeStoreMemBytes, cache.DefaultCodeStoreTableBytes) From bb75019191b5c896d0915fe6afd277223e8835d3 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 22:59:05 +0200 Subject: [PATCH 69/85] db/state/execctx: GuardAggregatorForCache mirrors SetStateCache's gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With USE_STATE_CACHE=false SetStateCache is a no-op, so no SD ever wires the cache and no fill can happen — but the guard still keyed only on STATE_CACHE_FILLS and forbade visibility lowering on the aggregator for a cache that never gets wired. Gate on dbg.UseStateCache too. --- db/state/execctx/domain_shared.go | 6 +++-- db/state/execctx/statecache_readfill_test.go | 27 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 72b91bc30a9..282aea7e905 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -861,11 +861,13 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { // GuardAggregatorForCache forbids visibility lowering on db's aggregator when // sc is a fill-enabled StateCache: fill admission relies on view frontiers // never decreasing. This is the one place that binds the invariant — call it -// wherever a fill-enabled cache is wired over a DB. Duck-typed so the storage +// wherever a fill-enabled cache is wired over a DB. It mirrors SetStateCache's +// gate: with USE_STATE_CACHE=false the cache is never wired and no fill can +// happen, so the aggregator stays unconstrained. Duck-typed so the storage // layer need not know the cache type (and vice versa); a DB without an // aggregator is a no-op. func GuardAggregatorForCache(db any, sc *cache.StateCache) { - if sc == nil || !sc.FillsEnabled() { + if sc == nil || !dbg.UseStateCache || !sc.FillsEnabled() { return } h, ok := db.(interface{ Agg() any }) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 59da87b06da..59348bb10c2 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -344,3 +344,30 @@ func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) require.False(t, ok, "an unwind of the view's last included txNum must invalidate the negative") } + +type fakeForbidder struct{ called bool } + +func (f *fakeForbidder) ForbidVisibilityLowering() { f.called = true } + +type fakeHasAgg struct{ f *fakeForbidder } + +func (h fakeHasAgg) Agg() any { return h.f } + +// GuardAggregatorForCache must mirror SetStateCache's gate: with +// USE_STATE_CACHE=false no SD ever wires the cache, so no fills can happen and +// the aggregator must stay free to lower visibility. +func TestGuardAggregatorForCache_RespectsUseStateCache(t *testing.T) { + sc := newSmallStateCache() + t.Cleanup(sc.Close) + old := dbg.UseStateCache + t.Cleanup(func() { dbg.SetUseStateCache(old) }) + + dbg.SetUseStateCache(false) + f := &fakeForbidder{} + execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + require.False(t, f.called, "a disabled cache is never wired — the aggregator must not be constrained") + + dbg.SetUseStateCache(true) + execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + require.True(t, f.called) +} From 25c1e91f2b5571b50a9ab3c40796bac2a60dcd08 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 23:16:59 +0200 Subject: [PATCH 70/85] db/state/execctx, execution/execmodule: fail closed on guard shape mismatch; build no cache when disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The centralized guard silently returned when the DB could not produce its aggregator — a TemporalRwDB wrapper hiding Agg (memory_mutation-style) would silently drop the load-bearing invariant the concrete casts used to enforce loudly. For a fill-enabled cache the shape mismatch now panics, naming the type; a nil or apply-only cache still needs no guard. USE_STATE_CACHE=false now constructs no cache at all instead of building one that SharedDomains never wires: read-ahead previously kept filling the unused cache, wasting allocation and falsifying the no-fill rationale. With construction gated, the guard no longer consults the global flag. Also reconcile the SetStateCache doc with the code (population is post-commit via Commit, the invariant is guard-enforced) and drop an incident anecdote per the comment policy. --- db/state/execctx/domain_shared.go | 34 +++++++++----------- db/state/execctx/statecache_readfill_test.go | 30 +++++++++++------ execution/execmodule/exec_module.go | 13 +++++--- 3 files changed, 43 insertions(+), 34 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 282aea7e905..56809fcbda2 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -840,16 +840,11 @@ func (sd *SharedDomains) GetCommitmentCtx() *commitmentdb.SharedDomainsCommitmen func (sd *SharedDomains) Logger() log.Logger { return sd.logger } -// SetStateCache hands this SD the process-global state cache to manage. -// -// Coherence is structural, enforced by the architecture rather than by -// remembering to call this: app components reach state only through the SD, and -// the SD owns cache population (on flush) and invalidation (sd.Unwind → -// Applier.Unwind). It is not *additionally* type-enforced only because the -// cache crosses the app/storage boundary — the storage layer can't depend on an -// app-level cache type. The single desync vector is a component that -// deliberately bypasses the SD (raw domain reads + direct cache writes, e.g. -// read-ahead warmup), which then owns its cache coherence explicitly. +// SetStateCache hands this SD the process-global state cache to manage: +// Commit applies committed updates after a successful DB commit, Unwind +// invalidates them, and reads populate it through admission-gated fills — +// the SD's own, and read-ahead warmup's through its own ReadView. No-op when +// USE_STATE_CACHE is off or the cache is nil. func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return @@ -861,22 +856,23 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { // GuardAggregatorForCache forbids visibility lowering on db's aggregator when // sc is a fill-enabled StateCache: fill admission relies on view frontiers // never decreasing. This is the one place that binds the invariant — call it -// wherever a fill-enabled cache is wired over a DB. It mirrors SetStateCache's -// gate: with USE_STATE_CACHE=false the cache is never wired and no fill can -// happen, so the aggregator stays unconstrained. Duck-typed so the storage -// layer need not know the cache type (and vice versa); a DB without an -// aggregator is a no-op. +// wherever a fill-enabled cache is wired over a DB. Duck-typed so the storage +// layer need not know the cache type (and vice versa) — but load-bearing, so +// a db that cannot produce its aggregator fails loudly instead of silently +// dropping the guard. A nil or apply-only cache needs no guard. func GuardAggregatorForCache(db any, sc *cache.StateCache) { - if sc == nil || !dbg.UseStateCache || !sc.FillsEnabled() { + if sc == nil || !sc.FillsEnabled() { return } h, ok := db.(interface{ Agg() any }) if !ok { - return + panic(fmt.Sprintf("assert: fill-enabled StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) } - if f, ok := h.Agg().(interface{ ForbidVisibilityLowering() }); ok { - f.ForbidVisibilityLowering() + f, ok := h.Agg().(interface{ ForbidVisibilityLowering() }) + if !ok { + panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", h.Agg())) } + f.ForbidVisibilityLowering() } // SetCodeStore sets the persistent codehash-keyed code cache. diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 59348bb10c2..80511576422 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -353,21 +353,31 @@ type fakeHasAgg struct{ f *fakeForbidder } func (h fakeHasAgg) Agg() any { return h.f } -// GuardAggregatorForCache must mirror SetStateCache's gate: with -// USE_STATE_CACHE=false no SD ever wires the cache, so no fills can happen and -// the aggregator must stay free to lower visibility. -func TestGuardAggregatorForCache_RespectsUseStateCache(t *testing.T) { +// The guard is load-bearing: for a fill-enabled cache it must either bind the +// invariant or fail loudly — never silently drop it on a DB shape mismatch. +// A nil or apply-only cache needs no guard at all. +func TestGuardAggregatorForCache(t *testing.T) { sc := newSmallStateCache() t.Cleanup(sc.Close) - old := dbg.UseStateCache - t.Cleanup(func() { dbg.SetUseStateCache(old) }) - dbg.SetUseStateCache(false) f := &fakeForbidder{} execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) - require.False(t, f.called, "a disabled cache is never wired — the aggregator must not be constrained") + require.True(t, f.called) + + require.NotPanics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, nil) }, + "no cache, no invariant to bind — shape is irrelevant") + require.Panics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, sc) }, + "a db that cannot produce its aggregator must fail loudly, not drop the guard") +} + +// An apply-only cache (STATE_CACHE_FILLS=false) has no fills for a lowered +// frontier to poison, so the guard must not constrain the aggregator. +func TestGuardAggregatorForCache_ApplyOnlySkips(t *testing.T) { + t.Setenv("STATE_CACHE_FILLS", "false") + sc := newSmallStateCache() + t.Cleanup(sc.Close) - dbg.SetUseStateCache(true) + f := &fakeForbidder{} execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) - require.True(t, f.called) + require.False(t, f.called) } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index f52f087a446..4c5b3710aa6 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -255,11 +255,14 @@ func NewExecModule( ) *ExecModule { // Production passes nil → full-size default cache. Test/CLI harnesses pass a // small cache so building one ExecModule per fixture doesn't allocate - // hundreds of MB of LRU tables each (which stalled the parallel eest - // blocktest). Per-instance, so it never mutates the process-wide default. - domainCache := domainStateCache - if domainCache == nil { - domainCache = cache.NewDefaultStateCache() + // hundreds of MB of LRU tables each. USE_STATE_CACHE=false constructs no + // cache at all: neither SharedDomains nor read-ahead gets one to touch. + var domainCache *cache.StateCache + if dbg.UseStateCache { + domainCache = domainStateCache + if domainCache == nil { + domainCache = cache.NewDefaultStateCache() + } } execctx.GuardAggregatorForCache(db, domainCache) var codeStore *cache.CodeStore From 982cc8e486cace0479d5ae18cb20745b031e1d91 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 4 Aug 2026 23:54:38 +0200 Subject: [PATCH 71/85] execution/execmodule, node/eth: module owns state-cache construction; harden the visibility guard USE_STATE_CACHE=false was not allocation-free end to end: backend.go could build a budget-sized cache that NewExecModule then discarded, leaking its memory-envelope reservation, and ExecModuleTester built a default cache regardless of the flag. Callers now pass a byte budget instead of a constructed cache; newDomainStateCache in the module is the single construction site (none when disabled, pinned by test), and ExecModule.Close releases the reservation for per-fixture harnesses. Guard hardening per review: cover the second panic branch (aggregator without ForbidVisibilityLowering) in the guard test, evaluate Agg() once, and take dirtyFilesLock in ForbidVisibilityLowering so 'from then on' holds against a recalcVisibleFiles already in flight. Also trim the SetStateCache doc to what the method manages (it does not wire read-ahead). --- db/state/aggregator.go | 8 +++- db/state/execctx/domain_shared.go | 10 ++-- db/state/execctx/statecache_readfill_test.go | 6 +++ execution/execmodule/exec_module.go | 40 +++++++++++----- .../execmodule/exec_module_internal_test.go | 46 +++++++++++++++++++ .../execmoduletester/exec_module_tester.go | 12 ++--- node/eth/backend.go | 11 +---- 7 files changed, 96 insertions(+), 37 deletions(-) create mode 100644 execution/execmodule/exec_module_internal_test.go diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 0a7b8d1f224..a433abdf478 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -551,7 +551,13 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { // ForbidVisibilityLowering marks this aggregator as backing a fill-enabled // StateCache: from then on recalcVisibleFiles panics instead of lowering a // cached state domain's visible end, whichever entry point caused it. -func (a *Aggregator) ForbidVisibilityLowering() { a.visibilityLoweringForbidden.Store(true) } +// Serialized with recalcVisibleFiles via dirtyFilesLock so "from then on" +// holds against a recalculation already in flight. +func (a *Aggregator) ForbidVisibilityLowering() { + a.dirtyFilesLock.Lock() + defer a.dirtyFilesLock.Unlock() + a.visibilityLoweringForbidden.Store(true) +} func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { a.dirtyFilesLock.Lock() diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 56809fcbda2..24dfd5a91c0 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -842,9 +842,8 @@ func (sd *SharedDomains) Logger() log.Logger { return sd.logger } // SetStateCache hands this SD the process-global state cache to manage: // Commit applies committed updates after a successful DB commit, Unwind -// invalidates them, and reads populate it through admission-gated fills — -// the SD's own, and read-ahead warmup's through its own ReadView. No-op when -// USE_STATE_CACHE is off or the cache is nil. +// invalidates them, and the SD's reads populate it through admission-gated +// fills. No-op when USE_STATE_CACHE is off or the cache is nil. func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return @@ -868,9 +867,10 @@ func GuardAggregatorForCache(db any, sc *cache.StateCache) { if !ok { panic(fmt.Sprintf("assert: fill-enabled StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) } - f, ok := h.Agg().(interface{ ForbidVisibilityLowering() }) + agg := h.Agg() + f, ok := agg.(interface{ ForbidVisibilityLowering() }) if !ok { - panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", h.Agg())) + panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) } f.ForbidVisibilityLowering() } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 80511576422..edaaa4effe3 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -353,6 +353,10 @@ type fakeHasAgg struct{ f *fakeForbidder } func (h fakeHasAgg) Agg() any { return h.f } +type fakeHasBadAgg struct{} + +func (fakeHasBadAgg) Agg() any { return struct{}{} } + // The guard is load-bearing: for a fill-enabled cache it must either bind the // invariant or fail loudly — never silently drop it on a DB shape mismatch. // A nil or apply-only cache needs no guard at all. @@ -368,6 +372,8 @@ func TestGuardAggregatorForCache(t *testing.T) { "no cache, no invariant to bind — shape is irrelevant") require.Panics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, sc) }, "a db that cannot produce its aggregator must fail loudly, not drop the guard") + require.Panics(t, func() { execctx.GuardAggregatorForCache(fakeHasBadAgg{}, sc) }, + "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the guard") } // An apply-only cache (STATE_CACHE_FILLS=false) has no fills for a lowered diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 4c5b3710aa6..d67f962f237 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -27,7 +27,9 @@ import ( "github.com/holiman/uint256" "golang.org/x/sync/semaphore" + "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/common/math" @@ -243,7 +245,7 @@ func NewExecModule( hook *stageloop.Hook, accum *Accumulation, stateCache *Cache, - domainStateCache *cache.StateCache, + stateCacheBudget datasize.ByteSize, logger log.Logger, engine rules.Engine, syncCfg ethconfig.Sync, @@ -253,17 +255,7 @@ func NewExecModule( readAheader *exec.BlockReadAheader, stopNode func() error, ) *ExecModule { - // Production passes nil → full-size default cache. Test/CLI harnesses pass a - // small cache so building one ExecModule per fixture doesn't allocate - // hundreds of MB of LRU tables each. USE_STATE_CACHE=false constructs no - // cache at all: neither SharedDomains nor read-ahead gets one to touch. - var domainCache *cache.StateCache - if dbg.UseStateCache { - domainCache = domainStateCache - if domainCache == nil { - domainCache = cache.NewDefaultStateCache() - } - } + domainCache := newDomainStateCache(stateCacheBudget) execctx.GuardAggregatorForCache(db, domainCache) var codeStore *cache.CodeStore if dbg.UseCodeStore { @@ -318,6 +310,30 @@ func (e *ExecModule) WaitIdle(ctx context.Context) { e.semaphore.Release(1) } +// newDomainStateCache is the module's one construction site of the domain +// state cache: USE_STATE_CACHE=false builds none, so nothing upstream can +// allocate a cache that would only be discarded. A budget > 0 overrides the +// production per-domain byte budget (test harnesses keep per-fixture modules +// small); 0 means the production default. +func newDomainStateCache(budget datasize.ByteSize) *cache.StateCache { + if !dbg.UseStateCache { + return nil + } + if budget > 0 { + return cache.NewStateCache(budget, budget, budget, budget) + } + return cache.NewDefaultStateCache() +} + +// Close releases the domain state cache's reservation in the shared memory +// envelope. For harnesses that build many modules per process; production +// modules live for the process. +func (e *ExecModule) Close() { + if e.stateCache != nil { + e.stateCache.Close() + } +} + // closeModuleContext closes and clears e.currentContext. The nil swap happens // under e.lock first, so getters holding the read lock (beginOverlayOrRo) can // never obtain a SharedDomains that is about to be closed. diff --git a/execution/execmodule/exec_module_internal_test.go b/execution/execmodule/exec_module_internal_test.go new file mode 100644 index 00000000000..f2420c0ff57 --- /dev/null +++ b/execution/execmodule/exec_module_internal_test.go @@ -0,0 +1,46 @@ +// 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 ( + "testing" + + "github.com/c2h5oh/datasize" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dbg" +) + +// The module is the one owner of the domain state cache: callers pass a byte +// budget, never a constructed cache, so a disabled cache cannot be built +// upstream and leak its memory-envelope reservation. +func TestNewDomainStateCacheRespectsUseStateCache(t *testing.T) { + prev := dbg.UseStateCache + t.Cleanup(func() { dbg.SetUseStateCache(prev) }) + + dbg.SetUseStateCache(false) + require.Nil(t, newDomainStateCache(0), "disabled mode must construct no cache") + require.Nil(t, newDomainStateCache(16*datasize.MB), "a budget must not override the kill switch") + + dbg.SetUseStateCache(true) + sc := newDomainStateCache(16 * datasize.MB) + require.NotNil(t, sc) + sc.Close() + scDefault := newDomainStateCache(0) + require.NotNil(t, scDefault, "zero budget means the production default, not no cache") + scDefault.Close() +} diff --git a/execution/execmodule/execmoduletester/exec_module_tester.go b/execution/execmodule/execmoduletester/exec_module_tester.go index 765198d1b68..b01020e34b6 100644 --- a/execution/execmodule/execmoduletester/exec_module_tester.go +++ b/execution/execmodule/execmoduletester/exec_module_tester.go @@ -55,7 +55,6 @@ import ( "github.com/erigontech/erigon/db/snaptype" dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/execution/builder" - "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/exec" "github.com/erigontech/erigon/execution/execmodule" @@ -126,7 +125,6 @@ type ExecModuleTester struct { ForkValidator *execmodule.ForkValidator ExecModule *execmodule.ExecModule StateCache *execmodule.Cache - domainCache *cache.StateCache retirementStart chan bool retirementDone chan struct{} retirementWg sync.WaitGroup @@ -169,8 +167,8 @@ func (emt *ExecModuleTester) Close() { if emt.DB != nil { emt.DB.Close() } - if emt.domainCache != nil { - emt.domainCache.Close() + if emt.ExecModule != nil { + emt.ExecModule.Close() } if emt.tb == nil && emt.Dirs.DataDir != "" { dir.RemoveAll(emt.Dirs.DataDir) @@ -776,10 +774,6 @@ func New(tb testing.TB, opts ...Option) *ExecModuleTester { Accumulator: mock.Notifications.Accumulator, RecentReceipts: mock.Notifications.RecentReceipts, } - // Per-instance domain cache, held on the tester so Close releases its - // envelope reservation. Uses the production default — the caches jump-grow on - // demand, so a small-working-set fixture stays small. - mock.domainCache = cache.NewDefaultStateCache() mock.ExecModule = execmodule.NewExecModule( ctx, mock.BlockReader, @@ -791,7 +785,7 @@ func New(tb testing.TB, opts ...Option) *ExecModuleTester { hook, accum, mock.StateCache, - mock.domainCache, + 0, // stateCacheBudget: production default; the caches jump-grow on demand logger, engine, cfg.Sync, diff --git a/node/eth/backend.go b/node/eth/backend.go index 128f80acd89..d6bfb68e4f2 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -73,7 +73,6 @@ import ( "github.com/erigontech/erigon/diagnostics/diaglib" "github.com/erigontech/erigon/diagnostics/mem" "github.com/erigontech/erigon/execution/builder" - "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/chain" chainspec "github.com/erigontech/erigon/execution/chain/spec" "github.com/erigontech/erigon/execution/engineapi" @@ -944,14 +943,6 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger Accumulator: backend.notifications.Accumulator, RecentReceipts: backend.notifications.RecentReceipts, } - // Test harnesses (e.g. EngineApiTester) set StateCacheBudget small so each - // per-fixture ExecModule doesn't allocate the full production cache; 0 keeps - // the production default. - var domainStateCache *cache.StateCache - if config.StateCacheBudget > 0 { - b := config.StateCacheBudget - domainStateCache = cache.NewStateCache(b, b, b, b) - } backend.execModule = execmodule.NewExecModule( ctx, blockReader, @@ -963,7 +954,7 @@ func New(ctx context.Context, stack *node.Node, config *ethconfig.Config, logger hook, accum, execmoduleCache, - domainStateCache, + config.StateCacheBudget, logger, backend.engine, config.Sync, From 756cd2bf107adf97679af9958751cb2aacb987eb Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 00:08:47 +0200 Subject: [PATCH 72/85] node/eth, execution/cache: release the exec module's cache on Stop Ethereum.Stop never released the domain state cache's memory-envelope reservation, so per-fixture backends (EngineApiTester) accumulated reservations across a test binary. Close the module after chainDB.Close, mirroring ExecModuleTester's teardown order. Also update the NewDefaultStateCache doc: harnesses now set a budget, they no longer pass a constructed cache. --- execution/cache/state_cache.go | 6 +++--- node/eth/backend.go | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index b634c2f6dd1..d517ea590c3 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -116,9 +116,9 @@ func newDomainCacheBytes(capacityBytes datasize.ByteSize, avgBytes uint32, mode } // NewDefaultStateCache creates a new StateCache with the production byte budgets -// (Account 1GB, Storage 150MB, Code 512MB, Addr 16MB). Test/CLI harnesses that -// build many short-lived ExecModules pass an explicit small cache instead — via -// ExecModuleTester, or via ethconfig.Config.StateCacheBudget for the eth.New path. +// (Account 1GB, Storage 150MB, Code 512MB, Addr 16MB). Harnesses that build +// many short-lived ExecModules set a small ethconfig.Config.StateCacheBudget +// instead. func NewDefaultStateCache() *StateCache { return NewStateCache( DefaultAccountCacheBytes, diff --git a/node/eth/backend.go b/node/eth/backend.go index d6bfb68e4f2..f7ce65085bc 100644 --- a/node/eth/backend.go +++ b/node/eth/backend.go @@ -1614,6 +1614,10 @@ func (s *Ethereum) Stop() error { s.chainDB.Close() + if s.execModule != nil { + s.execModule.Close() + } + if s.config.Downloader != nil { _ = s.config.Downloader.CloseTorrentLogFile() } From 7af4d0f7c5a8bd4e56e4f129d1f64b98492014e5 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 09:36:35 +0200 Subject: [PATCH 73/85] execution/cache, db/state: shorten state-cache lock windows; teardown symmetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply() checks the immutable caches array before taking the write lock, and the fill paths clone the value before taking the read lock — a rejected fill wastes one copy (rare), but Apply never waits on a fill's memcpy of up-to-24KB code. Aggregator.Close clears the visibility- lowering flag under dirtyFilesLock, matching the setter. The early SharedDomains.Close in the RPC resurrection tests now says it is deliberate (the view outlives the overlay teardown, as across a background commit), so it does not read as a use-after-close. Also fix import grouping in exec_module.go. --- db/state/aggregator.go | 2 ++ .../statecache_rpc_integration_test.go | 4 +++ execution/cache/state_cache.go | 26 +++++++++++-------- execution/execmodule/exec_module.go | 3 +-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index a433abdf478..66c7740140f 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -700,7 +700,9 @@ func (a *Aggregator) WaitForFiles() { } func (a *Aggregator) Close() { + a.dirtyFilesLock.Lock() a.visibilityLoweringForbidden.Store(false) // shutdown is not a fill window + a.dirtyFilesLock.Unlock() a.WaitForFiles() if !a.background.BeginClose() { // idempotent: safe to call Close multiple times return diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 0f5c5de4f8b..125612d5a96 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -235,6 +235,9 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) events.PublishOverlay(nil) + // The view outlives the overlay teardown on purpose: a production RPC view + // built during a background commit keeps reading after PublishOverlay(nil) + // and the SD's Close. deleteDomains.Close() oldValue, _, err := rpcTx.GetLatest(domain, key) @@ -324,6 +327,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) events.PublishOverlay(nil) + // The view outlives the overlay teardown on purpose, as above. deleteDomains.Close() _, ok := stateCache.View(nil).Get(kv.CodeDomain, addr) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index d517ea590c3..073bdc6b8c9 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -248,18 +248,21 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea if cache == nil { return } - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[domain] { - return - } + // Clone outside the lock: a rejected fill wastes one copy (rare), but + // Apply's write lock never waits on a fill's memcpy. + cloned := bytes.Clone(value) if len(value) == 0 { readTxNum = 0 if visibleEnd > 0 { readTxNum = visibleEnd - 1 } } - cache.PutIfAbsent(key, bytes.Clone(value), readTxNum) + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if visibleEnd < c.appliedEnd[domain] { + return + } + cache.PutIfAbsent(key, cloned, readTxNum) } // fillCodeIfFresh is fillIfFresh for the code domain. An addr-keyed code entry @@ -273,12 +276,13 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl return } codeHash := crypto.Keccak256(value) + cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() if visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return } - codeCache.PutWithCodeHashIfAbsent(key, bytes.Clone(value), codeHash, readTxNum) + codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) } // deleteKey removes the data for the given domain and key. Authoritative @@ -293,6 +297,10 @@ func (c *StateCache) deleteKey(domain kv.Domain, key []byte) { // apply makes a committed domain update authoritative for subsequent fills. func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { + cache := c.caches[domain] + if cache == nil { + return + } var codeHash []byte if domain == kv.CodeDomain && len(value) > 0 { // Clone before hashing so the stored bytes and their codeHash cannot @@ -303,10 +311,6 @@ func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() - cache := c.caches[domain] - if cache == nil { - return - } c.noteApplied(domain, txNum) switch domain { diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index d67f962f237..0517cb2c0bb 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -24,12 +24,11 @@ import ( "sync" "time" + "github.com/c2h5oh/datasize" "github.com/holiman/uint256" "golang.org/x/sync/semaphore" - "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/common/math" From 87321bbe110a43b99b2cd83b95575ae985ce0f2f Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 09:42:54 +0200 Subject: [PATCH 74/85] execution, db/state/execctx: pin budget release on node close; post-commit-apply wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestEngineApiNodeCloseReleasesCacheBudget drives the real EngineApiTester → node.Close → Ethereum.Stop path and asserts cachebudget.Global returns to its pre-construction level (red with the Stop-time ExecModule.Close removed, green with it). Replace the stale flush-apply vocabulary in package docs, comments, the fills-disabled log line and test text with commit/unwind and post-commit apply — applies happen after tx.Commit succeeds, never at Flush. Trim the ExecModule.Close doc to the invariant. --- db/state/execctx/codehash_routing_test.go | 2 +- db/state/execctx/domain_shared.go | 2 +- db/state/execctx/statecache_readfill_test.go | 2 +- .../statecache_rpc_integration_test.go | 2 +- execution/cache/cache.go | 4 +- execution/cache/state_cache.go | 2 +- execution/cache/view.go | 6 +- .../engineapi/engine_api_cache_budget_test.go | 56 +++++++++++++++++++ execution/execmodule/exec_module.go | 3 +- execution/tests/legacy-tests | 1 + 10 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 execution/engineapi/engine_api_cache_budget_test.go create mode 160000 execution/tests/legacy-tests diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index a0203e36d98..211e3cadaea 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -106,7 +106,7 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { _, ok := sc.View(nil).Get(kv.AccountsDomain, addr[:]) require.True(t, ok, "the committed record must be served by the accounts cache") _, ok = sc.View(nil).GetAddrCodeHash(addr[:]) - require.False(t, ok, "the flush apply must leave the derived mapping empty") + require.False(t, ok, "the post-commit apply must leave the derived mapping empty") roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 24dfd5a91c0..54fc690fd0d 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -229,7 +229,7 @@ type SharedDomains struct { parent *SharedDomains // stateCache is an optional cache for state data (accounts, storage, code); - // cacheApplier is its authoritative writer handle (flush/unwind only). + // cacheApplier is its authoritative writer handle (commit/unwind only). stateCache *cache.StateCache cacheApplier cache.Applier diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index edaaa4effe3..7c198a3034e 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -256,7 +256,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) } // The read-fill after a fall-through read must not replace a live cache -// entry: it never carries newer information than a flush-apply, and during an +// entry: it never carries newer information than a post-commit apply, and during an // in-flight unwind the bounded DB read can even return the not-yet-deleted // dying row. func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 125612d5a96..789bea870df 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -269,7 +269,7 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k // The account-deletion mirror of TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode. // DomainDel(AccountsDomain) cascades a code-domain delete at the SD layer, so the -// flush applies it and the code frontier advances past every pre-deletion view — +// commit applies it and the code frontier advances past every pre-deletion view — // and the cache-level code-fill admission also checks the accounts frontier. This // pins both layers: losing either must not let a pre-deletion RPC view refill the // deleted account's code. diff --git a/execution/cache/cache.go b/execution/cache/cache.go index e35e7c03105..d30c41a0f4d 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -30,8 +30,8 @@ // frontier — the exclusive txNum end of what its tx can see, so a view with // frontier N sees txNums < N — against the applied end, under the same lock // applies take. The Applier handle, held by the SharedDomains -// flush/unwind path, performs the authoritative writes: committed updates, -// unwinds, clears. +// commit/unwind path, performs the authoritative writes: post-commit +// applies, unwinds, clears. package cache // Cache is the interface for domain caches. diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 073bdc6b8c9..276871b07cc 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -77,7 +77,7 @@ func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.Byt sc := &StateCache{} if !dbg.EnvBool("STATE_CACHE_FILLS", true) { sc.disableFills = true - log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only flush applies populate the cache") + log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only post-commit applies populate the cache") } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) diff --git a/execution/cache/view.go b/execution/cache/view.go index 17524e1bbb3..7359ad30847 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -155,9 +155,9 @@ func (v ReadView) FillCodeSize(codeHash []byte, size int, txNum uint64) { v.c.putCodeSizeByHash(codeHash, size, txNum) } -// Applier is the authoritative writer handle of a StateCache: committed -// updates, unwinds and clears. It belongs to the authoritative mutation path -// — the SharedDomains flush/unwind code. The zero value is a no-op. +// Applier is the authoritative writer handle of a StateCache: post-commit +// applies, unwinds and clears. It belongs to the authoritative mutation path +// — the SharedDomains commit/unwind code. The zero value is a no-op. type Applier struct { c *StateCache } diff --git a/execution/engineapi/engine_api_cache_budget_test.go b/execution/engineapi/engine_api_cache_budget_test.go new file mode 100644 index 00000000000..e867a88f144 --- /dev/null +++ b/execution/engineapi/engine_api_cache_budget_test.go @@ -0,0 +1,56 @@ +// 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 engineapi_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/cachebudget" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/testlog" + "github.com/erigontech/erigon/execution/engineapi/engineapitester" +) + +// Node close must return every cache reservation to the process-wide envelope; +// otherwise each per-fixture node in a test binary leaks its slice of +// cachebudget.Global and later caches size against phantom concurrency. +func TestEngineApiNodeCloseReleasesCacheBudget(t *testing.T) { + if testing.Short() { + t.Skip("long-running test") + } + ctx := t.Context() + logger := testlog.Logger(t, log.LvlError) + genesis, coinbaseKey, err := engineapitester.DefaultEngineApiTesterGenesis() + require.NoError(t, err) + + usedBefore := cachebudget.Global.Used() + eat, err := engineapitester.InitialiseEngineApiTester(ctx, engineapitester.EngineApiTesterInitArgs{ + Logger: logger, + DataDir: t.TempDir(), + Genesis: genesis, + CoinbaseKey: coinbaseKey, + }) + require.NoError(t, err) + require.Greater(t, cachebudget.Global.Used(), usedBefore, + "a running node must hold cache-budget reservations") + + require.NoError(t, eat.Close()) + require.Equal(t, usedBefore, cachebudget.Global.Used(), + "node close must release every cache-budget reservation") +} diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 0517cb2c0bb..9a5b3204633 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -325,8 +325,7 @@ func newDomainStateCache(budget datasize.ByteSize) *cache.StateCache { } // Close releases the domain state cache's reservation in the shared memory -// envelope. For harnesses that build many modules per process; production -// modules live for the process. +// envelope. func (e *ExecModule) Close() { if e.stateCache != nil { e.stateCache.Close() diff --git a/execution/tests/legacy-tests b/execution/tests/legacy-tests new file mode 160000 index 00000000000..c67e485ff8b --- /dev/null +++ b/execution/tests/legacy-tests @@ -0,0 +1 @@ +Subproject commit c67e485ff8b5be9abc8ad15345ec21aa22e290d9 From a880acc51bcf2539807b593128cbeb1956cf27f0 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 09:43:08 +0200 Subject: [PATCH 75/85] execution/tests: remove accidentally committed legacy-tests gitlink --- execution/tests/legacy-tests | 1 - 1 file changed, 1 deletion(-) delete mode 160000 execution/tests/legacy-tests diff --git a/execution/tests/legacy-tests b/execution/tests/legacy-tests deleted file mode 160000 index c67e485ff8b..00000000000 --- a/execution/tests/legacy-tests +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c67e485ff8b5be9abc8ad15345ec21aa22e290d9 From 6aa368e0f3d61ee40bfa10ed59eef68a12ddf560 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 09:46:22 +0200 Subject: [PATCH 76/85] execution/engineapi: close the tester on assert failure in the budget test --- execution/engineapi/engine_api_cache_budget_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/execution/engineapi/engine_api_cache_budget_test.go b/execution/engineapi/engine_api_cache_budget_test.go index e867a88f144..9d78047d7d1 100644 --- a/execution/engineapi/engine_api_cache_budget_test.go +++ b/execution/engineapi/engine_api_cache_budget_test.go @@ -47,6 +47,7 @@ func TestEngineApiNodeCloseReleasesCacheBudget(t *testing.T) { CoinbaseKey: coinbaseKey, }) require.NoError(t, err) + t.Cleanup(func() { _ = eat.Close() }) require.Greater(t, cachebudget.Global.Used(), usedBefore, "a running node must hold cache-budget reservations") From f3cb104fe02490818bad51677441472d2d943a6e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 10:13:15 +0200 Subject: [PATCH 77/85] db/state: frontier never overstates values coverage; guard the history-II end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DomainVisibleEnd reported the history-II visible end, but a dependency checker can clamp the values view below it — reads in that gap fall back to older file values, so the frontier overstated what the view serves and a stale fill could pass admission. Clamp to the values end when the two diverge. The forbid-lowering assert watched only the domain-values ends, a different quantity than DomainVisibleEnd derives frontiers from; a history-II end could lower without tripping it. Add the dhii arm. Both pinned red first via a dependency-clamped visible bundle. Also narrow the pending-stash comment: the durable-MDBX guarantee covers applies, not reads that fill between flush and a failed (fatal) commit. --- db/state/aggregator.go | 14 ++++++ db/state/aggregator_align_test.go | 81 +++++++++++++++++++++++++++++++ db/state/execctx/domain_shared.go | 4 +- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 66c7740140f..231dfd08805 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -1883,6 +1883,14 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { if nextEnd < prevEnd { panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a fill-enabled StateCache is wired — fill admission relies on view frontiers never decreasing", d, prevEnd, nextEnd)) } + if prev.dhii[d] == nil || next.dhii[d] == nil { + continue + } + prevII := prev.dhii[d].files.EndTxNum() + nextII := next.dhii[d].files.EndTxNum() + if nextII < prevII { + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a fill-enabled StateCache is wired — DomainVisibleEnd derives view frontiers from it", d, prevII, nextII)) + } } } @@ -2635,6 +2643,12 @@ func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bo if d.d.HistoryDisabled { return 0, false } + // A dependency checker can clamp the values view below the history-II end; + // reads in that gap fall back to older file values, so the frontier must + // not overstate the values coverage. + if valuesEnd := d.files.EndTxNum(); valuesEnd < d.ht.iit.files.EndTxNum() { + return valuesEnd, true + } return d.ht.iit.visibleEnd(tx), true } func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index ce59a28015c..b3e5226e55c 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -17,10 +17,13 @@ package state import ( + "context" + "path/filepath" "testing" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/kv" ) @@ -179,3 +182,81 @@ func TestVisibilityLowering_ForbiddenAggregatorPanicsOnLoweringOnly(t *testing.T realign := agg.Unalign(kv.ReceiptDomain) // raises the ceiling: allowed require.Panics(t, func() { realign() }, "realigning a still-lagging receipt lowers the state domains' ends") } + +// craftedClampedVisible replaces the current visible bundle with one where +// every state domain's values files end one segment below its history-II end +// — the divergence a dependency checker produces when a dependent file is +// missing. +func craftedClampedVisible(t *testing.T, agg *Aggregator) { + t.Helper() + agg.dirtyFilesLock.Lock() + defer agg.dirtyFilesLock.Unlock() + v := agg.visible.Load() + crafted := &aggregatorVisible{minimaxTxNum: v.minimaxTxNum} + crafted.d, crafted.dh, crafted.dhii, crafted.iis = v.d, v.dh, v.dhii, v.iis + for _, dom := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + files := v.d[dom].files + require.GreaterOrEqual(t, len(files), 2) + crafted.d[dom] = newDomainVisible(dom, files[:len(files)-1]) + } + v.next = crafted + agg.visible.Store(crafted) +} + +// A view's frontier must not overstate what its values view can serve: with +// domain values clamped below the history-II end (dependency checker), reads +// above the values end fall back to older file values, so DomainVisibleEnd +// must report the values end, not the II end. +func TestDomainVisibleEnd_ClampedToValuesCoverage(t *testing.T) { + t.Parallel() + db, agg := testDbAndAggregatorv3(t, alignStepSize) + + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + require.NoError(t, agg.OpenFolder()) + + craftedClampedVisible(t, agg) + + at := agg.BeginFilesRo() + defer at.Close() + tx, err := db.BeginRo(context.Background()) + require.NoError(t, err) + defer tx.Rollback() + + end, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) + require.True(t, ok) + require.Equal(t, uint64(1*alignStepSize), end, + "the frontier must not overstate the values coverage") +} + +// The forbid assert must also watch the history-II ends: they are the base of +// what DomainVisibleEnd reports, and with values dependency-clamped below the +// ceiling they can lower while every values end stays put. +func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { + t.Parallel() + _, agg := testDbAndAggregatorv3(t, alignStepSize) + + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + require.NoError(t, agg.OpenFolder()) + + craftedClampedVisible(t, agg) + agg.ForbidVisibilityLowering() + + for _, pattern := range []string{ + filepath.Join(agg.Dirs().SnapIdx, "*accounts.1-2.ef"), + filepath.Join(agg.Dirs().SnapAccessors, "*accounts.1-2.efi"), + } { + matches, err := filepath.Glob(pattern) + require.NoError(t, err) + require.NotEmpty(t, matches, pattern) + for _, m := range matches { + require.NoError(t, dir.RemoveFile(m)) + } + } + + require.Panics(t, func() { _ = agg.ReloadFiles() }, + "lowering a history-II end while values ends stay put must trip the forbid assert") +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 54fc690fd0d..8398bacf452 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1063,7 +1063,9 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // Stash every cache-bound domain tuple during the flush; apply them only // after the commit succeeds. On a failed commit the stash is discarded, so - // no cache is ever advanced past durable MDBX state. + // no cache apply ever runs ahead of durable MDBX state. (Reads through + // this SD between flush and a failed commit can still fill flushed + // values; a failed commit is fatal, so they die with the process.) var pending []cacheUpdate stash := func(domain kv.Domain) kv.FlushOption { return kv.WithFlushCallback(domain, func(k []byte, v []byte, step kv.Step, txNum uint64) { From 522c7dae78b024214bb035c59bdb6d6ec69ab9ce Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 10:33:25 +0200 Subject: [PATCH 78/85] execution/cache, db/state, db/kv: state-cache review follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batched applies: Commit routes its pending state updates through Applier.ApplyAll, which takes the admission write lock once per 4096- update chunk instead of once per key — main's pending walk had no global lock at all, so per-key locking was a regression, and chunking bounds how long concurrent RPC fills wait during a big batch apply. Code values are still cloned and hashed outside the lock. ~815 -> ~714 ns/update uncontended; the contention win is the point. Flush now returns an error on a cache-attached SD instead of a doc comment asking callers to route through Commit; the memo test moved to Commit's validate window, which pins the fresher-frontier behavior where it actually matters. kv.TemporalRwDB carries Agg() any, so GuardAggregatorForCache takes the typed DB and a shape that cannot produce its aggregator no longer compiles (membatchwithdb's temporaldb returns nil and fails the guard loudly). The aggregator half stays duck-typed: execctx cannot import db/state. Frontier lookups tolerate a tx whose Debug() is nil (MemoryMutation over a nil db): no exact frontier, no fill, reads unaffected. Fill admission outcomes are counted and reported by PrintStatsAndReset to measure how much reader warming survives a real sync's commit cadence (parallel-exec workers hold one RO tx per run, so their fills are expected to stop after the first mid-run commit). Also state the per-domain admission invariant at appliedEnd and trim view.go rationale that duplicates the PR description. --- db/kv/kv_interface.go | 3 + db/kv/membatchwithdb/memory_mutation.go | 2 + db/state/execctx/domain_shared.go | 110 ++++++------- db/state/execctx/statecache_readfill_test.go | 105 ++++++++++--- execution/cache/apply_all_test.go | 156 +++++++++++++++++++ execution/cache/state_cache.go | 58 ++++++- execution/cache/view.go | 29 +++- 7 files changed, 379 insertions(+), 84 deletions(-) create mode 100644 execution/cache/apply_all_test.go diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 78c4fdd76ef..7bf751c4565 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -658,6 +658,9 @@ type TemporalRwDB interface { BeginTemporalRw(ctx context.Context) (TemporalRwTx, error) BeginTemporalRwNosync(ctx context.Context) (TemporalRwTx, error) UpdateTemporal(ctx context.Context, f func(tx TemporalRwTx) error) error + // Agg returns the DB's state-files aggregator as `any` (the concrete type + // lives above the kv layer); nil when the DB has none. + Agg() any } // ---- non-important utilities diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 1d24f6ce070..46415baf39d 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -1317,6 +1317,8 @@ func (td temporaldb) BeginTemporalRwNosync(ctx context.Context) (kv.TemporalRwTx return td.memoryMutation, nil } +func (td temporaldb) Agg() any { return nil } + func (td temporaldb) Debug() kv.TemporalDebugDB { panic("not implemented") } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 8398bacf452..aafdc67e89e 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -134,7 +134,7 @@ func (m *domainVisibleEndMemo) load(tx kv.TemporalTx, domain kv.Domain, viewID u state = 0 m.viewID.Store(viewID) } - end, ok := tx.Debug().DomainVisibleEnd(domain) + end, ok := debugDomainVisibleEnd(tx, domain) m.ends[domain].Store(end) state |= loadedBit if ok { @@ -157,7 +157,17 @@ func (sd *SharedDomains) domainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (u if _, ok := tx.(kv.TemporalRwTx); ok { return sd.visibleEnds.get(tx, domain) } - return tx.Debug().DomainVisibleEnd(domain) + return debugDomainVisibleEnd(tx, domain) +} + +// debugDomainVisibleEnd tolerates txs without a debug backend (MemoryMutation +// over a nil db): no exact frontier means no fills, reads still work. +func debugDomainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { + dbgTx := tx.Debug() + if dbgTx == nil { + return 0, false + } + return dbgTx.DomainVisibleEnd(domain) } // sdFrontier adapts one (SharedDomains, tx) pair to cache.Frontier: writable @@ -855,22 +865,18 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { // GuardAggregatorForCache forbids visibility lowering on db's aggregator when // sc is a fill-enabled StateCache: fill admission relies on view frontiers // never decreasing. This is the one place that binds the invariant — call it -// wherever a fill-enabled cache is wired over a DB. Duck-typed so the storage -// layer need not know the cache type (and vice versa) — but load-bearing, so -// a db that cannot produce its aggregator fails loudly instead of silently -// dropping the guard. A nil or apply-only cache needs no guard. -func GuardAggregatorForCache(db any, sc *cache.StateCache) { +// wherever a fill-enabled cache is wired over a DB. The aggregator side stays +// duck-typed (this package cannot import db/state) but load-bearing: an +// aggregator without the forbid fails loudly instead of silently dropping the +// guard. A nil or apply-only cache needs no guard. +func GuardAggregatorForCache(db kv.TemporalRwDB, sc *cache.StateCache) { if sc == nil || !sc.FillsEnabled() { return } - h, ok := db.(interface{ Agg() any }) - if !ok { - panic(fmt.Sprintf("assert: fill-enabled StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) - } - agg := h.Agg() + agg := db.Agg() f, ok := agg.(interface{ ForbidVisibilityLowering() }) if !ok { - panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) + panic(fmt.Sprintf("assert: fill-enabled StateCache wired over a DB whose aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) } f.ForbidVisibilityLowering() } @@ -979,22 +985,14 @@ func (sd *SharedDomains) Close() { // admission. // Flush writes the in-memory batch into tx without committing. It deliberately -// does NOT touch the caches: plain Flush leaves the commit to the caller (who -// may still roll back), so it must not warm a cache with state that could be -// rolled back. Cache entries are populated elsewhere — by Commit after a -// successful commit, and by reads (GetLatest) — each stamped with a -// conservative upper-bound txNum. It is that txNum stamp, not population -// timing, that keeps the cache correct: an unwind lowers the floor so every -// entry reflecting a now-dead fork is evicted, and mem-first masking means a -// later in-memory write shadows a stale cached read. -// -// An SD with an attached state cache must route every flush through Commit: -// Flush neither applies nor invalidates, so a populated cache would keep -// serving pre-flush values for the flushed keys after the caller's own -// commit — and Commit collects its cache updates only from its own flush, so -// an earlier plain Flush's keys would never be applied. Cache-less callers -// may Flush and commit themselves. +// does not touch the caches — the caller may still roll back. An SD with a +// state cache must route every flush through Commit: a plain Flush would +// leave the cache serving pre-flush values for the flushed keys forever, so +// it is rejected here. func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { + if sd.stateCache != nil { + return errors.New("SharedDomains with a state cache must flush through Commit") + } defer mxFlushTook.ObserveDuration(time.Now()) return sd.flushMem(ctx, tx) } @@ -1066,24 +1064,32 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // no cache apply ever runs ahead of durable MDBX state. (Reads through // this SD between flush and a failed commit can still fill flushed // values; a failed commit is fatal, so they die with the process.) - var pending []cacheUpdate - stash := func(domain kv.Domain) kv.FlushOption { + var pendingBranch []cacheUpdate + var pendingState []cache.Update + stashState := func(domain kv.Domain) kv.FlushOption { return kv.WithFlushCallback(domain, func(k []byte, v []byte, step kv.Step, txNum uint64) { - pending = append(pending, cacheUpdate{ - domain: domain, - key: append([]byte(nil), k...), - val: append([]byte(nil), v...), - step: step, - txN: txNum, + pendingState = append(pendingState, cache.Update{ + Domain: domain, + Key: append([]byte(nil), k...), + Val: append([]byte(nil), v...), + TxNum: txNum, }) }) } var opts []kv.FlushOption if sd.branchCache != nil { - opts = append(opts, stash(kv.CommitmentDomain)) + opts = append(opts, kv.WithFlushCallback(kv.CommitmentDomain, func(k []byte, v []byte, step kv.Step, txNum uint64) { + pendingBranch = append(pendingBranch, cacheUpdate{ + domain: kv.CommitmentDomain, + key: append([]byte(nil), k...), + val: append([]byte(nil), v...), + step: step, + txN: txNum, + }) + })) } if sd.stateCache != nil { - opts = append(opts, stash(kv.AccountsDomain), stash(kv.StorageDomain)) + opts = append(opts, stashState(kv.AccountsDomain), stashState(kv.StorageDomain)) } // CodeDomain flush stashes state-cache updates and collects code for the // persistent store. The code-store MDBX write is deferred to after flushMem — @@ -1096,12 +1102,11 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(v), append([]byte(nil), v...)}) } if sd.stateCache != nil { - pending = append(pending, cacheUpdate{ - domain: kv.CodeDomain, - key: append([]byte(nil), k...), - val: append([]byte(nil), v...), - step: step, - txN: txNum, + pendingState = append(pendingState, cache.Update{ + Domain: kv.CodeDomain, + Key: append([]byte(nil), k...), + Val: append([]byte(nil), v...), + TxNum: txNum, }) } })) @@ -1172,18 +1177,15 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun if err := tx.Commit(); err != nil { return err } - for i := range pending { - u := &pending[i] - if u.domain == kv.CommitmentDomain { - if len(u.val) == 0 { - sd.branchCache.Invalidate(u.key) - } else { - sd.branchCache.Put(u.key, u.val, uint64(u.step), u.txN) - } - continue + for i := range pendingBranch { + u := &pendingBranch[i] + if len(u.val) == 0 { + sd.branchCache.Invalidate(u.key) + } else { + sd.branchCache.Put(u.key, u.val, uint64(u.step), u.txN) } - sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) } + sd.cacheApplier.ApplyAll(pendingState) return nil } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7c198a3034e..8bb9c92cb3c 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -147,17 +147,46 @@ func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { written[0] = 4 domains.SetTxNum(20) require.NoError(t, domains.DomainPut(kv.AccountsDomain, rwTx, written, encAccount(2), 20, nil)) - require.NoError(t, domains.Flush(ctx, rwTx)) - missing := make([]byte, 20) - missing[0] = 5 - value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) - require.NoError(t, err) - require.Empty(t, value) + // The memo must re-derive inside Commit's validate window (after the + // internal flush, before the tx commits): reads here already see the + // advanced frontier. + require.NoError(t, domains.Commit(ctx, rwTx, func(kv.RwTx) error { + missing := make([]byte, 20) + missing[0] = 5 + value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) + require.NoError(t, err) + require.Empty(t, value) + return nil + })) require.Equal(t, uint64(2), debug.calls) require.Greater(t, debug.last, initialEnd) } +// An SD with a state cache must route every flush through Commit: a plain +// Flush neither applies nor invalidates, so the cache would keep serving +// pre-flush values for the flushed keys forever. +func TestFlushRejectsCacheAttachedSD(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + + require.NoError(t, domains.Flush(ctx, rwTx), "cache-less SDs may flush and commit themselves") + + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + require.Error(t, domains.Flush(ctx, rwTx)) +} + // During an in-flight unwind the mem overlay bounds reads of an affected key // by maxStep while MDBX still holds the not-yet-deleted dying row inside that // bound. A cache hit legitimately below the unwind floor then diverges from @@ -349,33 +378,67 @@ type fakeForbidder struct{ called bool } func (f *fakeForbidder) ForbidVisibilityLowering() { f.called = true } -type fakeHasAgg struct{ f *fakeForbidder } - -func (h fakeHasAgg) Agg() any { return h.f } - -type fakeHasBadAgg struct{} +// fakeTemporalDB satisfies kv.TemporalRwDB by embedding (the interface now +// carries Agg, so a DB shape without it no longer compiles); only Agg is +// implemented — the guard must not touch anything else. +type fakeTemporalDB struct { + kv.TemporalRwDB + agg any +} -func (fakeHasBadAgg) Agg() any { return struct{}{} } +func (d fakeTemporalDB) Agg() any { return d.agg } // The guard is load-bearing: for a fill-enabled cache it must either bind the -// invariant or fail loudly — never silently drop it on a DB shape mismatch. -// A nil or apply-only cache needs no guard at all. +// invariant or fail loudly — never silently drop it. A nil or apply-only +// cache needs no guard at all. func TestGuardAggregatorForCache(t *testing.T) { sc := newSmallStateCache() t.Cleanup(sc.Close) f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + execctx.GuardAggregatorForCache(fakeTemporalDB{agg: f}, sc) require.True(t, f.called) - require.NotPanics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, nil) }, - "no cache, no invariant to bind — shape is irrelevant") - require.Panics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, sc) }, - "a db that cannot produce its aggregator must fail loudly, not drop the guard") - require.Panics(t, func() { execctx.GuardAggregatorForCache(fakeHasBadAgg{}, sc) }, + require.NotPanics(t, func() { execctx.GuardAggregatorForCache(fakeTemporalDB{}, nil) }, + "no cache, no invariant to bind — the aggregator is never consulted") + require.Panics(t, func() { execctx.GuardAggregatorForCache(fakeTemporalDB{agg: struct{}{}}, sc) }, "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the guard") } +type nilDebugRwTx struct { + kv.TemporalRwTx +} + +func (nilDebugRwTx) Debug() kv.TemporalDebugTx { return nil } + +// A tx without a debug backend (MemoryMutation over a nil db) has no exact +// frontier: reads must still work and simply never fill. +func TestReadFill_NilDebugTxSkipsFills(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + + baseTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer baseTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, baseTx, log.New()) + require.NoError(t, err) + defer domains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + missing := make([]byte, 20) + missing[0] = 7 + value, _, err := domains.GetLatest(kv.AccountsDomain, nilDebugRwTx{TemporalRwTx: baseTx}, missing) + require.NoError(t, err) + require.Empty(t, value) + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, missing) + require.False(t, ok, "no exact frontier means no fill") +} + // An apply-only cache (STATE_CACHE_FILLS=false) has no fills for a lowered // frontier to poison, so the guard must not constrain the aggregator. func TestGuardAggregatorForCache_ApplyOnlySkips(t *testing.T) { @@ -384,6 +447,6 @@ func TestGuardAggregatorForCache_ApplyOnlySkips(t *testing.T) { t.Cleanup(sc.Close) f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + execctx.GuardAggregatorForCache(fakeTemporalDB{agg: f}, sc) require.False(t, f.called) } diff --git a/execution/cache/apply_all_test.go b/execution/cache/apply_all_test.go new file mode 100644 index 00000000000..2003a06553a --- /dev/null +++ b/execution/cache/apply_all_test.go @@ -0,0 +1,156 @@ +// 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 ( + "encoding/binary" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/db/kv" +) + +func applyAllTestCache(t *testing.T) *StateCache { + t.Helper() + c := NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(c.Close) + return c +} + +// ApplyAll must be observationally identical to per-key Apply: same entries, +// same deletions and cascades, same frontier advance (so the same fills are +// rejected afterwards). Only the locking is batched. +func TestApplierApplyAllMatchesPerKeyApply(t *testing.T) { + t.Parallel() + + addr := make([]byte, 20) + addr[0] = 1 + deleted := make([]byte, 20) + deleted[0] = 2 + slot := make([]byte, 52) + slot[0] = 3 + code := []byte{0x60, 0x00, 0x60, 0x00} + + updates := []Update{ + {Domain: kv.AccountsDomain, Key: append([]byte(nil), addr...), Val: []byte{1}, TxNum: 30}, + {Domain: kv.AccountsDomain, Key: append([]byte(nil), deleted...), Val: nil, TxNum: 31}, + {Domain: kv.StorageDomain, Key: append([]byte(nil), slot...), Val: []byte{7}, TxNum: 32}, + {Domain: kv.CodeDomain, Key: append([]byte(nil), addr...), Val: append([]byte(nil), code...), TxNum: 33}, + } + + perKey := applyAllTestCache(t) + for _, u := range updates { + perKey.Applier().Apply(u.Domain, u.Key, u.Val, u.TxNum) + } + batched := applyAllTestCache(t) + batched.Applier().ApplyAll(append([]Update(nil), updates...)) + + for name, c := range map[string]*StateCache{"per-key": perKey, "batched": batched} { + v, ok := c.View(nil).Get(kv.AccountsDomain, addr) + require.True(t, ok, name) + require.Equal(t, []byte{1}, v, name) + _, ok = c.View(nil).Get(kv.AccountsDomain, deleted) + require.False(t, ok, name) + v, ok = c.View(nil).Get(kv.StorageDomain, slot) + require.True(t, ok, name) + require.Equal(t, []byte{7}, v, name) + gotCode, ok := c.View(nil).GetCodeByHash(crypto.Keccak256(code)) + require.True(t, ok, name) + require.Equal(t, code, gotCode, name) + + staleView := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 20, true })) + staleKey := make([]byte, 20) + staleKey[0] = 9 + staleView.Fill(kv.AccountsDomain, staleKey, []byte{9}, 5) + _, ok = c.View(nil).Get(kv.AccountsDomain, staleKey) + require.False(t, ok, "%s: the batch apply must advance the frontier and reject stale fills", name) + } +} + +// One batch may span several chunks; entries on both sides of the chunk +// boundary must land. +func TestApplierApplyAllCrossesChunkBoundary(t *testing.T) { + t.Parallel() + + c := applyAllTestCache(t) + n := applyChunkSize + 3 + updates := make([]Update, 0, n) + for i := range n { + key := make([]byte, 20) + binary.BigEndian.PutUint32(key, uint32(i)) + updates = append(updates, Update{Domain: kv.AccountsDomain, Key: key, Val: []byte{1}, TxNum: uint64(i)}) + } + c.Applier().ApplyAll(updates) + + for _, i := range []int{0, applyChunkSize - 1, applyChunkSize, n - 1} { + key := make([]byte, 20) + binary.BigEndian.PutUint32(key, uint32(i)) + _, ok := c.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "index %d", i) + } +} + +// The admission counters distinguish surviving reader warming from rejected +// stale fills. +func TestFillAdmissionCounters(t *testing.T) { + t.Parallel() + + c := applyAllTestCache(t) + fresh := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 100, true })) + key := make([]byte, 20) + key[0] = 1 + fresh.Fill(kv.AccountsDomain, key, []byte{1}, 50) + require.EqualValues(t, 1, c.fillsAdmitted.Load()) + require.EqualValues(t, 0, c.fillsRejected.Load()) + + c.Applier().Apply(kv.AccountsDomain, key, []byte{2}, 200) + stale := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 100, true })) + stale.Fill(kv.AccountsDomain, key, []byte{1}, 50) + require.EqualValues(t, 1, c.fillsAdmitted.Load()) + require.EqualValues(t, 1, c.fillsRejected.Load()) +} + +func BenchmarkApplierApply(b *testing.B) { + for _, batched := range []bool{false, true} { + b.Run(fmt.Sprintf("batched=%t", batched), func(b *testing.B) { + c := NewStateCache(64<<20, 64<<20, 64<<20, 64<<20) + defer c.Close() + const n = 100_000 + updates := make([]Update, 0, n) + for i := range n { + key := make([]byte, 20) + binary.BigEndian.PutUint32(key, uint32(i)) + updates = append(updates, Update{Domain: kv.AccountsDomain, Key: key, Val: key[:8], TxNum: uint64(i)}) + } + applier := c.Applier() + b.ResetTimer() + for b.Loop() { + if batched { + applier.ApplyAll(updates) + } else { + for _, u := range updates { + applier.Apply(u.Domain, u.Key, u.Val, u.TxNum) + } + } + } + b.ReportMetric(float64(b.Elapsed().Nanoseconds())/float64(b.N)/n, "ns/update") + }) + } +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 276871b07cc..62c57193a73 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -21,6 +21,7 @@ import ( "math" "strings" "sync" + "sync/atomic" "github.com/c2h5oh/datasize" @@ -61,7 +62,15 @@ type StateCache struct { // admissionMu makes Apply's frontier advance + cache mutation atomic // against concurrent read-fills, which recheck freshness under RLock. admissionMu sync.RWMutex - appliedEnd [kv.DomainLen]uint64 + // appliedEnd is per domain, necessarily: a domain's frontier advances only + // on its own writes, so a single global applied end would reject every + // quiet domain's fills. + appliedEnd [kv.DomainLen]uint64 + // fillsAdmitted/fillsRejected count admission-gate outcomes, reported by + // PrintStatsAndReset — the lens on how much reader warming survives at a + // given commit cadence. + fillsAdmitted atomic.Uint64 + fillsRejected atomic.Uint64 // disableFills (STATE_CACHE_FILLS=false) turns off every reader fill // (including the content-addressed ones), leaving applies as the only // writer ("apply-only" mode) — an A/B lever and an operational kill switch. @@ -260,8 +269,10 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea c.admissionMu.RLock() defer c.admissionMu.RUnlock() if visibleEnd < c.appliedEnd[domain] { + c.fillsRejected.Add(1) return } + c.fillsAdmitted.Add(1) cache.PutIfAbsent(key, cloned, readTxNum) } @@ -280,8 +291,10 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl c.admissionMu.RLock() defer c.admissionMu.RUnlock() if visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { + c.fillsRejected.Add(1) return } + c.fillsAdmitted.Add(1) codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) } @@ -311,6 +324,45 @@ func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() + c.applyLocked(cache, domain, key, value, txNum, codeHash) +} + +// applyChunkSize bounds one exclusive critical section of applyAll, so a huge +// batch apply never starves concurrent fills for its whole duration. +const applyChunkSize = 4096 + +func (c *StateCache) applyAll(updates []Update) { + for start := 0; start < len(updates); start += applyChunkSize { + chunk := updates[start:min(start+applyChunkSize, len(updates))] + var codeHashes [][]byte + for i := range chunk { + u := &chunk[i] + if u.Domain == kv.CodeDomain && len(u.Val) > 0 { + if codeHashes == nil { + codeHashes = make([][]byte, len(chunk)) + } + u.Val = bytes.Clone(u.Val) + codeHashes[i] = crypto.Keccak256(u.Val) + } + } + c.admissionMu.Lock() + for i := range chunk { + u := &chunk[i] + cache := c.caches[u.Domain] + if cache == nil { + continue + } + var codeHash []byte + if codeHashes != nil { + codeHash = codeHashes[i] + } + c.applyLocked(cache, u.Domain, u.Key, u.Val, u.TxNum, codeHash) + } + c.admissionMu.Unlock() + } +} + +func (c *StateCache) applyLocked(cache Cache, domain kv.Domain, key, value []byte, txNum uint64, codeHash []byte) { c.noteApplied(domain, txNum) switch domain { @@ -413,6 +465,10 @@ func (c *StateCache) PrintStatsAndReset() { if c == nil { return } + admitted, rejected := c.fillsAdmitted.Swap(0), c.fillsRejected.Swap(0) + if admitted+rejected > 0 { + log.Info("[cache] fill admission", "admitted", admitted, "rejected", rejected) + } if acc, ok := c.caches[kv.AccountsDomain].(*DomainCache); ok { acc.PrintStatsAndReset("Account") } diff --git a/execution/cache/view.go b/execution/cache/view.go index 7359ad30847..415b2a5caec 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -44,11 +44,8 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // inert: reads miss, fills no-op. // // A ReadView does not isolate reads: the cache holds latest-applied state, so -// a hit can be newer than the view — the same direction the exec overlay -// already serves. In the forward direction the cache's invariant is -// monotonicity (content never regresses behind the applied frontier), -// enforced on the fill side; unwinds invalidate by epoch and floor. -// Snapshot-isolated caching is kvcache's job (node/shards). +// a hit can be newer than the view. Snapshot-isolated caching is kvcache's +// job (node/shards). type ReadView struct { c *StateCache frontier Frontier @@ -109,9 +106,7 @@ func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } // Fill offers a value read from this view without replacing an authoritative // entry. Admission is checked against the view's frontier for the domain; // views without an exact frontier skip the fill. A code fill also checks the -// accounts frontier: an addr-keyed code entry derives from the account — an -// account deletion drops it without advancing the code frontier — so a view -// that predates the deletion must not refill it (mirrors SeedAddrCodeHash). +// accounts frontier — see fillCodeIfFresh for why. func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uint64) { if v.c == nil || v.c.disableFills || v.frontier == nil { return @@ -175,6 +170,24 @@ func (a Applier) Apply(domain kv.Domain, key, value []byte, txNum uint64) { a.c.apply(domain, key, value, txNum) } +// Update is one authoritative committed tuple for ApplyAll. +type Update struct { + Domain kv.Domain + Key []byte + Val []byte + TxNum uint64 +} + +// ApplyAll is Apply over a batch: the write lock is taken once per chunk +// instead of once per key, bounding how long concurrent fills wait. Code +// values are cloned (and hashed) outside the lock. +func (a Applier) ApplyAll(updates []Update) { + if a.c == nil { + return + } + a.c.applyAll(updates) +} + // Unwind invalidates, across all caches, entries reflecting state above // unwindToTxNum on a now-dead fork, and lowers the applied frontiers. func (a Applier) Unwind(unwindToTxNum uint64) { From 3adae16fae50fc4ebe7bd9b04acd4edc7b856400 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 11:52:50 +0200 Subject: [PATCH 79/85] db/state: drop the history-II segment in memory in the lowering test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test removed a still-mapped .ef file from disk, which Windows forbids — both windows CI shards failed on ReloadFiles' remove. CloseIf deletes the dirty item and closes its mmaps, exercising the same recalcVisibleFiles chokepoint on every platform. Red-on-revert of the history-II assert arm re-verified with the new trigger. --- db/state/aggregator_align_test.go | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index b3e5226e55c..01495a32869 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -18,12 +18,10 @@ package state import ( "context" - "path/filepath" "testing" "github.com/stretchr/testify/require" - "github.com/erigontech/erigon/common/dir" "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/kv" ) @@ -245,18 +243,21 @@ func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { craftedClampedVisible(t, agg) agg.ForbidVisibilityLowering() - for _, pattern := range []string{ - filepath.Join(agg.Dirs().SnapIdx, "*accounts.1-2.ef"), - filepath.Join(agg.Dirs().SnapAccessors, "*accounts.1-2.efi"), - } { - matches, err := filepath.Glob(pattern) - require.NoError(t, err) - require.NotEmpty(t, matches, pattern) - for _, m := range matches { - require.NoError(t, dir.RemoveFile(m)) + // Drop the accounts history-II {1,2} segment in memory rather than from + // disk (Windows forbids removing a mapped file): the recalculation lowers + // the ii end while every values end stays put. + agg.dirtyFilesLock.Lock() + defer agg.dirtyFilesLock.Unlock() + dropped := 0 + agg.d[kv.AccountsDomain].History.InvertedIndex.dirtyFiles.CloseIf(func(item *FilesItem) bool { + if item.endTxNum == 2*alignStepSize { + dropped++ + return true } - } + return false + }) + require.Equal(t, 1, dropped) - require.Panics(t, func() { _ = agg.ReloadFiles() }, + require.Panics(t, func() { agg.recalcVisibleFiles(nil) }, "lowering a history-II end while values ends stay put must trip the forbid assert") } From 58ae69461309a052c00d422612a1e9c2f8790f43 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 13:01:13 +0200 Subject: [PATCH 80/85] db/state: a dependency-clamped values view has no exact frontier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting the values end kept fills flowing from a view that is not consistent as of any txNum: DB-resident keys read fresh while gap keys read older file values, and raising the dependent file's visibility later reveals state without any cache apply — nothing would ever invalidate a fill (or a negative entry) made during the clamp, so a cold cache could serve stale data until the key's next write. DomainVisibleEnd now returns ok=false while clamped: reads work, fills are skipped. Red-first via the flipped test expectation. --- db/state/aggregator.go | 12 +++++++----- db/state/aggregator_align_test.go | 17 ++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 231dfd08805..03c0109a129 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2643,11 +2643,13 @@ func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bo if d.d.HistoryDisabled { return 0, false } - // A dependency checker can clamp the values view below the history-II end; - // reads in that gap fall back to older file values, so the frontier must - // not overstate the values coverage. - if valuesEnd := d.files.EndTxNum(); valuesEnd < d.ht.iit.files.EndTxNum() { - return valuesEnd, true + // A dependency checker can clamp the values view below the history-II end. + // Such a view has no exact frontier: reads mix fresh DB-resident keys with + // older file values for the gap, and raising the dependent file's + // visibility later reveals state without any cache apply — a fill made + // during the clamp would never be invalidated. + if d.files.EndTxNum() < d.ht.iit.files.EndTxNum() { + return 0, false } return d.ht.iit.visibleEnd(tx), true } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 01495a32869..2d4be50b798 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -201,11 +201,12 @@ func craftedClampedVisible(t *testing.T, agg *Aggregator) { agg.visible.Store(crafted) } -// A view's frontier must not overstate what its values view can serve: with -// domain values clamped below the history-II end (dependency checker), reads -// above the values end fall back to older file values, so DomainVisibleEnd -// must report the values end, not the II end. -func TestDomainVisibleEnd_ClampedToValuesCoverage(t *testing.T) { +// A dependency-clamped values view has no exact frontier: reads mix fresh +// DB-resident keys with older file values for gap keys, and raising the +// dependent file's visibility later reveals state without any cache apply — +// nothing would invalidate a fill made during the clamp. DomainVisibleEnd +// must report ok=false so such views never fill. +func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { t.Parallel() db, agg := testDbAndAggregatorv3(t, alignStepSize) @@ -222,10 +223,8 @@ func TestDomainVisibleEnd_ClampedToValuesCoverage(t *testing.T) { require.NoError(t, err) defer tx.Rollback() - end, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) - require.True(t, ok) - require.Equal(t, uint64(1*alignStepSize), end, - "the frontier must not overstate the values coverage") + _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) + require.False(t, ok, "a dependency-clamped values view has no exact frontier") } // The forbid assert must also watch the history-II ends: they are the base of From 55bc6e6e316e889c5d2b5a5adaf29712c57fba61 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 13:01:13 +0200 Subject: [PATCH 81/85] db/state: a dependency-clamped values view has no exact frontier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reporting the values end kept fills flowing from a view that is not consistent as of any txNum: DB-resident keys read fresh while gap keys read older file values, and raising the dependent file's visibility later reveals state without any cache apply — nothing would ever invalidate a fill (or a negative entry) made during the clamp, so a cold cache could serve stale data until the key's next write. DomainVisibleEnd now returns ok=false while clamped: reads work, fills are skipped. Red-first via the flipped test expectation. --- db/state/aggregator.go | 12 +++++++----- db/state/aggregator_align_test.go | 17 ++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 231dfd08805..03c0109a129 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2643,11 +2643,13 @@ func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bo if d.d.HistoryDisabled { return 0, false } - // A dependency checker can clamp the values view below the history-II end; - // reads in that gap fall back to older file values, so the frontier must - // not overstate the values coverage. - if valuesEnd := d.files.EndTxNum(); valuesEnd < d.ht.iit.files.EndTxNum() { - return valuesEnd, true + // A dependency checker can clamp the values view below the history-II end. + // Such a view has no exact frontier: reads mix fresh DB-resident keys with + // older file values for the gap, and raising the dependent file's + // visibility later reveals state without any cache apply — a fill made + // during the clamp would never be invalidated. + if d.files.EndTxNum() < d.ht.iit.files.EndTxNum() { + return 0, false } return d.ht.iit.visibleEnd(tx), true } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 01495a32869..2d4be50b798 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -201,11 +201,12 @@ func craftedClampedVisible(t *testing.T, agg *Aggregator) { agg.visible.Store(crafted) } -// A view's frontier must not overstate what its values view can serve: with -// domain values clamped below the history-II end (dependency checker), reads -// above the values end fall back to older file values, so DomainVisibleEnd -// must report the values end, not the II end. -func TestDomainVisibleEnd_ClampedToValuesCoverage(t *testing.T) { +// A dependency-clamped values view has no exact frontier: reads mix fresh +// DB-resident keys with older file values for gap keys, and raising the +// dependent file's visibility later reveals state without any cache apply — +// nothing would invalidate a fill made during the clamp. DomainVisibleEnd +// must report ok=false so such views never fill. +func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { t.Parallel() db, agg := testDbAndAggregatorv3(t, alignStepSize) @@ -222,10 +223,8 @@ func TestDomainVisibleEnd_ClampedToValuesCoverage(t *testing.T) { require.NoError(t, err) defer tx.Rollback() - end, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) - require.True(t, ok) - require.Equal(t, uint64(1*alignStepSize), end, - "the frontier must not overstate the values coverage") + _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) + require.False(t, ok, "a dependency-clamped values view has no exact frontier") } // The forbid assert must also watch the history-II ends: they are the base of From f256418b527655bd0e5e7c62b65f8207031d108e Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 14:06:32 +0200 Subject: [PATCH 82/85] db/state/execctx: pin the incoherence the Flush rejection prevents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end shape of the review reproduction: commit v1 (the cache holds it), write v2 through another cache-attached SD — a plain Flush plus tx.Commit would leave the cache serving v1 while MDBX holds v2. The test pins the rejection at exactly that step and that routing through Commit keeps the cache coherent. Fails without the Flush guard (verified by reverting it). --- db/state/execctx/statecache_readfill_test.go | 51 ++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 8bb9c92cb3c..00a26cda551 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -187,6 +187,57 @@ func TestFlushRejectsCacheAttachedSD(t *testing.T) { require.Error(t, domains.Flush(ctx, rwTx)) } +// The incoherence the Flush rejection prevents, end to end: after v1 is +// committed (the cache holds it), flushing v2 through another cache-attached +// SD and committing the tx would leave the cache serving v1 while MDBX holds +// v2. The rejection fires at exactly that step; routing through Commit keeps +// the cache coherent. +func TestFlushRejectionPreventsStaleCachedReads(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + + slot := make([]byte, 52) + slot[0] = 1 + v1, v2 := []byte{1}, []byte{2} + + tx1, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx1.Rollback() + sd1, err := execctx.NewSharedDomains(ctx, tx1, log.New()) + require.NoError(t, err) + defer sd1.Close() + sd1.SetStateCacheForTest(stateCache) + sd1.SetTxNum(10) + require.NoError(t, sd1.DomainPut(kv.StorageDomain, tx1, slot, v1, 10, nil)) + require.NoError(t, sd1.Commit(ctx, tx1)) + sd1.Close() + + got, ok := stateCache.View(nil).Get(kv.StorageDomain, slot) + require.True(t, ok) + require.Equal(t, v1, got) + + tx2, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx2.Rollback() + sd2, err := execctx.NewSharedDomains(ctx, tx2, log.New()) + require.NoError(t, err) + defer sd2.Close() + sd2.SetStateCacheForTest(stateCache) + sd2.SetTxNum(20) + require.NoError(t, sd2.DomainPut(kv.StorageDomain, tx2, slot, v2, 20, nil)) + require.Error(t, sd2.Flush(ctx, tx2), + "the step that would split the cache (v1) from MDBX (v2) must be rejected") + + require.NoError(t, sd2.Commit(ctx, tx2)) + got, ok = stateCache.View(nil).Get(kv.StorageDomain, slot) + require.True(t, ok) + require.Equal(t, v2, got, "Commit keeps the cache coherent with MDBX") +} + // During an in-flight unwind the mem overlay bounds reads of an affected key // by maxStep while MDBX still holds the not-yet-deleted dying row inside that // bound. A cache hit legitimately below the unwind floor then diverges from From 7ca7ec7d72c000feb715ade0402bf5ad3c1412ce Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 14:21:26 +0200 Subject: [PATCH 83/85] execution/cache, db/state/execctx, cmd/integration: bind the aggregator at the cache, assert at wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combine the guard with the cache per review: GuardAggregatorForCache becomes StateCache.BindAggregator, and SetStateCache asserts the binding before wiring a fill-enabled cache — a future call site cannot forget the load-bearing guard. Integration wiring reordered to bind before wiring. Apply-only mode (STATE_CACHE_FILLS=false) no longer binds a frontier on the plain miss path just for Fill to no-op — one allocation per cold miss saved, pinned by an AllocsPerRun test. CanFill now means what it says: fills can go through this view (frontier present and fills enabled). --- cmd/integration/commands/stages.go | 2 +- db/state/execctx/domain_shared.go | 29 ++----- db/state/execctx/statecache_readfill_test.go | 79 +++++++++++++++++--- execution/cache/state_cache.go | 27 +++++++ execution/cache/view.go | 7 +- execution/execmodule/exec_module.go | 2 +- 6 files changed, 108 insertions(+), 38 deletions(-) diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index cd3b743f852..a25fb76a74a 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -844,9 +844,9 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn } defer doms.Close() doms.SetInMemHistoryReads(false) + stateCache.BindAggregator(db) doms.SetStateCache(stateCache) doms.SetCodeStore(codeStore) - execctx.GuardAggregatorForCache(db, stateCache) s, err := st.StageState(stages.Execution, tx, initialCycle, false) if err != nil { diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index aafdc67e89e..26ed7f9916e 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -858,29 +858,13 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } + if stateCache.FillsEnabled() && !stateCache.AggregatorBound() { + panic("assert: fill-enabled StateCache wired before BindAggregator — the visibility-lowering guard is not bound") + } sd.stateCache = stateCache sd.cacheApplier = stateCache.Applier() } -// GuardAggregatorForCache forbids visibility lowering on db's aggregator when -// sc is a fill-enabled StateCache: fill admission relies on view frontiers -// never decreasing. This is the one place that binds the invariant — call it -// wherever a fill-enabled cache is wired over a DB. The aggregator side stays -// duck-typed (this package cannot import db/state) but load-bearing: an -// aggregator without the forbid fails loudly instead of silently dropping the -// guard. A nil or apply-only cache needs no guard. -func GuardAggregatorForCache(db kv.TemporalRwDB, sc *cache.StateCache) { - if sc == nil || !sc.FillsEnabled() { - return - } - agg := db.Agg() - f, ok := agg.(interface{ ForbidVisibilityLowering() }) - if !ok { - panic(fmt.Sprintf("assert: fill-enabled StateCache wired over a DB whose aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) - } - f.ForbidVisibilityLowering() -} - // SetCodeStore sets the persistent codehash-keyed code cache. func (sd *SharedDomains) SetCodeStore(codeStore *cache.CodeStore) { sd.codeStore = codeStore @@ -1347,8 +1331,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k } // View freshness is rechecked while the fill is serialized against - // committed cache updates. - if sd.stateCache != nil && sd.stateCache.Caches(domain) { + // committed cache updates. Apply-only mode skips the block: binding a + // frontier for a fill that will no-op is a wasted allocation. + if sd.stateCache != nil && sd.stateCache.FillsEnabled() && sd.stateCache.Caches(domain) { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 fillView := view if !fillView.CanFill() { @@ -1543,7 +1528,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, } h, fromReadView := resolve() - if fromReadView && sd.stateCache != nil { + if fromReadView && sd.stateCache != nil && sd.stateCache.FillsEnabled() { var fixed [32]byte if len(h) == 32 { copy(fixed[:], h) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 00a26cda551..382501b8b6d 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -439,21 +439,25 @@ type fakeTemporalDB struct { func (d fakeTemporalDB) Agg() any { return d.agg } -// The guard is load-bearing: for a fill-enabled cache it must either bind the -// invariant or fail loudly — never silently drop it. A nil or apply-only -// cache needs no guard at all. -func TestGuardAggregatorForCache(t *testing.T) { +// The binding is load-bearing: for a fill-enabled cache it must either bind +// the invariant or fail loudly — never silently drop it. A nil or apply-only +// cache needs no binding at all. +func TestBindAggregator(t *testing.T) { sc := newSmallStateCache() t.Cleanup(sc.Close) f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeTemporalDB{agg: f}, sc) + sc.BindAggregator(fakeTemporalDB{agg: f}) require.True(t, f.called) + require.True(t, sc.AggregatorBound()) - require.NotPanics(t, func() { execctx.GuardAggregatorForCache(fakeTemporalDB{}, nil) }, + var nilCache *cache.StateCache + require.NotPanics(t, func() { nilCache.BindAggregator(fakeTemporalDB{}) }, "no cache, no invariant to bind — the aggregator is never consulted") - require.Panics(t, func() { execctx.GuardAggregatorForCache(fakeTemporalDB{agg: struct{}{}}, sc) }, - "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the guard") + sc2 := newSmallStateCache() + t.Cleanup(sc2.Close) + require.Panics(t, func() { sc2.BindAggregator(fakeTemporalDB{agg: struct{}{}}) }, + "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the binding") } type nilDebugRwTx struct { @@ -490,14 +494,67 @@ func TestReadFill_NilDebugTxSkipsFills(t *testing.T) { require.False(t, ok, "no exact frontier means no fill") } +// The binding is asserted at the real wiring point, so no future call site +// can wire a fill-enabled cache while forgetting the aggregator guard. +func TestSetStateCacheRequiresBoundAggregator(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 16) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + + unbound := newSmallStateCache() + t.Cleanup(unbound.Close) + require.Panics(t, func() { domains.SetStateCache(unbound) }, + "wiring a fill-enabled cache without a bound aggregator must fail loudly") + + bound := newSmallStateCache() + t.Cleanup(bound.Close) + f := &fakeForbidder{} + bound.BindAggregator(fakeTemporalDB{agg: f}) + require.True(t, f.called) + require.NotPanics(t, func() { domains.SetStateCache(bound) }) +} + +// Apply-only mode must not pay for fills it will never make: the plain miss +// path used to box a frontier only for the fill to no-op. +func TestApplyOnlyMissPathBindsNoFrontier(t *testing.T) { + t.Setenv("STATE_CACHE_FILLS", "false") + + ctx := t.Context() + db := newTestDb(t, 16) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + missing := make([]byte, 20) + missing[0] = 7 + allocs := testing.AllocsPerRun(100, func() { + v, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) + if err != nil || len(v) != 0 { + t.Fatalf("expected a clean negative read, got %x %v", v, err) + } + }) + require.Zero(t, allocs, "an apply-only cache must not bind a frontier on the miss path") +} + // An apply-only cache (STATE_CACHE_FILLS=false) has no fills for a lowered -// frontier to poison, so the guard must not constrain the aggregator. -func TestGuardAggregatorForCache_ApplyOnlySkips(t *testing.T) { +// frontier to poison, so the binding must not constrain the aggregator. +func TestBindAggregator_ApplyOnlySkips(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") sc := newSmallStateCache() t.Cleanup(sc.Close) f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeTemporalDB{agg: f}, sc) + sc.BindAggregator(fakeTemporalDB{agg: f}) require.False(t, f.called) } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 62c57193a73..d9983b8e70f 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -18,6 +18,7 @@ package cache import ( "bytes" + "fmt" "math" "strings" "sync" @@ -71,6 +72,9 @@ type StateCache struct { // given commit cadence. fillsAdmitted atomic.Uint64 fillsRejected atomic.Uint64 + // aggBound records that BindAggregator ran; SetStateCache asserts it + // before wiring a fill-enabled cache. + aggBound atomic.Bool // disableFills (STATE_CACHE_FILLS=false) turns off every reader fill // (including the content-addressed ones), leaving applies as the only // writer ("apply-only" mode) — an A/B lever and an operational kill switch. @@ -420,6 +424,29 @@ func (c *StateCache) clear() { } } +// BindAggregator forbids visibility lowering on db's aggregator for a +// fill-enabled cache: fill admission relies on view frontiers never +// decreasing. SharedDomains.SetStateCache asserts this binding, so wiring +// cannot forget it. The aggregator side is duck-typed (the concrete type +// lives in db/state, above this package) but load-bearing: an aggregator +// without the forbid fails loudly. A nil or apply-only cache needs no +// binding. +func (c *StateCache) BindAggregator(db kv.TemporalRwDB) { + if c == nil || !c.FillsEnabled() { + return + } + agg := db.Agg() + f, ok := agg.(interface{ ForbidVisibilityLowering() }) + if !ok { + panic(fmt.Sprintf("assert: fill-enabled StateCache bound to a DB whose aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) + } + f.ForbidVisibilityLowering() + c.aggBound.Store(true) +} + +// AggregatorBound reports whether BindAggregator ran. +func (c *StateCache) AggregatorBound() bool { return c.aggBound.Load() } + // Close releases every sub-cache's slot in the shared memory envelope so later // caches size against real concurrency. Idempotent. func (c *StateCache) Close() { diff --git a/execution/cache/view.go b/execution/cache/view.go index 415b2a5caec..4ff91b4b127 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -99,9 +99,10 @@ func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return v.c.getAddrCodeHash(addr) } -// CanFill reports whether this view carries a frontier, i.e. Fill and -// SeedAddrCodeHash can admit values through it. -func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } +// CanFill reports whether fills can go through this view: it carries a +// frontier and fills are enabled. A frontier answering ok=false for a domain +// is still decided at fill time. +func (v ReadView) CanFill() bool { return v.c != nil && !v.c.disableFills && v.frontier != nil } // Fill offers a value read from this view without replacing an authoritative // entry. Admission is checked against the view's frontier for the domain; diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 9a5b3204633..0e2854da32a 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -255,7 +255,7 @@ func NewExecModule( stopNode func() error, ) *ExecModule { domainCache := newDomainStateCache(stateCacheBudget) - execctx.GuardAggregatorForCache(db, domainCache) + domainCache.BindAggregator(db) var codeStore *cache.CodeStore if dbg.UseCodeStore { codeStore = cache.NewCodeStore(cache.DefaultCodeStoreMemBytes, cache.DefaultCodeStoreTableBytes) From 3c980056f667a3f98728bdc25c2e223b749ef1fa Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 15:36:50 +0200 Subject: [PATCH 84/85] execution/execmodule: wire frozen-block startup processing to the state cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessFrozenBlocks advanced durable state through SharedDomains with no attached cache — the one writer outside the apply stream. Engine endpoints are live before Start, so pre-catchup reads could populate the cache and nothing ever overwrote or fenced those entries; a read view opened before the boundary could refill stale state even across a clear. Both SD creation sites now go through newFrozenBlocksSD, which attaches the module's state cache and code store: catchup commits apply to the cache post-commit and advance the admission frontier, so pre-catchup fills are rejected by the ordinary gate — admission is the fence. The test pins both halves: applies overwrite a pre-seeded stale entry, and a pre-catchup view's refill is rejected. Closes #22925. --- execution/execmodule/exec_module.go | 2 +- .../execmodule/exec_module_internal_test.go | 47 +++++++++++++++++++ execution/execmodule/executor.go | 24 ++++++++-- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 0e2854da32a..fa43aef4f6b 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -702,7 +702,7 @@ func (e *ExecModule) Start(ctx context.Context, hook *stageloop.Hook) { } defer e.semaphore.Release(1) - if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart); err != nil { + if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart, e.stateCache, e.codeStore); err != nil { if !errors.Is(err, context.Canceled) { e.logger.Error("Could not start execution service", "err", err) } diff --git a/execution/execmodule/exec_module_internal_test.go b/execution/execmodule/exec_module_internal_test.go index f2420c0ff57..9892a600520 100644 --- a/execution/execmodule/exec_module_internal_test.go +++ b/execution/execmodule/exec_module_internal_test.go @@ -23,6 +23,11 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/execution/cache" ) // The module is the one owner of the domain state cache: callers pass a byte @@ -44,3 +49,45 @@ func TestNewDomainStateCacheRespectsUseStateCache(t *testing.T) { require.NotNil(t, scDefault, "zero budget means the production default, not no cache") scDefault.Close() } + +// Frozen-block startup processing must advance state through the cache like +// every other writer: its post-commit applies overwrite pre-catchup entries +// and advance the admission frontier, so a read view opened before catchup +// cannot refill stale values (issue 22925). +func TestFrozenBlocksSDWiredToStateCache(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := temporaltest.NewTestDB(t, datadir.New(t.TempDir())) + sc := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(sc.Close) + sc.BindAggregator(db) + + addr := make([]byte, 20) + addr[0] = 1 + stale := []byte{1} + sc.Applier().Apply(kv.AccountsDomain, addr, stale, 5) + + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + pe := &PipelineExecutor{logger: log.New()} + sd, err := pe.newFrozenBlocksSD(ctx, tx, sc, nil) + require.NoError(t, err) + defer sd.Close() + + fresh := []byte{2} + sd.SetTxNum(20) + require.NoError(t, sd.DomainPut(kv.AccountsDomain, tx, addr, fresh, 20, nil)) + require.NoError(t, sd.Commit(ctx, tx)) + + got, ok := sc.View(nil).Get(kv.AccountsDomain, addr) + require.True(t, ok) + require.Equal(t, fresh, got, "catchup applies must reach the cache") + + preCatchup := sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 10, true })) + preCatchup.Fill(kv.AccountsDomain, addr, stale, 5) + got, ok = sc.View(nil).Get(kv.AccountsDomain, addr) + require.True(t, ok) + require.Equal(t, fresh, got, "a pre-catchup read view must not refill stale state") +} diff --git a/execution/execmodule/executor.go b/execution/execmodule/executor.go index c172ce51913..838594f261c 100644 --- a/execution/execmodule/executor.go +++ b/execution/execmodule/executor.go @@ -28,6 +28,7 @@ import ( "github.com/erigontech/erigon/db/kv" dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/stagedsync" @@ -177,10 +178,25 @@ func (pe *PipelineExecutor) RunLoop(ctx context.Context, sd *execctx.SharedDomai return tx, sd, nil } +// newFrozenBlocksSD builds a SharedDomains for frozen-block processing wired +// to the module's caches: its post-commit applies overwrite pre-catchup cache +// entries and advance the admission frontier, so read views opened before +// catchup cannot refill stale state. +func (pe *PipelineExecutor) newFrozenBlocksSD(ctx context.Context, tx kv.TemporalRwTx, stateCache *cache.StateCache, codeStore *cache.CodeStore) (*execctx.SharedDomains, error) { + sd, err := execctx.NewSharedDomains(ctx, tx, pe.logger) + if err != nil { + return nil, err + } + sd.SetInMemHistoryReads(inMemHistoryReads) + sd.SetStateCache(stateCache) + sd.SetCodeStore(codeStore) + return sd, nil +} + // ProcessFrozenBlocks runs the pipeline over snapshot blocks at startup. // It downloads block files, then executes them in a hasMore loop until // all frozen blocks are processed. -func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stageloop.Hook, onlySnapDownload bool) error { +func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stageloop.Hook, onlySnapDownload bool, stateCache *cache.StateCache, codeStore *cache.CodeStore) error { sawZeroBlocksTimes := 0 tx, err := pe.db.BeginTemporalRw(ctx) if err != nil { @@ -203,12 +219,11 @@ func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stage return tx.Commit() } - doms, err := execctx.NewSharedDomains(ctx, tx, pe.logger) + doms, err := pe.newFrozenBlocksSD(ctx, tx, stateCache, codeStore) if err != nil { return err } defer func() { doms.Close() }() // RunLoop rotates doms; close whichever is current at exit - doms.SetInMemHistoryReads(inMemHistoryReads) var finishStageBeforeSync uint64 if hook != nil { @@ -247,11 +262,10 @@ func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stage return nil, nil, err } tx = newTx - newSD, err := execctx.NewSharedDomains(ctx, newTx, pe.logger) + newSD, err := pe.newFrozenBlocksSD(ctx, newTx, stateCache, codeStore) if err != nil { return nil, nil, err } - newSD.SetInMemHistoryReads(inMemHistoryReads) hook.NotifySyncState(newTx) return newTx, newSD, nil }, From 9b60631f558ca474e2235f59220d82189d277748 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Wed, 5 Aug 2026 15:45:46 +0200 Subject: [PATCH 85/85] db/state/execctx, execution/cache: drop the dead domain field from the branch stash cacheUpdate served both cache and branch tuples before ApplyAll split them; only the commitment branch remains, so the domain field was dead. Also note ApplyAll's slice ownership in its doc. --- db/state/execctx/domain_shared.go | 24 +++++++++++------------- execution/cache/view.go | 3 ++- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 26ed7f9916e..9c83ca2a905 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -998,12 +998,11 @@ func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.Fl return sd.mem.Flush(ctx, tx, opts...) } -type cacheUpdate struct { - domain kv.Domain - key []byte - val []byte - step kv.Step - txN uint64 +type branchUpdate struct { + key []byte + val []byte + step kv.Step + txN uint64 } // Commit flushes the in-memory batch into tx, commits tx, and only then applies @@ -1048,7 +1047,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // no cache apply ever runs ahead of durable MDBX state. (Reads through // this SD between flush and a failed commit can still fill flushed // values; a failed commit is fatal, so they die with the process.) - var pendingBranch []cacheUpdate + var pendingBranch []branchUpdate var pendingState []cache.Update stashState := func(domain kv.Domain) kv.FlushOption { return kv.WithFlushCallback(domain, func(k []byte, v []byte, step kv.Step, txNum uint64) { @@ -1063,12 +1062,11 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun var opts []kv.FlushOption if sd.branchCache != nil { opts = append(opts, kv.WithFlushCallback(kv.CommitmentDomain, func(k []byte, v []byte, step kv.Step, txNum uint64) { - pendingBranch = append(pendingBranch, cacheUpdate{ - domain: kv.CommitmentDomain, - key: append([]byte(nil), k...), - val: append([]byte(nil), v...), - step: step, - txN: txNum, + pendingBranch = append(pendingBranch, branchUpdate{ + key: append([]byte(nil), k...), + val: append([]byte(nil), v...), + step: step, + txN: txNum, }) })) } diff --git a/execution/cache/view.go b/execution/cache/view.go index 4ff91b4b127..c5727b1dbfc 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -181,7 +181,8 @@ type Update struct { // ApplyAll is Apply over a batch: the write lock is taken once per chunk // instead of once per key, bounding how long concurrent fills wait. Code -// values are cloned (and hashed) outside the lock. +// values are cloned (and hashed) outside the lock; the updates slice is +// consumed and may be rewritten in place. func (a Applier) ApplyAll(updates []Update) { if a.c == nil { return