From 9e103e8bcc83394cf02afb770235d2a940592762 Mon Sep 17 00:00:00 2001 From: yperbasis Date: Tue, 14 Jul 2026 14:03:44 +0200 Subject: [PATCH 1/3] execution/exec, execution/execmodule, execution/cache: fence cache-populating warmups against epoch bumps and clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split from #22159 (StateCache review findings #22120). - WarmupStarted/WarmupDone bracket every fire-and-forget warmup and StateCache.Unwind panics under ASSERT_STATE_CACHE if one is still in flight — the drain-before-epoch-bump convention becomes a loud invariant. The warmup binds its gauge and its puts to one launch-time cache snapshot so a racing SetStateCache cannot split the pair. - WaitForWarmup and drainReadAhead report whether the warmup fully drained (false only when the module context is cancelled), and every epoch-bump or Clear call site treats an interrupted drain as a failed precondition instead of proceeding; the DB-close caller keeps its bounded wait. - ExecModule.Start drains and clears the state cache under the module semaphore before ProcessFrozenBlocks, so pre-start payload validation cannot leave pre-catchup entries live across frozen-block processing. - The read-ahead getter carries a snapshot-progress oracle: negatives are stamped with the domain's progress at observation time (a getter without an oracle fills nothing), and a side-effect-free CodeCache.ContainsLive probe lets it skip the keccak+copy for already-live address bindings. --- execution/cache/cache_test.go | 43 +++++++++++ execution/cache/code_cache.go | 16 +++++ execution/cache/grow_lru.go | 3 + execution/cache/state_cache.go | 30 ++++++++ execution/exec/blocks_read_ahead.go | 91 +++++++++++++++++------- execution/exec/blocks_read_ahead_test.go | 78 +++++++++++++++++++- execution/execmodule/exec_module.go | 39 +++++++--- execution/execmodule/forkchoice.go | 6 +- execution/execmodule/set_head.go | 6 +- 9 files changed, 268 insertions(+), 44 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 3051a4f365c..f6f94ba80eb 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -27,6 +27,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" + "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/db/kv" ) @@ -881,3 +882,45 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { require.Equal(t, fresh, v, "round %d: PutIfAbsent raced past a concurrent Put", round) } } + +func TestCodeCache_ContainsLive(t *testing.T) { + cc := NewCodeCache(1*datasize.MB, 1*datasize.MB) + addr := makeAddr(1) + code := []byte{0xaa, 1, 2, 3} + require.False(t, cc.ContainsLive(addr), "absent addr") + + cc.PutWithCodeHash(addr, code, crypto.Keccak256(code), 10) + require.True(t, cc.ContainsLive(addr)) + + cc.Unwind(5) + require.False(t, cc.ContainsLive(addr), "stale binding must not read as live") + + // A bound addr whose content entry was evicted is not live: the binding + // alone cannot serve the bytes. + cc2 := NewCodeCache(1*datasize.MB, 1*datasize.MB) + cc2.PutWithCodeHash(addr, code, crypto.Keccak256(code), 10) + cc2.hashToCode.Purge() + require.False(t, cc2.ContainsLive(addr), "binding without content bytes is not servable") +} + +// The drain-before-unwind convention (drainReadAhead ordered before every +// epoch bump) is enforced here: a cache-populating warmup still in flight at +// Unwind time can stamp a dead-fork value with the post-unwind epoch. +func TestStateCache_UnwindAssertsWarmupInFlight(t *testing.T) { + old := dbg.AssertStateCache + dbg.AssertStateCache = true + t.Cleanup(func() { dbg.AssertStateCache = old }) + + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + sc.WarmupStarted() + require.Panics(t, func() { sc.Unwind(10) }, "epoch bump with a warmup in flight must fail loud") + sc.WarmupDone() + require.NotPanics(t, func() { sc.Unwind(10) }) + + // Without the assert flag the gauge is inert. + dbg.AssertStateCache = false + sc.WarmupStarted() + defer sc.WarmupDone() + require.NotPanics(t, func() { sc.Unwind(10) }) +} diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index 48c3a266a0a..2c1c442bdc4 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -361,6 +361,22 @@ func (c *CodeCache) putCode(addr []byte, code []byte, keyHash [32]byte, txNum ui &c.coh, &c.codeSize, 8, &c.putStripes[uint8(codeID)]) } +// ContainsLive reports whether addr resolves to live code bytes through the +// addr→code binding, without touching hit/miss counters or LRU recency. +// Prefetchers probe it to skip the keccak+copy work of preparing a conditional +// put that a live binding would no-op; advisory only. +func (c *CodeCache) ContainsLive(addr []byte) bool { + vID, ok := c.addrToHash.Peek(common.BytesToAddress(addr)) + if !ok || c.isStale(vID.txNum, vID.epoch) { + return false + } + ce, ok := c.hashToCode.Peek(vID.addrID) + if !ok || len(ce.code) == 0 || c.isStale(ce.txNum, ce.epoch) { + return false + } + return vID.codeHash == ([32]byte{}) || ce.keyHash == vID.codeHash +} + // GetAddrCodeHash returns the Ethereum codeHash for addr if cached. Lets // SharedDomains.codeHashForAddr skip a cold AccountsDomain read when the // EVM-known codeHash is already known. Eviction is LRU; freshly seen addrs diff --git a/execution/cache/grow_lru.go b/execution/cache/grow_lru.go index b2455f5f5c1..68a430b4110 100644 --- a/execution/cache/grow_lru.go +++ b/execution/cache/grow_lru.go @@ -78,6 +78,9 @@ func (g *growLRU[V]) newShards(capacity uint32) *freelru.ShardedLRU[uint64, V] { func (g *growLRU[V]) Get(key uint64) (V, bool) { return g.cur.Load().Get(key) } +// Peek is Get without the LRU recency bump, for side-effect-free probes. +func (g *growLRU[V]) Peek(key uint64) (V, bool) { return g.cur.Load().Peek(key) } + func (g *growLRU[V]) Add(key uint64, value V) { lru := g.cur.Load() if curCap := g.curCap.Load(); curCap < g.maxCap && lru.Len() >= int(curCap) { diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 1d215a350e4..28721b082ae 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -18,7 +18,9 @@ package cache import ( "bytes" + "fmt" "strings" + "sync/atomic" "github.com/c2h5oh/datasize" @@ -53,6 +55,12 @@ const ( // Code uses CodeCache (two-level for deduplication). type StateCache struct { caches [kv.DomainLen]Cache + + // warmupsInFlight counts fire-and-forget cache-populating prefetches + // (WarmupStarted/WarmupDone). Unwind asserts it is zero: a prefetch put + // racing the epoch bump could stamp a dead-fork value with the post-unwind + // epoch and have it served as canonical. + warmupsInFlight atomic.Int64 } // NewStateCache creates a new StateCache with the specified byte capacities. @@ -170,6 +178,13 @@ func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64, } } +// HasLiveCode reports whether addr resolves to live code bytes; see +// CodeCache.ContainsLive. +func (c *StateCache) HasLiveCode(addr []byte) bool { + cc, ok := c.caches[kv.CodeDomain].(*CodeCache) + return ok && cc.ContainsLive(addr) +} + // 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. @@ -284,7 +299,15 @@ func (c *StateCache) Close() { // 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. +// +// Callers must drain any in-flight cache-populating warmup first (see +// WarmupStarted); the assert converts that convention into a loud failure. func (c *StateCache) Unwind(unwindToTxNum uint64) { + if dbg.AssertStateCache { + if n := c.warmupsInFlight.Load(); n != 0 { + panic(fmt.Sprintf("StateCache.Unwind with %d cache-populating warmup(s) in flight — missing drain before the epoch bump", n)) + } + } for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) @@ -292,6 +315,13 @@ func (c *StateCache) Unwind(unwindToTxNum uint64) { } } +// WarmupStarted and WarmupDone bracket a fire-and-forget cache-populating +// prefetch; see warmupsInFlight. +func (c *StateCache) WarmupStarted() { c.warmupsInFlight.Add(1) } + +// WarmupDone is the counterpart of WarmupStarted. +func (c *StateCache) WarmupDone() { c.warmupsInFlight.Add(-1) } + // GetCache returns the cache for the given domain. // Returns nil if the domain is not supported. func (c *StateCache) GetCache(domain kv.Domain) Cache { diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 57eb3a6fdb0..c95b974df22 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -85,6 +85,14 @@ 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 returns the domain's max committed txNum in the read snapshot; + // it stamps negative results, whose miss carries no step to derive a + // bound from. + 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) { @@ -93,23 +101,34 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k // 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 name == kv.CodeDomain { + // A live binding makes the conditional put a no-op — skip before + // paying the keccak+copy below. Code negatives end here too: they + // are not cacheable (CodeCache drops zero-length puts). + if len(v) > 0 && !cpg.sc.HasLiveCode(k) { + // Key the content cache by keccak(v), the code's own hash — never + // a separately read account codeHash, which parallel exec can skew + // (see the code-domain read-fill in SharedDomains.getLatestMetered). + cpg.sc.PutCodeWithHashIfAbsent(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1) + } } 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) + // Cache including nil/empty results: a probe returning no bytes is + // a valid negative answer (missing account, empty storage slot) and + // caching it lets repeated probes skip the file accessor stack — + // revm's CacheAccount { account: None, status: LoadedNotExisting } + // pattern. Stamp with the last txNum of the value's step; a + // negative has no step — use the domain's progress at observation + // time so any unwind drops it. + txNum := (uint64(step)+1)*cpg.stepSize - 1 + if len(v) == 0 { + if cpg.progress == nil { + // No progress oracle → no honest stamp; skip rather than + // cache an unwind-immortal negative. + return v, step, err + } + txNum = cpg.progress(name) + } + cpg.sc.PutIfAbsent(name, k, v, txNum) } } return v, step, err @@ -132,18 +151,34 @@ func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, h if !bra.warming.CompareAndSwap(false, true) { return } + // Ordering makes "WaitForWarmup drained ⟹ gauge is zero" hold on its + // own: WarmupStarted only after warmWg.Add, WarmupDone before + // warmWg.Done (defers run LIFO). StateCache.Unwind asserts on the gauge. + // The cache pointer is captured once so the Started/Done pair and the + // warmup's puts all bind to the same gauge even if SetStateCache races + // the launch. bra.warmWg.Add(1) + sc := bra.stateCache + if sc != nil { + sc.WarmupStarted() + } go func() { defer bra.warmWg.Done() - bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming + if sc != nil { + defer sc.WarmupDone() + } + bra.warmBody(ctx, db, sc, header, body, 8) // use 8 workers for warming }() } } -// WaitForWarmup blocks until any in-flight warmBody goroutine finishes or -// the context is cancelled. Call before closing the database to avoid -// waitTxsAllDoneOnClose hangs. -func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) { +// WaitForWarmup blocks until any in-flight warmBody goroutine finishes or the +// context is cancelled, reporting whether the warmup fully drained. False +// means a warmup may still be running — callers about to bump the cache epoch +// or Clear must treat it as a failed precondition. Call before closing the +// database to avoid waitTxsAllDoneOnClose hangs (that caller may ignore the +// result: it only needs a bounded wait). +func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) bool { done := make(chan struct{}) go func() { bra.warmWg.Wait() @@ -151,7 +186,9 @@ func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) { }() select { case <-done: + return true case <-ctx.Done(): + return false } } @@ -166,7 +203,9 @@ func (bra *BlockReadAheader) AddSenders(senders []byte, blockHash common.Hash) { // It reads: To accounts, To account code, To account storage from access lists, // and block-level access lists. Each worker creates its own transaction. // Only one warmBody can run at a time - concurrent calls are no-ops. -func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *types.Header, body *types.Body, workers int) { +// sc is the launch-time cache snapshot (see AddHeaderAndBody), nil to warm the +// OS page cache only. +func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, sc *cache.StateCache, header *types.Header, body *types.Body, workers int) { defer bra.warming.Store(false) if !dbg.ReadAhead { @@ -228,8 +267,8 @@ func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *t return nil } var getter kv.TemporalGetter = ttx - if bra.stateCache != nil { - getter = &cachePopulatingGetter{g: ttx, sc: bra.stateCache, stepSize: ttx.Debug().StepSize()} + if sc != nil { + getter = newCachePopulatingGetter(ttx, sc) } stateReader := state.NewReaderV3(getter) @@ -299,8 +338,8 @@ 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()} + if sc != nil { + cpg = newCachePopulatingGetter(ttx, sc) 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..6ecfcb74132 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -17,6 +17,7 @@ package exec import ( + "context" "testing" "github.com/c2h5oh/datasize" @@ -27,6 +28,22 @@ import ( "github.com/erigontech/erigon/execution/cache" ) +// A cancelled wait can return while a warmup is still in flight — the gauge +// convention only holds for a drained return, so callers about to bump the +// cache epoch (or Clear) must be able to tell the two apart. +func TestWaitForWarmupReportsDrained(t *testing.T) { + bra := &BlockReadAheader{} + require.True(t, bra.WaitForWarmup(context.Background()), "nothing in flight — drained") + + bra.warmWg.Add(1) + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + require.False(t, bra.WaitForWarmup(cancelled), "cancelled wait with a live warmup must report undrained") + + bra.warmWg.Done() + require.True(t, bra.WaitForWarmup(context.Background()), "drained after the warmup finished") +} + // stubTemporalGetter stands in for the committed-state snapshot a warmup // goroutine reads: every GetLatest returns the same fixed value. type stubTemporalGetter struct { @@ -98,7 +115,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 +124,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 +133,65 @@ 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) } + +// With a live addr binding the prefetch must be a full no-op: not even the +// content layers may be populated for its (superseded) snapshot code, because +// the liveness pre-check exists to skip the keccak+copy for that code +// entirely. +func TestCachePopulatingGetterSkipsContentForLiveBinding(t *testing.T) { + addr := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") + freshCode := []byte{0xaa, 0x01, 0x02, 0x03} + 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} + + _, _, err := cpg.GetLatest(kv.CodeDomain, addr) + require.NoError(t, err) + + _, ok := sc.GetCodeByHash(crypto.Keccak256(staleCode)) + require.False(t, ok, "live binding: prefetch must not populate content for the snapshot code") +} + +// Negative results are stamped with the domain's progress at observation time, +// not a synthetic step-0 bound — a synthetic stamp far below any real unwind +// floor would make the negative immortal. +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") +} + +// A getter constructed without a progress oracle must skip caching negatives +// (an honest stamp is impossible), not panic. +func TestCachePopulatingGetterNilProgressSkipsNegative(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 — the negative must not be cached") +} + +func zeroProgress(kv.Domain) uint64 { return 0 } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index f34fdfb307f..97ab9f564cb 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -384,22 +384,24 @@ func (e *ExecModule) canonicalHash(ctx context.Context, tx kv.Tx, blockNumber ui return canonical, nil } -// 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 -// 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. -func (e *ExecModule) drainReadAhead() { +// drainReadAhead blocks until any in-flight block-assembly warmup finishes, +// reporting whether it fully drained — false only when the module context is +// cancelled (shutdown). 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 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, and do not proceed to them on false. +func (e *ExecModule) drainReadAhead() bool { if e.readAheader == nil { - return + return true } ctx := e.bacgroundCtx if ctx == nil { ctx = context.Background() } - e.readAheader.WaitForWarmup(ctx) + return e.readAheader.WaitForWarmup(ctx) } func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) error { @@ -430,7 +432,9 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te return err } - e.drainReadAhead() + if !e.drainReadAhead() { + return fmt.Errorf("read-ahead drain interrupted before unwind: %w", e.bacgroundCtx.Err()) + } if err := e.pipelineExecutor.UnwindTo(unwindPoint, stagedsync.ExecUnwind, tx); err != nil { return err } @@ -690,6 +694,19 @@ func (e *ExecModule) Start(ctx context.Context, hook *stageloop.Hook) { } defer e.semaphore.Release(1) + // Engine servers are live before Start, so an early payload validation may + // already have warmed the state cache with pre-catchup state. Frozen-block + // processing advances state without touching the cache (its SDs are not + // wired to it), so such entries would be served stale afterwards — drain + // any in-flight warmup and clear before it runs. An interrupted drain + // means shutdown: return rather than Clear under a live warmup. + if !e.drainReadAhead() { + return + } + if e.stateCache != nil { + e.stateCache.Clear() + } + if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart); err != nil { if !errors.Is(err, context.Canceled) { e.logger.Error("Could not start execution service", "err", err) diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 280959cb907..16f1cfbfa6d 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -363,8 +363,10 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa // 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() + // the semaphore). An interrupted drain means shutdown — bail. + if !e.drainReadAhead() { + return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, e.bacgroundCtx.Err(), false) + } var validationError string diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 9a9c11c2004..e0ce6581297 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -113,8 +113,10 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { // Drain in-flight warmup before the unwind bumps the cache epoch, so a // fire-and-forget warmup can't Put a dead-fork value stamped with the new - // epoch (cross-fork contamination). - e.drainReadAhead() + // epoch (cross-fork contamination). An interrupted drain means shutdown. + if !e.drainReadAhead() { + return fmt.Errorf("read-ahead drain interrupted before unwind: %w", e.bacgroundCtx.Err()) + } // Set the unwind point and run the unwind if err := e.pipelineExecutor.UnwindTo(targetBlock, stagedsync.StagedUnwind, tx); err != nil { From c2afd2784957220e4e8faf7a962439f0b6d6255c Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:14:03 +0200 Subject: [PATCH 2/3] execution/exec, execution/execmodule: defer startup cache fix --- execution/exec/blocks_read_ahead.go | 6 +++--- execution/execmodule/exec_module.go | 13 ------------- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 6be67d17541..f8bb39df7d8 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -166,9 +166,9 @@ func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, h // WaitForWarmup blocks until any in-flight warmBody goroutine finishes or the // context is cancelled, reporting whether the warmup fully drained. False // means a warmup may still be running — callers about to bump the cache epoch -// or Clear must treat it as a failed precondition. Call before closing the -// database to avoid waitTxsAllDoneOnClose hangs (that caller may ignore the -// result: it only needs a bounded wait). +// must treat it as a failed precondition. Call before closing the database to +// avoid waitTxsAllDoneOnClose hangs (that caller may ignore the result: it only +// needs a bounded wait). func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) bool { done := make(chan struct{}) go func() { diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index ae03f30dd1c..4103b19d90b 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -694,19 +694,6 @@ func (e *ExecModule) Start(ctx context.Context, hook *stageloop.Hook) { } defer e.semaphore.Release(1) - // Engine servers are live before Start, so an early payload validation may - // already have warmed the state cache with pre-catchup state. Frozen-block - // processing advances state without touching the cache (its SDs are not - // wired to it), so such entries would be served stale afterwards — drain - // any in-flight warmup and clear before it runs. An interrupted drain - // means shutdown: return rather than Clear under a live warmup. - if !e.drainReadAhead() { - return - } - if e.stateCache != nil { - e.stateCache.Clear() - } - if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart); err != nil { if !errors.Is(err, context.Canceled) { e.logger.Error("Could not start execution service", "err", err) From a0aee33ca77e7d6d1d1e695c686decdfcae39a39 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:37:22 +0200 Subject: [PATCH 3/3] execution/cache, execution/exec: keep warmup fencing lifecycle-only --- execution/cache/cache_test.go | 20 -------- execution/cache/code_cache.go | 17 ------- execution/cache/grow_lru.go | 3 -- execution/cache/state_cache.go | 7 --- execution/exec/blocks_read_ahead.go | 20 +++----- execution/exec/blocks_read_ahead_test.go | 61 ++---------------------- 6 files changed, 10 insertions(+), 118 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index cea06f8e161..4e665904760 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -891,26 +891,6 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -func TestCodeCache_ContainsLive(t *testing.T) { - cc := NewCodeCache(1*datasize.MB, 1*datasize.MB) - addr := makeAddr(1) - code := []byte{0xaa, 1, 2, 3} - require.False(t, cc.ContainsLive(addr), "absent addr") - - cc.PutWithCodeHash(addr, code, crypto.Keccak256(code), 10) - require.True(t, cc.ContainsLive(addr)) - - cc.Unwind(5) - require.False(t, cc.ContainsLive(addr), "stale binding must not read as live") - - // A bound addr whose content entry was evicted is not live: the binding - // alone cannot serve the bytes. - cc2 := NewCodeCache(1*datasize.MB, 1*datasize.MB) - cc2.PutWithCodeHash(addr, code, crypto.Keccak256(code), 10) - cc2.hashToCode.Purge() - require.False(t, cc2.ContainsLive(addr), "binding without content bytes is not servable") -} - // The drain-before-unwind convention (drainReadAhead ordered before every // epoch bump) is enforced here: a cache-populating warmup still in flight at // Unwind time can stamp a dead-fork value with the post-unwind epoch. diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index 15386b0662d..17f029212be 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -359,23 +359,6 @@ func (c *CodeCache) putCodeLocked(addr []byte, code []byte, keyHash [32]byte, co &c.coh, &c.codeSize, 8) } -// ContainsLive reports whether addr resolves to live code bytes through the -// addr→code binding, without touching hit/miss counters or LRU recency. -// Prefetchers probe it to skip the keccak+copy work of preparing a conditional -// put that a live binding would no-op; advisory only. -func (c *CodeCache) ContainsLive(addr []byte) bool { - coh := c.coh.Snapshot() - vID, ok := c.addrToHash.Peek(common.BytesToAddress(addr)) - if !ok || coh.IsStale(vID.txNum, vID.epoch) { - return false - } - ce, ok := c.hashToCode.Peek(vID.addrID) - if !ok || len(ce.code) == 0 || coh.IsStale(ce.txNum, ce.epoch) { - return false - } - return vID.codeHash == ([32]byte{}) || ce.keyHash == vID.codeHash -} - // GetAddrCodeHash returns the Ethereum codeHash for addr if cached. Lets // SharedDomains.codeHashForAddr skip a cold AccountsDomain read when the // EVM-known codeHash is already known. Eviction is LRU; freshly seen addrs diff --git a/execution/cache/grow_lru.go b/execution/cache/grow_lru.go index 1e1b1897695..b0e3725028d 100644 --- a/execution/cache/grow_lru.go +++ b/execution/cache/grow_lru.go @@ -84,9 +84,6 @@ func (g *growLRU[V]) newShards(capacity uint32) *freelru.ShardedLRU[uint64, V] { func (g *growLRU[V]) Get(key uint64) (V, bool) { return g.cur.Load().Get(key) } -// Peek is Get without the LRU recency bump, for side-effect-free probes. -func (g *growLRU[V]) Peek(key uint64) (V, bool) { return g.cur.Load().Peek(key) } - func (g *growLRU[V]) Add(key uint64, value V) { lru := g.cur.Load() if curCap := g.curCap.Load(); curCap < g.maxCap && lru.Len() >= int(curCap) { diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 1c37935f80d..6edef481f1e 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -177,13 +177,6 @@ func (c *StateCache) putCodeWithHash(addr, code, codeHash []byte, txNum uint64, } } -// HasLiveCode reports whether addr resolves to live code bytes; see -// CodeCache.ContainsLive. -func (c *StateCache) HasLiveCode(addr []byte) bool { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - return ok && cc.ContainsLive(addr) -} - // 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/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index f8bb39df7d8..9f97595a001 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -100,16 +100,13 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k // 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 { - // A live binding makes the conditional put a no-op — skip before - // paying the keccak+copy below. Code negatives end here too: they - // are not cacheable (CodeCache drops zero-length puts). - if len(v) > 0 && !cpg.sc.HasLiveCode(k) { - // Key the content cache by keccak(v), the code's own hash — never - // a separately read account codeHash, which parallel exec can skew - // (see the code-domain read-fill in SharedDomains.getLatestMetered). - cpg.sc.PutCodeWithHashIfAbsent(k, v, crypto.Keccak256(v), (uint64(step)+1)*cpg.stepSize-1) - } + 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) } else { // Cache including nil/empty results: a probe returning no // bytes is a valid negative answer (missing account, empty @@ -124,9 +121,6 @@ func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, k // does). readTxNum := (uint64(step)+1)*cpg.stepSize - 1 if len(v) == 0 && name != kv.CodeDomain && cpg.sc.GetCache(name) != nil { - if cpg.progress == nil { - return v, step, err - } readTxNum = cpg.progress(name) } cpg.sc.PutIfAbsent(name, k, v, readTxNum) diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index cf50be3a4f5..78f6a874573 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -30,7 +30,7 @@ import ( // A cancelled wait can return while a warmup is still in flight — the gauge // convention only holds for a drained return, so callers about to bump the -// cache epoch (or Clear) must be able to tell the two apart. +// cache epoch must be able to tell the two apart. func TestWaitForWarmupReportsDrained(t *testing.T) { bra := &BlockReadAheader{} require.True(t, bra.WaitForWarmup(context.Background()), "nothing in flight — drained") @@ -115,7 +115,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} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) got, ok := sc.Get(domain, key) @@ -124,7 +124,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} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) got, ok := sc.Get(kv.CodeDomain, key) @@ -141,61 +141,6 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { require.Empty(t, got) } -// With a live addr binding the prefetch must be a full no-op: not even the -// content layers may be populated for its (superseded) snapshot code, because -// the liveness pre-check exists to skip the keccak+copy for that code -// entirely. -func TestCachePopulatingGetterSkipsContentForLiveBinding(t *testing.T) { - addr := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") - freshCode := []byte{0xaa, 0x01, 0x02, 0x03} - 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} - - _, _, err := cpg.GetLatest(kv.CodeDomain, addr) - require.NoError(t, err) - - _, ok := sc.GetCodeByHash(crypto.Keccak256(staleCode)) - require.False(t, ok, "live binding: prefetch must not populate content for the snapshot code") -} - -// Negative results are stamped with the domain's progress at observation time, -// not a synthetic step-0 bound — a synthetic stamp far below any real unwind -// floor would make the negative immortal. -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") -} - -// A getter constructed without a progress oracle must skip caching negatives -// (an honest stamp is impossible), not panic. -func TestCachePopulatingGetterNilProgressSkipsNegative(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 — the negative must not be cached") -} - -func zeroProgress(kv.Domain) uint64 { return 0 } - // A negative (missing account, empty slot) carries no write step, so a // step-derived stamp pins it at the start of history where no unwind can drop // it. It must be stamped with the domain's progress at observation time —