From f6c36528660519665d0833d25749a0e3fec02fa2 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:09 +0200 Subject: [PATCH 01/36] execution/cache: test unwind fill-readmission window --- execution/cache/cache_test.go | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 42e0e740eab..37952ccc450 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -926,6 +926,45 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { require.False(t, ok, "a view older than the deletion must not fill afterward") } +// SharedDomains commits the tx and only then walks `pending` into the cache, so +// between those steps a reader opening a new tx legitimately sees txNums the +// cache has not applied yet: its frontier is ahead of appliedEnd. Rejecting +// "ahead" would drop fills on every flush for the length of the apply loop. +func TestStateCache_ReaderAheadOfApplyWindowCanFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + sc.apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 100) + + key := makeAddr(2) + sc.fillIfFresh(kv.AccountsDomain, key, makeValue(2), 200, 201) + + _, ok := sc.get(kv.AccountsDomain, key) + require.True(t, ok, + "a reader ahead of appliedEnd is the normal commit-then-apply window, not a dead fork") +} + +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") + + 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 975b518b15b06672d8d3a2a3f96f7bd366203155 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:32:01 +0200 Subject: [PATCH 02/36] execution/cache, db/state/execctx: reject stale unwind fills Stamp cache read views with an unwind generation and reject fills from older generations. Skip direct and derived fills while the mem overlay supplies a per-key unwind bound. --- db/state/execctx/domain_shared.go | 20 +++-- db/state/execctx/statecache_readfill_test.go | 77 +++++++++++++++++++ .../statecache_rpc_integration_test.go | 51 ++++++++++++ execution/cache/cache_test.go | 23 +++--- execution/cache/state_cache.go | 17 ++-- execution/cache/view.go | 26 +++++-- 6 files changed, 185 insertions(+), 29 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 8398bacf452..fb596649aac 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1344,16 +1344,15 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k return nil, 0, fmt.Errorf("storage %x read error: %w", k, err) } - // View freshness is rechecked while the fill is serialized against - // committed cache updates. - if sd.stateCache != nil && sd.stateCache.Caches(domain) { + // A bounded read observes a staged unwind, not stable committed state. + if maxStep == kv.NoStepBound && sd.stateCache != nil && sd.stateCache.Caches(domain) { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 fillView := view if !fillView.CanFill() { // 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 = fillView.WithFrontier(sdFrontier{sd: sd, tx: tx}) } fillView.Fill(domain, k, v, readTxNum) } @@ -1496,17 +1495,22 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if len(addr) == 0 { return nil } + bounded := false // In-batch state is authoritative: sd.mem / parent.mem hold this batch's // uncommitted account writes, while the addr→codeHash LRU is invalidated only // on flush. Route mem-first; the LRU is a committed-state layer that may only // answer once mem has missed. - if v, _, ok := sd.mem.GetLatest(kv.AccountsDomain, addr); ok { + v, step, ok := sd.mem.GetLatest(kv.AccountsDomain, addr) + if ok { return accounts.DeserialiseV3CodeHash(v) } + bounded = step != kv.NoStepBound if sd.parent != nil { - if v, _, ok := sd.parent.mem.GetLatest(kv.AccountsDomain, addr); ok { + v, step, ok = sd.parent.mem.GetLatest(kv.AccountsDomain, addr) + if ok { return accounts.DeserialiseV3CodeHash(v) } + bounded = bounded || step != kv.NoStepBound } // Below mem: the addr → codeHash LRU caches committed state @@ -1541,7 +1545,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, } h, fromReadView := resolve() - if fromReadView && sd.stateCache != nil { + if fromReadView && !bounded && sd.stateCache != nil { var fixed [32]byte if len(h) == 32 { copy(fixed[:], h) @@ -1556,7 +1560,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, 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 = seedView.WithFrontier(sdFrontier{sd: sd, tx: tx}) } seedView.SeedAddrCodeHash(addr, fixed, txNum) } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7c198a3034e..222588f5889 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -25,6 +25,7 @@ import ( "github.com/holiman/uint256" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" @@ -292,6 +293,82 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { require.Equal(t, v3, got, "read-fill must not clobber the live entry") } +func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + sc := newSmallStateCache() + key, _, v2, diffs := twoStepRows(t, db, sc) + + 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) + sd.Unwind(10, &diffs) + + got, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) + require.NoError(t, err) + require.Equal(t, v2, got) + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "a bounded in-flight unwind read must not populate the shared cache") +} + +func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + sc := newSmallStateCache() + + key := make([]byte, 20) + key[0] = 0xcc + var codeHash common.Hash + codeHash[0] = 0xdd + value := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + CodeHash: accounts.InternCodeHash(codeHash), + }) + + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer sd.Close() + sd.SetStateCacheForTest(sc) + sd.SetTxNum(20) + require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, value, 20, nil)) + require.NoError(t, sd.Commit(ctx, rwTx)) + + stepBytes := make([]byte, 8) + binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) + var diffs [kv.DomainLen][]kv.DomainEntryDiff + diffs[kv.AccountsDomain] = []kv.DomainEntryDiff{{Key: string(key) + string(stepBytes)}} + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + sd2, err := execctx.NewSharedDomains(ctx, roTx, log.New()) + require.NoError(t, err) + defer sd2.Close() + sd2.SetStateCacheForTest(sc) + sd2.Unwind(10, &diffs) + + got := sd2.CodeHashForAddr(roTx, key, 20) + require.Equal(t, codeHash[:], got) + + _, ok := sc.View(nil).GetAddrCodeHash(key) + require.False(t, ok, "a bounded in-flight unwind read must not seed a code-hash mapping") +} + // 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) { diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 789bea870df..0e97e1318c4 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -45,6 +45,57 @@ func TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode(t *testing.T) { testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t, kv.CodeDomain) } +func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + defer unwindDomains.Close() + unwindDomains.SetStateCacheForTest(stateCache) + + events := shards.NewEvents() + events.PublishOverlay(unwindDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + events.PublishOverlay(nil) + + got, err := rpcView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the pre-reorg RPC view still sees the discarded fork") + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the pre-reorg RPC view must not refill the discarded fork") + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 37952ccc450..c669156e382 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -58,6 +58,10 @@ func makeValue(i int) []byte { return []byte{byte(i), byte(i + 1), byte(i + 2)} } +func frontierAt(end uint64) Frontier { + return FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) +} + // ============================================================================= // DomainCache Tests // ============================================================================= @@ -921,7 +925,7 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { _, 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) + sc.View(frontierAt(11)).Fill(kv.AccountsDomain, key, stale, 10) _, ok = sc.get(kv.AccountsDomain, key) require.False(t, ok, "a view older than the deletion must not fill afterward") } @@ -938,14 +942,14 @@ func TestStateCache_ReaderAheadOfApplyWindowCanFill(t *testing.T) { sc.apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 100) key := makeAddr(2) - sc.fillIfFresh(kv.AccountsDomain, key, makeValue(2), 200, 201) + sc.View(frontierAt(201)).Fill(kv.AccountsDomain, key, makeValue(2), 200) _, ok := sc.get(kv.AccountsDomain, key) require.True(t, ok, "a reader ahead of appliedEnd is the normal commit-then-apply window, not a dead fork") } -func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { +func TestStateCache_PreReorgViewCannotFillAfterUnwind(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) t.Cleanup(sc.Close) @@ -955,12 +959,13 @@ func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { sc.apply(kv.AccountsDomain, key, canonical, 40) sc.apply(kv.AccountsDomain, key, fork, 100) - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + preReorg := sc.View(frontierAt(101)) + preReorg.Fill(kv.AccountsDomain, key, fork, 100) sc.unwind(50) _, ok := sc.get(kv.AccountsDomain, key) require.False(t, ok, "the unwind must evict the fork's value") - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + preReorg.Fill(kv.AccountsDomain, key, fork, 100) _, ok = sc.get(kv.AccountsDomain, key) require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") } @@ -974,12 +979,12 @@ func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { stale := makeValue(1) sc.apply(kv.AccountsDomain, key, nil, 100) - sc.fillIfFresh(kv.AccountsDomain, key, stale, 99, 100) + sc.View(frontierAt(100)).Fill(kv.AccountsDomain, key, stale, 99) _, 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) + sc.View(frontierAt(101)).Fill(kv.AccountsDomain, key, fresh, 100) got, ok := sc.get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, fresh, got) @@ -1006,7 +1011,7 @@ func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { }() go func() { defer wg.Done() - sc.fillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, visibleEnd) + sc.View(frontierAt(visibleEnd)).Fill(kv.AccountsDomain, key, value, appliedTxNum) }() wg.Wait() @@ -1023,7 +1028,7 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { addr := makeAddr(1) var h [32]byte h[0] = 0xaa - sc.seedAddrCodeHash(addr, h, 10, 0) + sc.View(frontierAt(0)).SeedAddrCodeHash(addr, h, 10) _, ok := sc.getAddrCodeHash(addr) require.True(t, ok) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 276871b07cc..5c0f0dcb8b2 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" @@ -62,6 +63,7 @@ type StateCache struct { // against concurrent read-fills, which recheck freshness under RLock. admissionMu sync.RWMutex appliedEnd [kv.DomainLen]uint64 + unwindGen 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. @@ -207,14 +209,14 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { // 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) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd uint64) { +func (c *StateCache) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, viewGen uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[kv.AccountsDomain] { + if viewGen != c.unwindGen.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } cc.PutAddrCodeHash(addr, h, txNum) @@ -243,7 +245,7 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 // 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) { +func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd, viewGen uint64) { cache := c.caches[domain] if cache == nil { return @@ -259,7 +261,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[domain] { + if viewGen != c.unwindGen.Load() || visibleEnd < c.appliedEnd[domain] { return } cache.PutIfAbsent(key, cloned, readTxNum) @@ -270,7 +272,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea // 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) { +func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibleEnd, accountsVisibleEnd, viewGen uint64) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok || len(value) == 0 { return @@ -279,7 +281,9 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { + if viewGen != c.unwindGen.Load() || + visibleEnd < c.appliedEnd[kv.CodeDomain] || + accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return } codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) @@ -386,6 +390,7 @@ func (c *StateCache) Close() { func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() + c.unwindGen.Add(1) for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) diff --git a/execution/cache/view.go b/execution/cache/view.go index 0de781920d6..e106b0ab631 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -51,14 +51,28 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // 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. +// Each view also keeps the unwind generation sampled at construction, so a +// later unwind prevents it from filling from the discarded fork. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { - c *StateCache - frontier Frontier + c *StateCache + frontier Frontier + unwindGen uint64 } // 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} } +func (c *StateCache) View(f Frontier) ReadView { + if c == nil { + return ReadView{} + } + return ReadView{c: c, frontier: f, unwindGen: c.unwindGen.Load()} +} + +// WithFrontier binds f without changing the unwind generation sampled by View. +func (v ReadView) WithFrontier(f Frontier) ReadView { + v.frontier = f + return v +} // Get retrieves data for the given domain and key. // Returns (value, true) on cache hit — including (nil, true) for cached negatives — @@ -128,10 +142,10 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin if !ok { return } - v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd) + v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, v.unwindGen) return } - v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd) + v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.unwindGen) } // SeedAddrCodeHash offers an addr → codeHash mapping derived from an account @@ -145,7 +159,7 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { if !ok { return } - v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd) + v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, v.unwindGen) } // FillCodeSize records the code length for codeHash. Content-addressed and From 0c14b98431a0774fa07bc6039a92d52077a833cf Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:47:37 +0200 Subject: [PATCH 03/36] execution/cache: clarify read-view epoch semantics Rename the unwind admission generation to readViewEpoch and document why it remains separate from per-cache entry epochs. --- execution/cache/cache.go | 9 +++++---- execution/cache/state_cache.go | 21 +++++++++++++-------- execution/cache/view.go | 24 +++++++++++++----------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index d30c41a0f4d..a718d20a87d 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -22,16 +22,17 @@ // newer than the reader's tx (snapshot-isolated caching is kvcache's job, // 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. +// stored entries by a per-cache entry 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 // behalf of a database reader after a miss); admission compares the view's // 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 -// commit/unwind path, performs the authoritative writes: post-commit -// applies, unwinds, clears. +// applies take. A ReadView also snapshots a separate, StateCache-wide +// read-view epoch, which an unwind advances to revoke fills from older views. +// The Applier handle, held by the SharedDomains 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 5c0f0dcb8b2..c7c4a8fce13 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -63,7 +63,11 @@ type StateCache struct { // against concurrent read-fills, which recheck freshness under RLock. admissionMu sync.RWMutex appliedEnd [kv.DomainLen]uint64 - unwindGen atomic.Uint64 + // readViewEpoch lets an unwind revoke fill authority from all older + // ReadViews. It is StateCache-wide and advances only on unwind. Per-cache + // entry epochs instead stamp stored values and also advance on Clear; + // sharing them would make Clear revoke otherwise valid read views. + readViewEpoch 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. @@ -209,14 +213,14 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { // 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) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, viewGen uint64) { +func (c *StateCache) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, viewEpoch uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewGen != c.unwindGen.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } cc.PutAddrCodeHash(addr, h, txNum) @@ -245,7 +249,7 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 // 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, viewGen uint64) { +func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd, viewEpoch uint64) { cache := c.caches[domain] if cache == nil { return @@ -261,7 +265,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewGen != c.unwindGen.Load() || visibleEnd < c.appliedEnd[domain] { + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[domain] { return } cache.PutIfAbsent(key, cloned, readTxNum) @@ -272,7 +276,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea // 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, viewGen uint64) { +func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibleEnd, accountsVisibleEnd, viewEpoch uint64) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok || len(value) == 0 { return @@ -281,7 +285,7 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewGen != c.unwindGen.Load() || + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return @@ -387,10 +391,11 @@ 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. +// It also advances readViewEpoch to revoke fill authority from older views. func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() - c.unwindGen.Add(1) + c.readViewEpoch.Add(1) for _, cache := range c.caches { if cache != nil { cache.Unwind(unwindToTxNum) diff --git a/execution/cache/view.go b/execution/cache/view.go index e106b0ab631..d3d0f6a0ec7 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -50,14 +50,16 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // 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. -// Each view also keeps the unwind generation sampled at construction, so a -// later unwind prevents it from filling from the discarded fork. +// enforced on the fill side; unwinds invalidate stored entries by their +// per-cache entry epoch and floor. +// Each view also snapshots the StateCache read-view epoch. An unwind advances +// that epoch, so older views can still read but cannot fill from the discarded +// fork. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { - c *StateCache - frontier Frontier - unwindGen uint64 + c *StateCache + frontier Frontier + readViewEpoch uint64 } // View creates a ReadView vouched for by f. A nil f disables admission-gated fills. @@ -65,10 +67,10 @@ func (c *StateCache) View(f Frontier) ReadView { if c == nil { return ReadView{} } - return ReadView{c: c, frontier: f, unwindGen: c.unwindGen.Load()} + return ReadView{c: c, frontier: f, readViewEpoch: c.readViewEpoch.Load()} } -// WithFrontier binds f without changing the unwind generation sampled by View. +// WithFrontier binds f while preserving the original view's read-view epoch. func (v ReadView) WithFrontier(f Frontier) ReadView { v.frontier = f return v @@ -142,10 +144,10 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin if !ok { return } - v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, v.unwindGen) + v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, v.readViewEpoch) return } - v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.unwindGen) + v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.readViewEpoch) } // SeedAddrCodeHash offers an addr → codeHash mapping derived from an account @@ -159,7 +161,7 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { if !ok { return } - v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, v.unwindGen) + v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, v.readViewEpoch) } // FillCodeSize records the code length for codeHash. Content-addressed and From 4370502231108fd43129612c4b468a5bc03efb26 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:42:24 +0200 Subject: [PATCH 04/36] db/state: reject late stale cache fills after unwind --- db/state/execctx/domain_shared.go | 42 +++++++-- .../statecache_rpc_integration_test.go | 91 +++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index fb596649aac..b584174ae6c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -34,6 +34,7 @@ import ( "github.com/erigontech/erigon/db/kv/membatchwithdb" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/kv/rawdbv3" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/changeset" "github.com/erigontech/erigon/db/state/kvmetrics" "github.com/erigontech/erigon/db/state/statecfg" @@ -172,6 +173,27 @@ func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return f.sd.domainVisibleEnd(f.tx, domain) } +type rejectedFrontier struct{} + +func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, false } + +// readViewEpoch rejects cache views created before an unwind. Requiring the same +// state generation also rejects an old database transaction first bound after it. +func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { + if !sd.baseStateVersionKnown { + return rejectedFrontier{} + } + sameStateGeneration := tx.ViewID() == sd.baseViewID + if !sameStateGeneration { + stateVersion, err := rawdb.GetStateVersion(tx) + sameStateGeneration = err == nil && stateVersion == sd.baseStateVersion + } + if !sameStateGeneration { + return rejectedFrontier{} + } + return sdFrontier{sd: sd, tx: tx} +} + // 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. @@ -179,7 +201,7 @@ 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}) + return sd.stateCache.View(sd.cacheFrontierFor(tx)) } // cacheReader is a frontier-less view: admission-gated fills are disabled, @@ -205,6 +227,10 @@ type SharedDomains struct { logger log.Logger + baseViewID uint64 + baseStateVersion uint64 + baseStateVersionKnown bool + txNum uint64 currentStep kv.Step // disableInlineTouchKey when true, DomainPut skips the TouchKey call. @@ -308,10 +334,14 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, } trieCfg := o.trieCfg + stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: tx.Debug().StepSize(), + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: tx.Debug().StepSize(), + baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, + baseStateVersionKnown: stateVersionErr == nil, } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -1352,7 +1382,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // 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 = fillView.WithFrontier(sdFrontier{sd: sd, tx: tx}) + fillView = fillView.WithFrontier(sd.cacheFrontierFor(tx)) } fillView.Fill(domain, k, v, readTxNum) } @@ -1560,7 +1590,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, 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 = seedView.WithFrontier(sdFrontier{sd: sd, tx: tx}) + seedView = seedView.WithFrontier(sd.cacheFrontierFor(tx)) } seedView.SeedAddrCodeHash(addr, fixed, txNum) } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 0e97e1318c4..71f70997db7 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -96,6 +96,97 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { require.Equal(t, v1, got) } +func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + events := shards.NewEvents() + events.PublishOverlay(freshDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + got, err := rpcView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the old RPC transaction still sees the discarded fork") + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "binding an old RPC transaction after unwind must not refill the discarded fork") + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + +func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + oldTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer oldTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + got, _, err := freshDomains.GetLatest(kv.AccountsDomain, oldTx, key) + require.NoError(t, err) + require.Equal(t, v2, got, "the old transaction still sees the discarded fork") + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "binding an old transaction on a cache miss must not refill the discarded fork") + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() From a4a2af9cb674ad44ee086ecd8fb045aee67d52dc Mon Sep 17 00:00:00 2001 From: yperbasis Date: Fri, 7 Aug 2026 12:33:56 +0200 Subject: [PATCH 05/36] db/state/execctx: tolerate the nil placeholder tx in cacheFrontierFor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background exec workers bind getters with a nil chainTx and open the real tx on their first task. The generation check dereferenced the placeholder eagerly (tx.ViewID()), panicking every parallel-exec worker pool reset — all EEST shards and benchmarks red. A nil tx gets the rejected frontier: the placeholder getter can never fill, and the worker replaces it before reading. --- db/state/execctx/domain_shared.go | 4 +++- db/state/execctx/statecache_readfill_test.go | 21 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b584174ae6c..8a8bdcf7860 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -180,7 +180,9 @@ func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, f // readViewEpoch rejects cache views created before an unwind. Requiring the same // state generation also rejects an old database transaction first bound after it. func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { - if !sd.baseStateVersionKnown { + // Background exec workers bind getters with a nil placeholder tx and open + // the real one on their first task; the placeholder can never fill. + if tx == nil || !sd.baseStateVersionKnown { return rejectedFrontier{} } sameStateGeneration := tx.ViewID() == sd.baseViewID diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 222588f5889..24786c74629 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -369,6 +369,27 @@ func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { require.False(t, ok, "a bounded in-flight unwind read must not seed a code-hash mapping") } +// Background exec workers are constructed with a nil chainTx placeholder and +// open their real tx on the first task; binding a getter for the placeholder +// must not touch the tx. +func TestAsGetterMeteredNilTx(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() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + require.NotPanics(t, func() { domains.AsGetterMetered(nil, nil) }) +} + // 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) { From d3ce694e67ba5058e16c375af43ecf7748c01546 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:50:19 +0200 Subject: [PATCH 06/36] db/state: validate overlay cache fills against backing tx --- db/state/execctx/domain_shared.go | 30 ++++++++--- .../statecache_rpc_integration_test.go | 51 +++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 8a8bdcf7860..7af3e62ab7c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -177,17 +177,32 @@ type rejectedFrontier struct{} func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, false } -// readViewEpoch rejects cache views created before an unwind. Requiring the same -// state generation also rejects an old database transaction first bound after it. +// cacheGenerationTx unwraps table overlays because their sequence metadata +// belongs to the overlay, while cache fills read temporal domains from the +// backing transaction. +func cacheGenerationTx(tx kv.TemporalTx) kv.TemporalTx { + for tx != nil { + wrapper, ok := tx.(interface{ UnderlyingTx() kv.TemporalTx }) + if !ok { + return tx + } + tx = wrapper.UnderlyingTx() + } + return nil +} + +// cacheFrontierFor grants fill authority only to transactions in the durable +// state generation from which this SharedDomains was created. func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { // Background exec workers bind getters with a nil placeholder tx and open // the real one on their first task; the placeholder can never fill. - if tx == nil || !sd.baseStateVersionKnown { + generationTx := cacheGenerationTx(tx) + if generationTx == nil || !sd.baseStateVersionKnown { return rejectedFrontier{} } - sameStateGeneration := tx.ViewID() == sd.baseViewID + sameStateGeneration := generationTx.ViewID() == sd.baseViewID if !sameStateGeneration { - stateVersion, err := rawdb.GetStateVersion(tx) + stateVersion, err := rawdb.GetStateVersion(generationTx) sameStateGeneration = err == nil && stateVersion == sd.baseStateVersion } if !sameStateGeneration { @@ -336,12 +351,13 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, } trieCfg := o.trieCfg - stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) + generationTx := cacheGenerationTx(tx) + stateVersion, stateVersionErr := rawdb.GetStateVersion(generationTx) sd := &SharedDomains{ logger: logger, metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, stepSize: tx.Debug().StepSize(), - baseViewID: tx.ViewID(), + baseViewID: generationTx.ViewID(), baseStateVersion: stateVersion, baseStateVersionKnown: stateVersionErr == nil, } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 71f70997db7..fa72b69c046 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -145,6 +145,57 @@ func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) require.Equal(t, v1, got) } +func TestEmbeddedRPCOverlayTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + require.NoError(t, freshDomains.InitBlockOverlay(freshTx, t.TempDir())) + + overlayTx := freshDomains.BlockOverlay().NewReadView(rpcTx) + events := shards.NewEvents() + events.PublishOverlay(freshDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, overlayTx) + require.NoError(t, err) + + got, err := rpcView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the overlay read view still reads from the old RPC transaction") + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "an overlay around an old RPC transaction must not refill the discarded fork") + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() From 3658ffa8bc90ef58b930ee269d625630e75ad668 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:03:04 +0200 Subject: [PATCH 07/36] execution/cache, db/state: close staged-unwind fill window --- db/state/execctx/codehash_routing_test.go | 5 +- db/state/execctx/domain_shared.go | 110 ++++++++---- db/state/execctx/export_test.go | 3 +- db/state/execctx/statecache_readfill_test.go | 26 ++- .../statecache_rpc_integration_test.go | 65 ++++++++ execution/cache/cache_test.go | 124 ++++++++++++++ execution/cache/state_cache.go | 156 ++++++++++++++---- execution/cache/view.go | 78 +++++++-- execution/exec/blocks_read_ahead.go | 15 +- 9 files changed, 498 insertions(+), 84 deletions(-) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 211e3cadaea..8f8df9b8be8 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -45,7 +45,10 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - sc.View(frontierAt(0)).SeedAddrCodeHash(addr[:], staleArr, 0) + sc.View(frontierAtStateVersion(t, rwTx, frontierAt(0))).SeedAddrCodeHash(addr[:], staleArr, 0) + seeded, ok := sc.View(nil).GetAddrCodeHash(addr[:]) + require.True(t, ok) + require.Equal(t, staleArr, seeded) 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 0eca2c3a3c0..b334b1dc2ad 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -173,9 +173,14 @@ func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return f.sd.domainVisibleEnd(f.tx, domain) } +func (f sdFrontier) StateVersion() (uint64, bool) { + return f.sd.baseStateVersion, f.sd.baseStateVersionKnown +} + type rejectedFrontier struct{} func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, false } +func (rejectedFrontier) StateVersion() (uint64, bool) { return 0, false } // cacheGenerationTx unwraps table overlays because their sequence metadata // belongs to the overlay, while cache fills read temporal domains from the @@ -278,8 +283,10 @@ type SharedDomains struct { // stateCache is an optional cache for state data (accounts, storage, code); // cacheApplier is its authoritative writer handle (commit/unwind only). - stateCache *cache.StateCache - cacheApplier cache.Applier + stateCache *cache.StateCache + cacheApplier cache.Applier + cacheUnwindTo uint64 + cacheUnwindPending bool // Backing frontiers stay fixed while writes and staged unwinds remain in // mem; both reach the transaction during flush, which resets the memo. @@ -839,8 +846,13 @@ 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 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. + // they are not below the reorg window. Commit repeats the invalidation at the + // durable state-version boundary, so no fill admitted while staged survives. sd.cacheApplier.Unwind(txNumUnwindTo) + if !sd.cacheUnwindPending || txNumUnwindTo < sd.cacheUnwindTo { + sd.cacheUnwindTo = txNumUnwindTo + } + sd.cacheUnwindPending = true } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -905,8 +917,15 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } + sd.bindStateCache(stateCache) +} + +func (sd *SharedDomains) bindStateCache(stateCache *cache.StateCache) { sd.stateCache = stateCache sd.cacheApplier = stateCache.Applier() + if sd.baseStateVersionKnown { + sd.cacheApplier.Initialize(sd.baseStateVersion) + } } // GuardAggregatorForCache forbids visibility lowering on db's aggregator when @@ -1070,12 +1089,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 branchCacheUpdate struct { + key []byte + val []byte + step kv.Step + txN uint64 } // Commit flushes the in-memory batch into tx, commits tx, and only then applies @@ -1114,21 +1132,38 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun } return tx.Commit() } + var sourceStateVersion uint64 + if sd.stateCache != nil { + stateVersion, err := rawdb.GetStateVersion(tx) + if err != nil { + return fmt.Errorf("read state version before flush: %w", err) + } + sourceStateVersion = stateVersion + } // 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 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 + var pendingBranches []branchCacheUpdate + var pendingState []cache.StateUpdate stash := 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, + if domain == kv.CommitmentDomain { + pendingBranches = append(pendingBranches, branchCacheUpdate{ + key: append([]byte(nil), k...), + val: append([]byte(nil), v...), + step: step, + txN: txNum, + }) + return + } + pendingState = append(pendingState, cache.StateUpdate{ + Domain: domain, + Key: append([]byte(nil), k...), + Value: append([]byte(nil), v...), + TxNum: txNum, }) }) } @@ -1150,12 +1185,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.StateUpdate{ + Domain: kv.CodeDomain, + Key: append([]byte(nil), k...), + Value: append([]byte(nil), v...), + TxNum: txNum, }) } })) @@ -1223,20 +1257,32 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader, factory, provider) } } + var committedStateVersion uint64 + if sd.stateCache != nil { + stateVersion, err := rawdb.GetStateVersion(tx) + if err != nil { + return fmt.Errorf("read state version before commit: %w", err) + } + committedStateVersion = stateVersion + } 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 pendingBranches { + u := &pendingBranches[i] + if len(u.val) == 0 { + sd.branchCache.Invalidate(u.key) + } else { + sd.branchCache.Put(u.key, u.val, uint64(u.step), u.txN) + } + } + if sd.stateCache != nil { + if sd.cacheUnwindPending { + sd.cacheApplier.PublishUnwind(sourceStateVersion, committedStateVersion, sd.cacheUnwindTo, pendingState) + } else { + sd.cacheApplier.Publish(sourceStateVersion, committedStateVersion, pendingState) } - sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) + sd.cacheUnwindPending = false } return nil } diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index 868dacd5a98..587368ee375 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -17,6 +17,5 @@ func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // it so they always exercise the cache instead of skipping when the env is off // — 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() + sd.bindStateCache(sc) } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 24786c74629..0a4202cccdf 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -29,6 +29,7 @@ import ( "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/types/accounts" @@ -81,16 +82,31 @@ func frontierAt(end uint64) cache.Frontier { return cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) } +type stateVersionFrontier struct { + cache.Frontier + stateVersion uint64 +} + +func (f stateVersionFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } + +func frontierAtStateVersion(t *testing.T, tx kv.Tx, frontier cache.Frontier) cache.Frontier { + t.Helper() + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + return stateVersionFrontier{Frontier: frontier, stateVersion: stateVersion} +} + // 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) { +func seed(t *testing.T, sc *cache.StateCache, tx kv.Tx, domain kv.Domain, k, v []byte, txNum uint64) { + t.Helper() end := uint64(math.MaxUint64) if len(v) == 0 { end = txNum + 1 } - sc.View(frontierAt(end)).Fill(domain, k, v, txNum) + sc.View(frontierAtStateVersion(t, tx, frontierAt(end))).Fill(domain, k, v, txNum) } type visibleEndCountingDebugTx struct { @@ -186,7 +202,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { 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) + seed(t, sc, roTx, kv.AccountsDomain, key, v1, 5) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -242,7 +258,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) sd2.SetStateCacheForTest(sc) sd2.Unwind(3, &diffs) - seed(sc, kv.AccountsDomain, key, nil, 2) + seed(t, sc, roTx, kv.AccountsDomain, key, nil, 2) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -282,7 +298,7 @@ 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) - seed(sc, kv.AccountsDomain, key, v3, 40) + seed(t, sc, roTx, kv.AccountsDomain, key, v3, 40) v, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) require.NoError(t, err) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 7c32781a6bf..1f8f58a06c6 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -96,6 +96,71 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { require.Equal(t, v1, got) } +func TestEmbeddedRPCViewCreatedDuringStagedUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + publishedTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer publishedTx.Rollback() + publishedDomains, err := execctx.NewSharedDomains(ctx, publishedTx, log.New()) + require.NoError(t, err) + defer publishedDomains.Close() + publishedDomains.SetStateCacheForTest(stateCache) + + events := shards.NewEvents() + events.PublishOverlay(publishedDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + got, err := rpcView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the database still holds the old canonical state before unwind commit") + + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the commit must invalidate fills admitted during the staged unwind") + lateRPCView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + got, err = lateRPCView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the previous published SD still serves its old durable snapshot") + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the committed state version must reject later fills from the previous published SD") + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index c669156e382..dcdffab82e0 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -62,6 +62,17 @@ func frontierAt(end uint64) Frontier { return FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) } +type versionedFrontier struct { + Frontier + stateVersion uint64 +} + +func (f versionedFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } + +func frontierAtVersion(end, stateVersion uint64) Frontier { + return versionedFrontier{Frontier: frontierAt(end), stateVersion: stateVersion} +} + // ============================================================================= // DomainCache Tests // ============================================================================= @@ -970,6 +981,119 @@ func TestStateCache_PreReorgViewCannotFillAfterUnwind(t *testing.T) { require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") } +func TestStateCache_InitializeDoesNotMoveStateVersionBackward(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + sc.Applier().Initialize(3) + sc.Applier().Initialize(2) + + staleKey := makeAddr(1) + sc.View(frontierAtVersion(11, 2)).Fill(kv.AccountsDomain, staleKey, makeValue(1), 10) + _, ok := sc.View(nil).Get(kv.AccountsDomain, staleKey) + require.False(t, ok, "an older initializer must not reactivate stale fills") + + currentKey := makeAddr(2) + sc.View(frontierAtVersion(11, 3)).Fill(kv.AccountsDomain, currentKey, makeValue(2), 10) + _, ok = sc.View(nil).Get(kv.AccountsDomain, currentKey) + require.True(t, ok, "the accepted state version must remain active") +} + +func TestStateCache_InitializeClearsUnversionedEntries(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + sc.View(frontierAt(11)).Fill(kv.AccountsDomain, key, makeValue(1), 10) + sc.Applier().Initialize(1) + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "initialization cannot vouch for entries admitted without a state version") +} + +func TestStateCache_PublishRejectsOlderStateVersion(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + newer := makeValue(3) + sc.Applier().Initialize(1) + sc.Applier().Publish(1, 3, []StateUpdate{{Domain: kv.AccountsDomain, Key: key, Value: newer, TxNum: 30}}) + sc.Applier().Publish(1, 2, []StateUpdate{{Domain: kv.AccountsDomain, Key: key, Value: makeValue(2), TxNum: 20}}) + + got, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, newer, got, "a delayed older publication must not overwrite newer state") + + staleKey := makeAddr(2) + sc.View(frontierAtVersion(31, 2)).Fill(kv.AccountsDomain, staleKey, makeValue(2), 30) + _, ok = sc.View(nil).Get(kv.AccountsDomain, staleKey) + require.False(t, ok, "a rejected publication must not move fill admission backward") +} + +func TestStateCache_PublishClearsOnSkippedStateVersion(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + sc.Applier().Initialize(1) + sc.Applier().Apply(kv.AccountsDomain, key, makeValue(1), 10) + sc.Applier().Publish(2, 3, nil) + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "a skipped publication may omit the update that made an old entry stale") +} + +func TestStateCache_PublishKeepsEntriesWhenOneCommitAdvancesVersionMoreThanOnce(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + sc.Applier().Initialize(1) + sc.Applier().Apply(kv.AccountsDomain, key, makeValue(1), 10) + sc.Applier().Publish(1, 3, nil) + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "a complete publication must preserve unchanged entries") +} + +func TestStateCache_PublishUnwindSerializesWithFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + sc.Applier().Initialize(0) + + for committedStateVersion := uint64(1); committedStateVersion <= 100; committedStateVersion++ { + key := makeAddr(int(committedStateVersion)) + sc.Applier().Unwind(10) + view := sc.View(frontierAtVersion(11, committedStateVersion-1)) + + start := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + <-start + view.Fill(kv.AccountsDomain, key, makeValue(1), 10) + }() + go func() { + defer wg.Done() + <-start + sc.Applier().PublishUnwind(committedStateVersion-1, committedStateVersion, 10, nil) + }() + close(start) + wg.Wait() + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "a fill from the pre-commit state must not survive unwind publication") + } +} + func TestStateCache_FileEndViewCannotFillAtAppliedTx(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 b4e80596356..d5b45876ff1 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -55,10 +55,13 @@ const ( // Code uses CodeCache (two-level for deduplication). type StateCache struct { 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 + // admissionMu serializes fill admission with canonical publication. + // stateVersion identifies the durable state allowed to fill; appliedEnd + // rejects older domain frontiers within that state version. + admissionMu sync.RWMutex + appliedEnd [kv.DomainLen]uint64 + stateVersion uint64 + stateVersionKnown bool // readViewEpoch lets an unwind revoke fill authority from all older // ReadViews. It is StateCache-wide and advances only on unwind. Per-cache // entry epochs instead stamp stored values and also advance on Clear; @@ -213,14 +216,17 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { // 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) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, viewEpoch uint64) { +func (c *StateCache) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, + stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { + if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || + viewEpoch != c.readViewEpoch.Load() || + visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } cc.PutAddrCodeHash(addr, h, txNum) @@ -249,7 +255,8 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 // 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, viewEpoch uint64) { +func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, readTxNum, visibleEnd, + stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { cache := c.caches[domain] if cache == nil { return @@ -265,7 +272,9 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[domain] { + if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || + viewEpoch != c.readViewEpoch.Load() || + visibleEnd < c.appliedEnd[domain] { return } cache.PutIfAbsent(key, cloned, readTxNum) @@ -276,7 +285,8 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea // 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, viewEpoch uint64) { +func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibleEnd, accountsVisibleEnd, + stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok || len(value) == 0 { return @@ -285,7 +295,8 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || + if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || + viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return @@ -293,6 +304,13 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) } +// acceptsStateVersion runs under admissionMu. Standalone caches remain +// unversioned for low-level users; production initialization makes matching +// the fill source's durable state version mandatory. +func (c *StateCache) acceptsStateVersion(stateVersion uint64, known bool) bool { + return !c.stateVersionKnown || known && stateVersion == c.stateVersion +} + // 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) { @@ -303,45 +321,63 @@ func (c *StateCache) deleteKey(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) { - cache := c.caches[domain] - if cache == nil { - return +type preparedStateUpdate struct { + domain kv.Domain + key []byte + value []byte + codeHash []byte + txNum uint64 +} + +func prepareStateUpdate(update StateUpdate) preparedStateUpdate { + prepared := preparedStateUpdate{ + domain: update.Domain, + key: update.Key, + value: bytes.Clone(update.Value), + txNum: update.TxNum, } - var codeHash []byte - if domain == kv.CodeDomain && len(value) > 0 { - // 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) + if update.Domain == kv.CodeDomain && len(update.Value) > 0 { + prepared.codeHash = crypto.Keccak256(prepared.value) } + return prepared +} +// apply makes a committed domain update authoritative for subsequent fills. +func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { + prepared := prepareStateUpdate(StateUpdate{Domain: domain, Key: key, Value: value, TxNum: txNum}) c.admissionMu.Lock() defer c.admissionMu.Unlock() - c.noteApplied(domain, txNum) + c.applyLocked(prepared) +} + +func (c *StateCache) applyLocked(update preparedStateUpdate) { + cache := c.caches[update.domain] + if cache == nil { + return + } + c.noteApplied(update.domain, update.txNum) - switch domain { + switch update.domain { case kv.AccountsDomain: - putOrDelete(cache, key, value, txNum) - c.deleteAddrCodeHash(key) - if len(value) == 0 { + putOrDelete(cache, update.key, update.value, update.txNum) + c.deleteAddrCodeHash(update.key) + if len(update.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) + c.deleteKey(kv.CodeDomain, update.key) } case kv.CodeDomain: - if len(value) == 0 { - cache.Delete(key) - c.deleteAddrCodeHash(key) + if len(update.value) == 0 { + cache.Delete(update.key) + c.deleteAddrCodeHash(update.key) } else if codeCache, ok := cache.(*CodeCache); ok { - codeCache.PutWithCodeHash(key, value, codeHash, txNum) + codeCache.PutWithCodeHash(update.key, update.value, update.codeHash, update.txNum) } default: - putOrDelete(cache, key, value, txNum) + putOrDelete(cache, update.key, update.value, update.txNum) } } @@ -350,7 +386,7 @@ func putOrDelete(cache Cache, key, value []byte, txNum uint64) { cache.Delete(key) return } - cache.Put(key, bytes.Clone(value), txNum) + cache.Put(key, value, txNum) } func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { @@ -369,6 +405,10 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { func (c *StateCache) clear() { c.admissionMu.Lock() defer c.admissionMu.Unlock() + c.clearLocked() +} + +func (c *StateCache) clearLocked() { for _, cache := range c.caches { if cache != nil { cache.Clear() @@ -376,6 +416,11 @@ func (c *StateCache) clear() { } } +func (c *StateCache) resetForStateVersionLocked() { + c.clearLocked() + clear(c.appliedEnd[:]) +} + // Close releases every sub-cache's slot in the shared memory envelope so later // caches size against real concurrency. Idempotent. func (c *StateCache) Close() { @@ -395,6 +440,10 @@ func (c *StateCache) Close() { func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() + c.unwindLocked(unwindToTxNum) +} + +func (c *StateCache) unwindLocked(unwindToTxNum uint64) { c.readViewEpoch.Add(1) for _, cache := range c.caches { if cache != nil { @@ -406,6 +455,47 @@ func (c *StateCache) unwind(unwindToTxNum uint64) { } } +func (c *StateCache) initialize(stateVersion uint64) { + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + if c.stateVersionKnown && stateVersion <= c.stateVersion { + return + } + c.resetForStateVersionLocked() + c.stateVersion = stateVersion + c.stateVersionKnown = true +} + +func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, + hasUnwind bool, updates []StateUpdate) { + if committedStateVersion <= sourceStateVersion { + return + } + prepared := make([]preparedStateUpdate, len(updates)) + for i := range updates { + prepared[i] = prepareStateUpdate(updates[i]) + } + + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + if c.stateVersionKnown && committedStateVersion <= c.stateVersion { + return + } + if !c.stateVersionKnown || sourceStateVersion != c.stateVersion { + // The cache missed part of the source state. Incremental updates cannot + // repair unknown retained entries, so publish into an empty generation. + c.resetForStateVersionLocked() + } + if hasUnwind { + c.unwindLocked(unwindToTxNum) + } + for i := range prepared { + c.applyLocked(prepared[i]) + } + c.stateVersion = committedStateVersion + c.stateVersionKnown = true +} + // 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 diff --git a/execution/cache/view.go b/execution/cache/view.go index d3d0f6a0ec7..ee6fd2b5de9 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -34,6 +34,14 @@ type Frontier interface { DomainVisibleEnd(domain kv.Domain) (visibleEnd uint64, ok bool) } +// stateVersionFrontier identifies the durable state snapshot behind a +// frontier. A StateCache initialized with a state version admits fills only +// from frontiers that report the same version. +type stateVersionFrontier interface { + Frontier + StateVersion() (stateVersion uint64, ok bool) +} + // FrontierFunc adapts a function to the Frontier interface. type FrontierFunc func(domain kv.Domain) (visibleEnd uint64, ok bool) @@ -41,8 +49,9 @@ 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 disables the admission-gated fills (Fill, SeedAddrCodeHash); +// view's frontier and durable state version, and it must not outlive the +// transaction. A nil 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. // @@ -57,9 +66,11 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // fork. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { - c *StateCache - frontier Frontier - readViewEpoch uint64 + c *StateCache + frontier Frontier + stateVersion uint64 + stateVersionKnown bool + readViewEpoch uint64 } // View creates a ReadView vouched for by f. A nil f disables admission-gated fills. @@ -67,12 +78,18 @@ func (c *StateCache) View(f Frontier) ReadView { if c == nil { return ReadView{} } - return ReadView{c: c, frontier: f, readViewEpoch: c.readViewEpoch.Load()} + v := ReadView{c: c, readViewEpoch: c.readViewEpoch.Load()} + return v.WithFrontier(f) } -// WithFrontier binds f while preserving the original view's read-view epoch. +// WithFrontier binds f and its optional state version while preserving the +// original view's read-view epoch. func (v ReadView) WithFrontier(f Frontier) ReadView { v.frontier = f + v.stateVersion, v.stateVersionKnown = 0, false + if versioned, ok := f.(stateVersionFrontier); ok { + v.stateVersion, v.stateVersionKnown = versioned.StateVersion() + } return v } @@ -144,10 +161,12 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin if !ok { return } - v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, v.readViewEpoch) + v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, + v.stateVersion, v.stateVersionKnown, v.readViewEpoch) return } - v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.readViewEpoch) + v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, + v.stateVersion, v.stateVersionKnown, v.readViewEpoch) } // SeedAddrCodeHash offers an addr → codeHash mapping derived from an account @@ -161,7 +180,8 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { if !ok { return } - v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, v.readViewEpoch) + v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, + v.stateVersion, v.stateVersionKnown, v.readViewEpoch) } // FillCodeSize records the code length for codeHash. Content-addressed and @@ -181,9 +201,47 @@ type Applier struct { c *StateCache } +// StateUpdate is one committed domain mutation published to StateCache. +type StateUpdate struct { + Domain kv.Domain + Key []byte + Value []byte + TxNum uint64 +} + // Applier creates the writer handle. func (c *StateCache) Applier() Applier { return Applier{c: c} } +// Initialize establishes the first durable version or moves the cache forward +// when a newer read view proves that a publication was missed. Moving forward +// without a complete delta clears entries; an equal or older view does nothing. +func (a Applier) Initialize(stateVersion uint64) { + if a.c == nil { + return + } + a.c.initialize(stateVersion) +} + +// Publish applies one successful commit and advances the cache from its source +// state version to the committed state version in one critical section. Source +// continuity lets unchanged entries survive even if one commit advances the +// durable counter more than once. +func (a Applier) Publish(sourceStateVersion, committedStateVersion uint64, updates []StateUpdate) { + if a.c == nil { + return + } + a.c.publish(sourceStateVersion, committedStateVersion, 0, false, updates) +} + +// PublishUnwind republishes an unwind at commit so fills admitted after the +// staged invalidation cannot survive into the committed state version. +func (a Applier) PublishUnwind(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, updates []StateUpdate) { + if a.c == nil { + return + } + a.c.publish(sourceStateVersion, committedStateVersion, unwindToTxNum, true, updates) +} + // 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. diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 0dd5e11964d..90ad138daa6 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -17,6 +17,7 @@ import ( "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" @@ -91,12 +92,24 @@ type cachePopulatingGetter struct { stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) } +type readAheadFrontier struct { + cache.Frontier + stateVersion uint64 +} + +func (f readAheadFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } + func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx } debug := ttx.Debug() - return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(debug), stepSize: debug.StepSize()} + stateVersion, err := rawdb.GetStateVersion(ttx) + if err != nil { + return ttx + } + frontier := readAheadFrontier{Frontier: debug, stateVersion: stateVersion} + return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(frontier), stepSize: debug.StepSize()} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { From 89169c58443cf84dbd04be0a9d6fda60edd7f8ec Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:38:49 +0200 Subject: [PATCH 08/36] execution/cache: validate fill authority when views bind --- db/state/execctx/domain_shared.go | 15 +-- db/state/execctx/statecache_readfill_test.go | 9 +- execution/cache/cache.go | 7 +- execution/cache/cache_test.go | 50 ++++++--- execution/cache/state_cache.go | 49 ++++----- execution/cache/view.go | 104 ++++++++++++------- execution/exec/blocks_read_ahead.go | 9 +- execution/exec/blocks_read_ahead_test.go | 5 +- 8 files changed, 135 insertions(+), 113 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b334b1dc2ad..005d93f7517 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -173,15 +173,6 @@ func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return f.sd.domainVisibleEnd(f.tx, domain) } -func (f sdFrontier) StateVersion() (uint64, bool) { - return f.sd.baseStateVersion, f.sd.baseStateVersionKnown -} - -type rejectedFrontier struct{} - -func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, false } -func (rejectedFrontier) StateVersion() (uint64, bool) { return 0, false } - // cacheGenerationTx unwraps table overlays because their sequence metadata // belongs to the overlay, while cache fills read temporal domains from the // backing transaction. @@ -203,7 +194,7 @@ func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { // the real one on their first task; the placeholder can never fill. generationTx := cacheGenerationTx(tx) if generationTx == nil || !sd.baseStateVersionKnown { - return rejectedFrontier{} + return nil } sameStateGeneration := generationTx.ViewID() == sd.baseViewID if !sameStateGeneration { @@ -211,9 +202,9 @@ func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { sameStateGeneration = err == nil && stateVersion == sd.baseStateVersion } if !sameStateGeneration { - return rejectedFrontier{} + return nil } - return sdFrontier{sd: sd, tx: tx} + return cache.FrontierWithStateVersion(sdFrontier{sd: sd, tx: tx}, sd.baseStateVersion) } // cacheViewFor binds the shared state cache to tx's read view. Boxing the diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 0a4202cccdf..1f4b6beb6b5 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -82,18 +82,11 @@ func frontierAt(end uint64) cache.Frontier { return cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) } -type stateVersionFrontier struct { - cache.Frontier - stateVersion uint64 -} - -func (f stateVersionFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } - func frontierAtStateVersion(t *testing.T, tx kv.Tx, frontier cache.Frontier) cache.Frontier { t.Helper() stateVersion, err := rawdb.GetStateVersion(tx) require.NoError(t, err) - return stateVersionFrontier{Frontier: frontier, stateVersion: stateVersion} + return cache.FrontierWithStateVersion(frontier, stateVersion) } // seed places an entry with an exact txNum stamp through the public fill API diff --git a/execution/cache/cache.go b/execution/cache/cache.go index a718d20a87d..b71d223b0f5 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -29,10 +29,11 @@ // behalf of a database reader after a miss); admission compares the view's // 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. A ReadView also snapshots a separate, StateCache-wide -// read-view epoch, which an unwind advances to revoke fills from older views. +// publications take. A ReadView also snapshots a separate, StateCache-wide +// read-view epoch, which an unwind or state-version discontinuity advances to +// revoke fills from older views. // The Applier handle, held by the SharedDomains commit/unwind path, performs -// the authoritative writes: post-commit applies, unwinds, clears. +// the authoritative writes: post-commit publications, unwinds and clears. package cache // Cache is the interface for domain caches. diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index dcdffab82e0..e5f9d88b94b 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -62,15 +62,8 @@ func frontierAt(end uint64) Frontier { return FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) } -type versionedFrontier struct { - Frontier - stateVersion uint64 -} - -func (f versionedFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } - func frontierAtVersion(end, stateVersion uint64) Frontier { - return versionedFrontier{Frontier: frontierAt(end), stateVersion: stateVersion} + return FrontierWithStateVersion(frontierAt(end), stateVersion) } // ============================================================================= @@ -1006,11 +999,15 @@ func TestStateCache_InitializeClearsUnversionedEntries(t *testing.T) { t.Cleanup(sc.Close) key := makeAddr(1) - sc.View(frontierAt(11)).Fill(kv.AccountsDomain, key, makeValue(1), 10) + view := sc.View(frontierAt(11)) + view.Fill(kv.AccountsDomain, key, makeValue(1), 10) sc.Applier().Initialize(1) _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.False(t, ok, "initialization cannot vouch for entries admitted without a state version") + view.Fill(kv.AccountsDomain, key, makeValue(1), 10) + _, ok = sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "initialization must revoke views bound before the state version was known") } func TestStateCache_PublishRejectsOlderStateVersion(t *testing.T) { @@ -1041,11 +1038,15 @@ func TestStateCache_PublishClearsOnSkippedStateVersion(t *testing.T) { key := makeAddr(1) sc.Applier().Initialize(1) - sc.Applier().Apply(kv.AccountsDomain, key, makeValue(1), 10) + sc.apply(kv.AccountsDomain, key, makeValue(1), 10) + staleView := sc.View(frontierAtVersion(11, 1)) sc.Applier().Publish(2, 3, nil) _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.False(t, ok, "a skipped publication may omit the update that made an old entry stale") + staleView.Fill(kv.AccountsDomain, key, makeValue(1), 10) + _, ok = sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "a skipped publication must revoke previously bound views") } func TestStateCache_PublishKeepsEntriesWhenOneCommitAdvancesVersionMoreThanOnce(t *testing.T) { @@ -1055,13 +1056,28 @@ func TestStateCache_PublishKeepsEntriesWhenOneCommitAdvancesVersionMoreThanOnce( key := makeAddr(1) sc.Applier().Initialize(1) - sc.Applier().Apply(kv.AccountsDomain, key, makeValue(1), 10) + sc.apply(kv.AccountsDomain, key, makeValue(1), 10) sc.Applier().Publish(1, 3, nil) _, ok := sc.View(nil).Get(kv.AccountsDomain, key) require.True(t, ok, "a complete publication must preserve unchanged entries") } +func TestStateCache_BoundViewCanFillAcrossContinuousPublication(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + sc.Applier().Initialize(1) + view := sc.View(frontierAtVersion(11, 1)) + sc.Applier().Publish(1, 2, nil) + view.Fill(kv.AccountsDomain, key, makeValue(1), 10) + + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "a continuous forward publication must not revoke an already-eligible view") +} + func TestStateCache_PublishUnwindSerializesWithFill(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) @@ -1243,7 +1259,7 @@ func TestDomainCache_ClearAtomicWithPut_NoSizeDrift(t *testing.T) { // 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. +// switch. Canonical publication keeps working. func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") b := 1 * datasize.MB @@ -1267,9 +1283,9 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { _, 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) + c.Applier().Publish(0, 1, []StateUpdate{{Domain: kv.AccountsDomain, Key: key, Value: []byte("applied"), TxNum: 20}}) got, ok := c.View(nil).Get(kv.AccountsDomain, key) - require.True(t, ok, "applies must keep working") + require.True(t, ok, "canonical publication must keep working") require.Equal(t, []byte("applied"), got) } @@ -1284,7 +1300,7 @@ func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { 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.apply(kv.AccountsDomain, key, nil, 20) // canonical delete sc.Applier().Clear() oldView.Fill(kv.AccountsDomain, key, []byte("pre-delete"), 10) @@ -1308,8 +1324,8 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { 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) + c.apply(kv.CodeDomain, addr, code, 100) + c.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) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index d5b45876ff1..873cf71bd3b 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -62,14 +62,14 @@ type StateCache struct { appliedEnd [kv.DomainLen]uint64 stateVersion uint64 stateVersionKnown bool - // readViewEpoch lets an unwind revoke fill authority from all older - // ReadViews. It is StateCache-wide and advances only on unwind. Per-cache - // entry epochs instead stamp stored values and also advance on Clear; - // sharing them would make Clear revoke otherwise valid read views. + // readViewEpoch lets an unwind or state-version discontinuity revoke fill + // authority from all older ReadViews. Per-cache entry epochs instead stamp + // stored values and also advance on Clear; sharing them would make an + // ordinary Clear revoke otherwise valid read views. readViewEpoch 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. + // (including the content-addressed ones), leaving canonical publication as + // the only writer — an A/B lever and an operational kill switch. disableFills bool } @@ -82,7 +82,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 post-commit applies populate the cache") + log.Info("[cache] STATE_CACHE_FILLS=false — read fills disabled, only post-commit publication populates the cache") } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) @@ -217,15 +217,14 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { // 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) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd, - stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { + viewEpoch uint64) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || - viewEpoch != c.readViewEpoch.Load() || + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } @@ -242,7 +241,7 @@ 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). It bypasses fill -// admission: committed updates go through Applier.Apply, read fills through +// admission: committed updates go through Applier.Publish, read fills through // ReadView.Fill. func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64) { cache := c.caches[domain] @@ -256,13 +255,13 @@ func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint6 // 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, - stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { + viewEpoch uint64) { cache := c.caches[domain] if cache == nil { return } // Clone outside the lock: a rejected fill wastes one copy (rare), but - // Apply's write lock never waits on a fill's memcpy. + // A publication's write lock never waits on a fill's memcpy. cloned := bytes.Clone(value) if len(value) == 0 { readTxNum = 0 @@ -272,8 +271,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || - viewEpoch != c.readViewEpoch.Load() || + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[domain] { return } @@ -286,7 +284,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea // 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, - stateVersion uint64, stateVersionKnown bool, viewEpoch uint64) { + viewEpoch uint64) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok || len(value) == 0 { return @@ -295,8 +293,7 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if !c.acceptsStateVersion(stateVersion, stateVersionKnown) || - viewEpoch != c.readViewEpoch.Load() || + if viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return @@ -304,13 +301,6 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) } -// acceptsStateVersion runs under admissionMu. Standalone caches remain -// unversioned for low-level users; production initialization makes matching -// the fill source's durable state version mandatory. -func (c *StateCache) acceptsStateVersion(stateVersion uint64, known bool) bool { - return !c.stateVersionKnown || known && stateVersion == c.stateVersion -} - // 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) { @@ -417,6 +407,9 @@ func (c *StateCache) clearLocked() { } func (c *StateCache) resetForStateVersionLocked() { + // Clearing entries is not enough: views bound to the previous state could + // otherwise refill them after continuity was lost. + c.readViewEpoch.Add(1) c.clearLocked() clear(c.appliedEnd[:]) } @@ -481,12 +474,12 @@ func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindTo if c.stateVersionKnown && committedStateVersion <= c.stateVersion { return } - if !c.stateVersionKnown || sourceStateVersion != c.stateVersion { + discontinuous := !c.stateVersionKnown || sourceStateVersion != c.stateVersion + if discontinuous { // The cache missed part of the source state. Incremental updates cannot // repair unknown retained entries, so publish into an empty generation. c.resetForStateVersionLocked() - } - if hasUnwind { + } else if hasUnwind { c.unwindLocked(unwindToTxNum) } for i := range prepared { diff --git a/execution/cache/view.go b/execution/cache/view.go index ee6fd2b5de9..89e679b35d5 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -39,7 +39,23 @@ type Frontier interface { // from frontiers that report the same version. type stateVersionFrontier interface { Frontier - StateVersion() (stateVersion uint64, ok bool) + StateVersion() uint64 +} + +type frontierWithStateVersion struct { + Frontier + stateVersion uint64 +} + +func (f frontierWithStateVersion) StateVersion() uint64 { return f.stateVersion } + +// FrontierWithStateVersion attaches the durable state identity used when a +// StateCache decides whether a frontier may fill. +func FrontierWithStateVersion(frontier Frontier, stateVersion uint64) Frontier { + if frontier == nil { + return nil + } + return frontierWithStateVersion{Frontier: frontier, stateVersion: stateVersion} } // FrontierFunc adapts a function to the Frontier interface. @@ -49,8 +65,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 and durable state version, and it must not outlive the -// transaction. A nil frontier disables the admission-gated fills +// view's frontier, and it must not outlive the transaction. A nil 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. @@ -63,36 +79,67 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // per-cache entry epoch and floor. // Each view also snapshots the StateCache read-view epoch. An unwind advances // that epoch, so older views can still read but cannot fill from the discarded -// fork. +// fork. State version is checked when the frontier is bound, not on every fill: +// continuous forward publication keeps the view eligible, while the domain +// frontier rejects values older than the latest update. A discontinuity also +// advances the epoch and revokes every previously bound view. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { - c *StateCache - frontier Frontier - stateVersion uint64 - stateVersionKnown bool - readViewEpoch uint64 + c *StateCache + frontier Frontier + readViewEpoch uint64 } -// View creates a ReadView vouched for by f. A nil f disables admission-gated fills. +// View creates a ReadView vouched for by f. If the cache has a durable state +// version, f must report the same version when it is bound. A nil or rejected +// frontier disables admission-gated fills. func (c *StateCache) View(f Frontier) ReadView { if c == nil { return ReadView{} } - v := ReadView{c: c, readViewEpoch: c.readViewEpoch.Load()} - return v.WithFrontier(f) + if f == nil { + return ReadView{c: c, readViewEpoch: c.readViewEpoch.Load()} + } + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + return ReadView{ + c: c, + frontier: c.eligibleFrontierLocked(f), + readViewEpoch: c.readViewEpoch.Load(), + } } -// WithFrontier binds f and its optional state version while preserving the -// original view's read-view epoch. +// WithFrontier binds f while preserving the original view's read-view epoch. +// Binding is serialized with publication so a transaction from an older +// durable state cannot gain fill authority after an unwind commits. func (v ReadView) WithFrontier(f Frontier) ReadView { - v.frontier = f - v.stateVersion, v.stateVersionKnown = 0, false - if versioned, ok := f.(stateVersionFrontier); ok { - v.stateVersion, v.stateVersionKnown = versioned.StateVersion() + if v.c == nil { + return v + } + if f == nil { + v.frontier = nil + return v } + v.c.admissionMu.RLock() + defer v.c.admissionMu.RUnlock() + v.frontier = v.c.eligibleFrontierLocked(f) return v } +func (c *StateCache) eligibleFrontierLocked(frontier Frontier) Frontier { + if frontier == nil || !c.stateVersionKnown { + return frontier + } + versioned, ok := frontier.(stateVersionFrontier) + if !ok { + return nil + } + if versioned.StateVersion() != c.stateVersion { + return nil + } + return frontier +} + // 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. @@ -161,12 +208,10 @@ func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uin if !ok { return } - v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, - v.stateVersion, v.stateVersionKnown, v.readViewEpoch) + v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd, v.readViewEpoch) return } - v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, - v.stateVersion, v.stateVersionKnown, v.readViewEpoch) + v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.readViewEpoch) } // SeedAddrCodeHash offers an addr → codeHash mapping derived from an account @@ -180,8 +225,7 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { if !ok { return } - v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, - v.stateVersion, v.stateVersionKnown, v.readViewEpoch) + v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd, v.readViewEpoch) } // FillCodeSize records the code length for codeHash. Content-addressed and @@ -195,7 +239,7 @@ func (v ReadView) FillCodeSize(codeHash []byte, size int, txNum uint64) { } // Applier is the authoritative writer handle of a StateCache: post-commit -// applies, unwinds and clears. It belongs to the authoritative mutation path +// publications, 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 @@ -242,16 +286,6 @@ func (a Applier) PublishUnwind(sourceStateVersion, committedStateVersion, unwind a.c.publish(sourceStateVersion, committedStateVersion, unwindToTxNum, true, updates) } -// 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) { diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 90ad138daa6..c5ba5844f70 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -92,13 +92,6 @@ type cachePopulatingGetter struct { stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) } -type readAheadFrontier struct { - cache.Frontier - stateVersion uint64 -} - -func (f readAheadFrontier) StateVersion() (uint64, bool) { return f.stateVersion, true } - func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx @@ -108,7 +101,7 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter if err != nil { return ttx } - frontier := readAheadFrontier{Frontier: debug, stateVersion: stateVersion} + frontier := cache.FrontierWithStateVersion(debug, stateVersion) return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(frontier), stepSize: debug.StepSize()} } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 439ac18982e..82061b8c8a3 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -190,11 +190,12 @@ func TestCachePopulatingGetterUnavailableVisibleEndNeverFills(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.Applier().Apply(kv.AccountsDomain, key, nil, 20) + sc.Applier().Publish(0, 1, []cache.StateUpdate{{Domain: kv.AccountsDomain, Key: key, TxNum: 20}}) cpg := &cachePopulatingGetter{ TemporalGetter: stubTemporalGetter{v: []byte("pre-delete-record")}, stepSize: 1_562_500, - view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true })), + view: sc.View(cache.FrontierWithStateVersion( + cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true }), 1)), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) From ca9868085668afd422567c074d37c642a8b9188c Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:02:56 +0200 Subject: [PATCH 09/36] db/kv/membatchwithdb: flush only changed sequences --- db/kv/membatchwithdb/memory_mutation.go | 63 ++++++++++++++++++-- db/kv/membatchwithdb/memory_mutation_test.go | 50 ++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 086a242411e..0b7e1feca04 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -55,6 +55,7 @@ type MemoryMutation struct { deletedEntries map[string]map[string]struct{} deletedDups map[string]map[string]map[string]struct{} clearedTables map[string]struct{} + sequenceWrites map[string]struct{} db kv.TemporalTx statelessCursors map[string]kv.RwCursor DomainReader DomainReader @@ -84,6 +85,7 @@ func NewMemoryBatch(tx kv.TemporalTx, tmpDir string, logger log.Logger) (*Memory deletedEntries: make(map[string]map[string]struct{}), deletedDups: map[string]map[string]map[string]struct{}{}, clearedTables: make(map[string]struct{}), + sequenceWrites: make(map[string]struct{}), }, nil } @@ -114,6 +116,7 @@ func NewMemoryBatchMDBX(tx kv.TemporalTx, tmpDir string, logger log.Logger) (mm deletedEntries: make(map[string]map[string]struct{}), deletedDups: map[string]map[string]map[string]struct{}{}, clearedTables: make(map[string]struct{}), + sequenceWrites: make(map[string]struct{}), }, nil } @@ -205,7 +208,11 @@ func initSequences(db kv.Tx, memTx kv.RwTx) error { func (m *MemoryMutation) IncrementSequence(bucket string, amount uint64) (uint64, error) { m.mu.Lock() defer m.mu.Unlock() - return m.memTx.IncrementSequence(bucket, amount) + previous, err := m.memTx.IncrementSequence(bucket, amount) + if err == nil && amount != 0 { + m.markSequenceWrite(bucket) + } + return previous, err } func (m *MemoryMutation) ReadSequence(bucket string) (uint64, error) { @@ -217,7 +224,18 @@ func (m *MemoryMutation) ReadSequence(bucket string) (uint64, error) { func (m *MemoryMutation) ResetSequence(bucket string, newValue uint64) error { m.mu.Lock() defer m.mu.Unlock() - return m.memTx.ResetSequence(bucket, newValue) + if err := m.memTx.ResetSequence(bucket, newValue); err != nil { + return err + } + m.markSequenceWrite(bucket) + return nil +} + +func (m *MemoryMutation) markSequenceWrite(key string) { + if m.sequenceWrites == nil { + m.sequenceWrites = make(map[string]struct{}) + } + m.sequenceWrites[key] = struct{}{} } func (m *MemoryMutation) ForAmount(bucket string, prefix []byte, amount uint32, walker func(k, v []byte) error) error { @@ -321,13 +339,25 @@ func (m *MemoryMutation) Has(table string, key []byte) (bool, error) { func (m *MemoryMutation) Put(table string, k, v []byte) error { m.mu.Lock() defer m.mu.Unlock() - return m.memTx.Put(table, k, v) + if err := m.memTx.Put(table, k, v); err != nil { + return err + } + if table == kv.Sequence { + m.markSequenceWrite(string(k)) + } + return nil } func (m *MemoryMutation) Append(table string, key []byte, value []byte) error { m.mu.Lock() defer m.mu.Unlock() - return m.memTx.Append(table, key, value) + if err := m.memTx.Append(table, key, value); err != nil { + return err + } + if table == kv.Sequence { + m.markSequenceWrite(string(key)) + } + return nil } func (m *MemoryMutation) AppendDup(table string, key []byte, value []byte) error { @@ -676,6 +706,14 @@ func (m *MemoryMutation) Flush(ctx context.Context, tx kv.RwTx) error { return ctx.Err() default: } + if bucket == kv.Sequence { + // Constructor-copied sequence values support overlay reads but are not + // writes. Replay only keys explicitly changed through this mutation. + if err := m.flushSequenceWrites(tx); err != nil { + return err + } + continue + } if isTablePurelyDupsort(bucket) { if err := flushDupsortBucket(m.memTx, tx, bucket); err != nil { return err @@ -689,6 +727,22 @@ func (m *MemoryMutation) Flush(ctx context.Context, tx kv.RwTx) error { return nil } +func (m *MemoryMutation) flushSequenceWrites(tx kv.RwTx) error { + for key := range m.sequenceWrites { + value, err := m.memTx.GetOne(kv.Sequence, []byte(key)) + if err != nil { + return err + } + if value == nil { + continue + } + if err := tx.Put(kv.Sequence, []byte(key), value); err != nil { + return err + } + } + return nil +} + // flushPlainBucket copies all keys from the in-memory bucket to the destination // transaction. When the destination's largest existing key is strictly less than // the source's smallest key — the common case for canonical chain advance, where @@ -1097,6 +1151,7 @@ func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { deletedEntries: m.deletedEntries, deletedDups: m.deletedDups, clearedTables: m.clearedTables, + sequenceWrites: m.sequenceWrites, db: dbTx, DomainReader: m.DomainReader, } diff --git a/db/kv/membatchwithdb/memory_mutation_test.go b/db/kv/membatchwithdb/memory_mutation_test.go index 661da20428f..1ae16fefb03 100644 --- a/db/kv/membatchwithdb/memory_mutation_test.go +++ b/db/kv/membatchwithdb/memory_mutation_test.go @@ -30,6 +30,7 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/membatchwithdb" "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/rawdb" ) func initializeDbNonDupSort(rwTx kv.RwTx) { @@ -517,6 +518,55 @@ func TestIncReadSequence(t *testing.T) { val, err := batch.ReadSequence(kv.HeaderNumber) require.NoError(t, err) require.Equal(t, uint64(12), val) + + require.NoError(t, batch.Flush(t.Context(), rwTx)) + val, err = rwTx.ReadSequence(kv.HeaderNumber) + require.NoError(t, err) + require.Equal(t, uint64(12), val, "an explicitly changed sequence must be flushed") +} + +func TestMemoryMutationFlushDoesNotOverwriteUnchangedStateVersion(t *testing.T) { + db, seedTx := newTestTx(t) + ctx := t.Context() + + _, err := rawdb.IncrementStateVersion(seedTx) + require.NoError(t, err) + require.NoError(t, seedTx.Commit()) + + snapshotTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer snapshotTx.Rollback() + batch, err := membatchwithdb.NewMemoryBatch(snapshotTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + require.NoError(t, batch.Put(kv.HeaderNumber, []byte("overlay-key"), []byte("overlay-value"))) + + advanceTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer advanceTx.Rollback() + _, err = rawdb.IncrementStateVersion(advanceTx) + require.NoError(t, err) + wantVersion, err := rawdb.GetStateVersion(advanceTx) + require.NoError(t, err) + require.NoError(t, advanceTx.Commit()) + + flushTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer flushTx.Rollback() + require.NoError(t, batch.Flush(ctx, flushTx)) + require.NoError(t, flushTx.Commit()) + + checkTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer checkTx.Rollback() + gotVersion, err := rawdb.GetStateVersion(checkTx) + require.NoError(t, err) + require.Equal(t, wantVersion, gotVersion, + "flushing an overlay must not replay the state-version value copied from its older snapshot") + overlayValue, err := checkTx.GetOne(kv.HeaderNumber, []byte("overlay-key")) + require.NoError(t, err) + require.Equal(t, []byte("overlay-value"), overlayValue, + "the overlay's explicit table writes must still be flushed") } func initializeDbDupSort(rwTx kv.RwTx) { From 992265a8a79e9ab8a7586b323b867e93f3116676 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:20:08 +0200 Subject: [PATCH 10/36] db/state/execctx: honor unwind bounds in code lookup --- db/state/execctx/domain_shared.go | 17 ++++-- db/state/execctx/statecache_readfill_test.go | 63 ++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 005d93f7517..39e31c55384 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1586,7 +1586,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if len(addr) == 0 { return nil } - bounded := false + hasUnwindBound := false // In-batch state is authoritative: sd.mem / parent.mem hold this batch's // uncommitted account writes, while the addr→codeHash LRU is invalidated only // on flush. Route mem-first; the LRU is a committed-state layer that may only @@ -1595,13 +1595,22 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if ok { return accounts.DeserialiseV3CodeHash(v) } - bounded = step != kv.NoStepBound + hasUnwindBound = step != kv.NoStepBound if sd.parent != nil { v, step, ok = sd.parent.mem.GetLatest(kv.AccountsDomain, addr) if ok { return accounts.DeserialiseV3CodeHash(v) } - bounded = bounded || step != kv.NoStepBound + hasUnwindBound = hasUnwindBound || step != kv.NoStepBound + } + if hasUnwindBound { + // A staged unwind bounds the committed lookup. Reuse the normal account + // path so every cache and database source observes the same bound. + v, _, err := sd.getLatestMetered(kv.AccountsDomain, tx, addr, nil, view) + if err != nil { + return nil + } + return accounts.DeserialiseV3CodeHash(v) } // Below mem: the addr → codeHash LRU caches committed state @@ -1636,7 +1645,7 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, } h, fromReadView := resolve() - if fromReadView && !bounded && sd.stateCache != nil { + if fromReadView && sd.stateCache != nil { 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 1f4b6beb6b5..f304081b56b 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/require" "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" @@ -378,6 +379,68 @@ func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { require.False(t, ok, "a bounded in-flight unwind read must not seed a code-hash mapping") } +func TestGetCode_RespectsStagedUnwindBound(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + codeStore := cache.NewCodeStore(1<<20, 1<<20) + + addr := make([]byte, 20) + addr[0] = 0xdd + code := []byte{0x60, 0x01, 0x60, 0x00, 0x55} + account := accounts.SerialiseV3(&accounts.Account{ + Nonce: 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.SetCodeStore(codeStore) + seedDomains.SetTxNum(20) + require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, addr, account, 20, nil)) + require.NoError(t, seedDomains.DomainPut(kv.CodeDomain, seedTx, addr, code, 20, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + + stepBytes := make([]byte, 8) + binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) + var diffs [kv.DomainLen][]kv.DomainEntryDiff + diffs[kv.AccountsDomain] = []kv.DomainEntryDiff{{Key: string(addr) + string(stepBytes)}} + diffs[kv.CodeDomain] = []kv.DomainEntryDiff{{Key: string(addr) + string(stepBytes)}} + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, roTx, log.New()) + require.NoError(t, err) + defer unwindDomains.Close() + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.SetCodeStore(codeStore) + unwindDomains.Unwind(10, &diffs) + + futureAddr := make([]byte, 20) + futureAddr[0] = 0xee + futureCode := []byte{0x60, 0x02, 0x60, 0x00, 0x55} + futureHash := crypto.Keccak256Hash(futureCode) + futureView := stateCache.View(frontierAtStateVersion(t, roTx, frontierAt(math.MaxUint64))) + futureView.Fill(kv.CodeDomain, futureAddr, futureCode, 40) + futureView.SeedAddrCodeHash(addr, [32]byte(futureHash), 40) + + got, ok, err := unwindDomains.GetCode(roTx, addr, 20) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, code, got, + "the code-hash fast path must ignore cache entries above the staged unwind bound") +} + // Background exec workers are constructed with a nil chainTx placeholder and // open their real tx on the first task; binding a getter for the placeholder // must not touch the tx. From 4e4878adb5506127f830aad95942acf6ff0030eb Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:10:37 +0200 Subject: [PATCH 11/36] db/state/execctx: preserve fills after state commit --- db/state/execctx/domain_shared.go | 34 +++++++++------ .../statecache_rpc_integration_test.go | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 39e31c55384..45d54462f78 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -187,24 +187,29 @@ func cacheGenerationTx(tx kv.TemporalTx) kv.TemporalTx { return nil } -// cacheFrontierFor grants fill authority only to transactions in the durable -// state generation from which this SharedDomains was created. +// cacheFrontierFor binds fill authority to the transaction's durable state +// version. StateCache admits it only while that version is current. func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { - // Background exec workers bind getters with a nil placeholder tx and open - // the real one on their first task; the placeholder can never fill. generationTx := cacheGenerationTx(tx) - if generationTx == nil || !sd.baseStateVersionKnown { + if generationTx == nil { return nil } - sameStateGeneration := generationTx.ViewID() == sd.baseViewID - if !sameStateGeneration { - stateVersion, err := rawdb.GetStateVersion(generationTx) - sameStateGeneration = err == nil && stateVersion == sd.baseStateVersion - } - if !sameStateGeneration { - return nil + stateVersion := sd.baseStateVersion + _, txWritable := generationTx.(kv.TemporalRwTx) + // A write transaction's ViewID is the snapshot ID it will create. After + // commit, a new read transaction can have that ID but a newer state version. + if generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable { + if !sd.baseStateVersionKnown { + return nil + } + } else { + var err error + stateVersion, err = rawdb.GetStateVersion(generationTx) + if err != nil { + return nil + } } - return cache.FrontierWithStateVersion(sdFrontier{sd: sd, tx: tx}, sd.baseStateVersion) + return cache.FrontierWithStateVersion(sdFrontier{sd: sd, tx: tx}, stateVersion) } // cacheViewFor binds the shared state cache to tx's read view. Boxing the @@ -241,6 +246,7 @@ type SharedDomains struct { logger log.Logger baseViewID uint64 + baseTxWritable bool baseStateVersion uint64 baseStateVersionKnown bool @@ -356,11 +362,13 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, generationTx := cacheGenerationTx(tx) stateVersion, stateVersionErr := rawdb.GetStateVersion(generationTx) + _, baseTxWritable := generationTx.(kv.TemporalRwTx) sd := &SharedDomains{ logger: logger, metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, stepSize: tx.Debug().StepSize(), baseViewID: generationTx.ViewID(), + baseTxWritable: baseTxWritable, baseStateVersion: stateVersion, baseStateVersionKnown: stateVersionErr == nil, } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 1f8f58a06c6..3638d8f53e1 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -96,6 +96,47 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { require.Equal(t, v1, got) } +func TestEmbeddedRPCViewOpenedAfterCommitCanFillStateCache(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + + commitTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer commitTx.Rollback() + publishedDomains, err := execctx.NewSharedDomains(ctx, commitTx, log.New()) + require.NoError(t, err) + defer publishedDomains.Close() + publishedDomains.SetStateCacheForTest(stateCache) + + written := make([]byte, 20) + written[0] = 0x01 + publishedDomains.SetTxNum(5) + require.NoError(t, publishedDomains.DomainPut(kv.AccountsDomain, commitTx, written, encAccount(1), 5, nil)) + require.NoError(t, publishedDomains.Commit(ctx, commitTx)) + + events := shards.NewEvents() + events.PublishOverlay(publishedDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + missing := make([]byte, 20) + missing[0] = 0x02 + got, err := rpcView.Get(missing) + require.NoError(t, err) + require.Empty(t, got) + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, missing) + require.True(t, ok, "a fresh RPC transaction must retain fill authority after the published SharedDomains commits") +} + func TestEmbeddedRPCViewCreatedDuringStagedUnwindDoesNotRefillUnwoundAccount(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() From 09e6216bb1cf605ca701e9b6845bc43c89b4d3d9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:19:33 +0200 Subject: [PATCH 12/36] db/state/execctx: retry cache state version reads --- db/state/execctx/domain_shared.go | 8 ++-- db/state/execctx/statecache_readfill_test.go | 43 ++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 45d54462f78..3736349bb1c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -198,11 +198,9 @@ func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { _, txWritable := generationTx.(kv.TemporalRwTx) // A write transaction's ViewID is the snapshot ID it will create. After // commit, a new read transaction can have that ID but a newer state version. - if generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable { - if !sd.baseStateVersionKnown { - return nil - } - } else { + useBaseStateVersion := sd.baseStateVersionKnown && + generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable + if !useBaseStateVersion { var err error stateVersion, err = rawdb.GetStateVersion(generationTx) if err != nil { diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index f304081b56b..351d079df8c 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" + "errors" "math" "testing" @@ -125,6 +126,48 @@ func (tx *visibleEndCountingRwTx) Debug() kv.TemporalDebugTx { return tx.debug } +type failStateVersionOnceRwTx struct { + kv.TemporalRwTx + stateVersionReads int +} + +func (tx *failStateVersionOnceRwTx) ReadSequence(table string) (uint64, error) { + if table == string(kv.PlainStateVersion) { + tx.stateVersionReads++ + if tx.stateVersionReads == 1 { + return 0, errors.New("temporary state-version read failure") + } + } + return tx.TemporalRwTx.ReadSequence(table) +} + +func TestReadFill_RetriesStateVersionAfterConstructionError(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + baseTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer baseTx.Rollback() + tx := &failStateVersionOnceRwTx{TemporalRwTx: baseTx} + + domains, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer domains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + domains.SetStateCacheForTest(stateCache) + + missing := make([]byte, 20) + missing[0] = 0x01 + value, _, err := domains.AsGetter(tx).GetLatest(kv.AccountsDomain, missing) + require.NoError(t, err) + require.Empty(t, value) + require.Equal(t, 2, tx.stateVersionReads, "binding the getter must retry the failed construction-time read") + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, missing) + require.True(t, ok, "the recovered state-version read must restore fill authority") +} + func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { t.Parallel() From 1b47021a49dec09ded902af428ee9941a1435caf Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:11:39 +0200 Subject: [PATCH 13/36] execution/exec, execmodule: quiesce read-ahead across unwinds --- execution/exec/blocks_read_ahead.go | 26 ++++++++-- execution/exec/blocks_read_ahead_test.go | 63 ++++++++++++++++++++++++ execution/execmodule/exec_module.go | 54 ++++++++++---------- execution/execmodule/forkchoice.go | 6 +-- execution/execmodule/set_head.go | 8 ++- 5 files changed, 118 insertions(+), 39 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index c5ba5844f70..1c14463c18d 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -33,8 +33,9 @@ type BlockReadAheader struct { bals *lru.Cache[common.Hash, []byte] // this is for warming state - warming atomic.Bool // only one warmBody can run at a time - warmWg sync.WaitGroup + warming atomic.Bool // only one warmBody can run at a time + warmWg sync.WaitGroup + warmupGate sync.RWMutex // stateCache is the process-global state cache that SharedDomains.GetLatest // consults on the EVM hot path. When set, warmBody routes its prefetches @@ -124,11 +125,28 @@ func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, h return } bra.warmWg.Go(func() { - bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming + defer bra.warming.Store(false) + bra.withWarmupPermit(func() { + bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming + }) }) } } +func (bra *BlockReadAheader) withWarmupPermit(warm func()) { + bra.warmupGate.RLock() + defer bra.warmupGate.RUnlock() + warm() +} + +// SuspendWarmup waits for active state-cache warmup and prevents another +// warmup from starting until the returned function is called. Keep it +// suspended while staged unwind state is being read or published. +func (bra *BlockReadAheader) SuspendWarmup() func() { + bra.warmupGate.Lock() + return bra.warmupGate.Unlock +} + // WaitForWarmup blocks until any in-flight warmBody goroutine finishes or // the context is cancelled. Call before closing the database to avoid // waitTxsAllDoneOnClose hangs. @@ -163,8 +181,6 @@ func (bra *BlockReadAheader) AddBlockAccessList(blockHash common.Hash, bal []byt // 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) { - defer bra.warming.Store(false) - if !dbg.ReadAhead { return } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 82061b8c8a3..32a104faa3e 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -19,6 +19,7 @@ package exec import ( "context" "testing" + "time" "github.com/c2h5oh/datasize" "github.com/holiman/uint256" @@ -68,6 +69,68 @@ func TestBlockReadAheaderCarriesBlockAccessList(t *testing.T) { require.Equal(t, bal, block.BlockAccessList()) } +func TestBlockReadAheaderSuspendWarmupWaitsForActiveWarmup(t *testing.T) { + bra := NewBlockReadAheader() + warmupStarted := make(chan struct{}) + finishWarmup := make(chan struct{}) + warmupDone := make(chan struct{}) + go func() { + bra.withWarmupPermit(func() { + close(warmupStarted) + <-finishWarmup + }) + close(warmupDone) + }() + <-warmupStarted + + suspendStarted := make(chan struct{}) + resumeReadAhead := make(chan func()) + go func() { + close(suspendStarted) + resumeReadAhead <- bra.SuspendWarmup() + }() + <-suspendStarted + select { + case resume := <-resumeReadAhead: + resume() + close(finishWarmup) + <-warmupDone + t.Fatal("SuspendWarmup returned while a warmup was active") + case <-time.After(50 * time.Millisecond): + } + + close(finishWarmup) + resume := <-resumeReadAhead + resume() + <-warmupDone +} + +func TestBlockReadAheaderSuspendWarmupBlocksNewWarmup(t *testing.T) { + bra := NewBlockReadAheader() + resume := bra.SuspendWarmup() + + warmupStarted := make(chan struct{}) + warmupAttempted := make(chan struct{}) + go func() { + close(warmupAttempted) + bra.withWarmupPermit(func() { close(warmupStarted) }) + }() + <-warmupAttempted + select { + case <-warmupStarted: + resume() + t.Fatal("warmup started while suspended") + case <-time.After(50 * time.Millisecond): + } + + resume() + select { + case <-warmupStarted: + case <-time.After(time.Second): + t.Fatal("warmup did not start after suspension ended") + } +} + // 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) { diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index edd986a4984..4801e1ed71b 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -393,34 +393,27 @@ 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 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 -// 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() { +// suspendReadAhead prevents raw-database warmup from filling the shared state +// cache while an unwind's staged state is being read or published. +func (e *ExecModule) suspendReadAhead() func() { if e.readAheader == nil { - return - } - ctx := e.bacgroundCtx - if ctx == nil { - ctx = context.Background() + return func() {} } - e.readAheader.WaitForWarmup(ctx) + return e.readAheader.SuspendWarmup() } -func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) error { +// unwindToCommonCanonical leaves read-ahead suspended after an unwind. The +// caller resumes it after validation stops reading the staged unwind state. +func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) (func(), error) { currentHeader := header for isCanonical, err := e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash()); !isCanonical && err == nil; isCanonical, err = e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash()) { parentBlockHash, parentBlockNum := currentHeader.ParentHash, currentHeader.Number.Uint64()-1 currentHeader, err = e.getHeader(e.bacgroundCtx, tx, parentBlockHash, parentBlockNum) if err != nil { - return err + return nil, err } if currentHeader == nil { - return makeErrMissingChainSegment(parentBlockHash) + return nil, makeErrMissingChainSegment(parentBlockHash) } } // Check if you can skip unwind by comparing the current header number with the progress of all stages. @@ -429,24 +422,31 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te commonProgress, allEqual, err := stages.GetStageProgressIfAllEqual(tx, stages.Headers, stages.Senders, stages.Execution) if err != nil { - return err + return nil, err } if allEqual && commonProgress == unwindPoint { - return nil + return nil, nil } if err := e.hook.BeforeRun(tx, true); err != nil { - return err + return nil, err } - e.drainReadAhead() + resumeReadAhead := e.suspendReadAhead() + handedOff := false + defer func() { + if !handedOff { + resumeReadAhead() + } + }() if err := e.pipelineExecutor.UnwindTo(unwindPoint, stagedsync.ExecUnwind, tx); err != nil { - return err + return nil, err } if err := e.pipelineExecutor.RunUnwind(sd, tx); err != nil { - return err + return nil, err } - return nil + handedOff = true + return resumeReadAhead, nil } const nextForkBanner = ` @@ -590,10 +590,14 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b // Set state cache in SharedDomains for use during state reading doms.SetStateCache(e.stateCache) doms.SetCodeStore(e.codeStore) - if err = e.unwindToCommonCanonical(doms, tx, header); err != nil { + resumeReadAhead, err := e.unwindToCommonCanonical(doms, tx, header) + if err != nil { doms.Close() return ValidationResult{}, err } + if resumeReadAhead != nil { + defer resumeReadAhead() + } status, lvh, validationError, criticalError := e.forkValidator.ValidatePayload(ctx, doms, tx, header, body.RawBody(), e.logger) if criticalError != nil { diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 7186fa73322..1f10df0c991 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -360,10 +360,8 @@ 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() + resumeReadAhead := e.suspendReadAhead() + defer resumeReadAhead() var validationError string diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 9a9c11c2004..38dab815afe 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -58,6 +58,9 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { } defer e.semaphore.Release(1) + resumeReadAhead := e.suspendReadAhead() + defer resumeReadAhead() + tx, err := e.db.BeginTemporalRw(ctx) if err != nil { return fmt.Errorf("failed to begin rw transaction: %w", err) @@ -111,11 +114,6 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { sd.SetStateCache(e.stateCache) sd.SetCodeStore(e.codeStore) - // 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() - // Set the unwind point and run the unwind if err := e.pipelineExecutor.UnwindTo(targetBlock, stagedsync.StagedUnwind, tx); err != nil { return fmt.Errorf("failed to set unwind point: %w", err) From e4b570e0026caaa8678b546389845e2cda04664a Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:23:06 +0200 Subject: [PATCH 14/36] execution/cache, db/state: clean up unwind cache tests and docs --- db/state/execctx/statecache_readfill_test.go | 23 ++----------------- .../statecache_rpc_integration_test.go | 4 ++++ execution/cache/cache.go | 5 ++-- execution/cache/state_cache.go | 1 - execution/cache/view.go | 1 + 5 files changed, 9 insertions(+), 25 deletions(-) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 351d079df8c..d8b41cc058d 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -353,6 +353,7 @@ func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) key, _, v2, diffs := twoStepRows(t, db, sc) roTx, err := db.BeginTemporalRo(ctx) @@ -380,6 +381,7 @@ func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) key := make([]byte, 20) key[0] = 0xcc @@ -484,27 +486,6 @@ func TestGetCode_RespectsStagedUnwindBound(t *testing.T) { "the code-hash fast path must ignore cache entries above the staged unwind bound") } -// Background exec workers are constructed with a nil chainTx placeholder and -// open their real tx on the first task; binding a getter for the placeholder -// must not touch the tx. -func TestAsGetterMeteredNilTx(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() - stateCache := newSmallStateCache() - t.Cleanup(stateCache.Close) - domains.SetStateCacheForTest(stateCache) - - require.NotPanics(t, func() { domains.AsGetterMetered(nil, nil) }) -} - // 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) { diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 3638d8f53e1..a30718b0626 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -163,6 +163,7 @@ func TestEmbeddedRPCViewCreatedDuringStagedUnwindDoesNotRefillUnwoundAccount(t * defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) + defer unwindDomains.Close() unwindDomains.SetStateCacheForTest(stateCache) unwindDomains.Unwind(10, &diffs) @@ -219,6 +220,7 @@ func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) + defer unwindDomains.Close() unwindDomains.SetStateCacheForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) @@ -268,6 +270,7 @@ func TestEmbeddedRPCOverlayTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *test defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) + defer unwindDomains.Close() unwindDomains.SetStateCacheForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) @@ -319,6 +322,7 @@ func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testin defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) + defer unwindDomains.Close() unwindDomains.SetStateCacheForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index b71d223b0f5..b108f138f59 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -29,9 +29,8 @@ // behalf of a database reader after a miss); admission compares the view's // 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 -// publications take. A ReadView also snapshots a separate, StateCache-wide -// read-view epoch, which an unwind or state-version discontinuity advances to -// revoke fills from older views. +// publications take. +// // The Applier handle, held by the SharedDomains commit/unwind path, performs // the authoritative writes: post-commit publications, unwinds and clears. package cache diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 873cf71bd3b..05b63abe7aa 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -429,7 +429,6 @@ 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. -// It also advances readViewEpoch to revoke fill authority from older views. func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() defer c.admissionMu.Unlock() diff --git a/execution/cache/view.go b/execution/cache/view.go index 89e679b35d5..61a6d32aff6 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -77,6 +77,7 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // monotonicity (content never regresses behind the applied frontier), // enforced on the fill side; unwinds invalidate stored entries by their // per-cache entry epoch and floor. +// // Each view also snapshots the StateCache read-view epoch. An unwind advances // that epoch, so older views can still read but cannot fill from the discarded // fork. State version is checked when the frontier is bound, not on every fill: From db28c7a1e7d10eaa8b909fba484ac3b8b5b03d50 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:47:41 +0200 Subject: [PATCH 15/36] execution, db/state: centralize state version advancement --- db/state/execctx/domain_shared.go | 66 +++++++++---- db/state/execctx/state_version_commit_test.go | 93 +++++++++++++++++++ db/state/temporal_mem_batch.go | 3 +- execution/execmodule/forkchoice.go | 5 + .../execmodule/notification_dispatcher.go | 20 ++-- .../notification_dispatcher_test.go | 71 ++++++++++++++ execution/stagedsync/exec3.go | 5 +- .../stagedsync/exec3_state_version_test.go | 63 +++++++++++++ execution/stagedsync/stageloop/stageloop.go | 11 ++- 9 files changed, 302 insertions(+), 35 deletions(-) create mode 100644 db/state/execctx/state_version_commit_test.go create mode 100644 execution/execmodule/notification_dispatcher_test.go create mode 100644 execution/stagedsync/exec3_state_version_test.go diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 3736349bb1c..a5ee9dc269d 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -21,6 +21,7 @@ import ( "context" "errors" "fmt" + "math" "runtime" "sync" "sync/atomic" @@ -1093,6 +1094,44 @@ type branchCacheUpdate struct { txN uint64 } +// ProjectedStateVersion returns the durable state version produced by the next +// successful Commit. +func (sd *SharedDomains) ProjectedStateVersion() (uint64, error) { + if !sd.baseStateVersionKnown { + return 0, errors.New("state version was unavailable when SharedDomains was created") + } + if sd.baseStateVersion == math.MaxUint64 { + return 0, errors.New("state version overflow") + } + return sd.baseStateVersion + 1, nil +} + +func (sd *SharedDomains) stateVersionsForCommit(tx kv.Tx) (source, target uint64, err error) { + target, err = sd.ProjectedStateVersion() + if err != nil { + return 0, 0, err + } + current, err := rawdb.GetStateVersion(tx) + if err != nil { + return 0, 0, fmt.Errorf("read state version before flush: %w", err) + } + if current != sd.baseStateVersion { + return 0, 0, fmt.Errorf("state version changed since SharedDomains was created: base=%d current=%d", sd.baseStateVersion, current) + } + return sd.baseStateVersion, target, nil +} + +func requireStateVersion(tx kv.Tx, expected uint64) error { + actual, err := rawdb.GetStateVersion(tx) + if err != nil { + return fmt.Errorf("read state version before commit: %w", err) + } + if actual != expected { + return fmt.Errorf("unexpected state version after flush: expected=%d actual=%d", expected, actual) + } + return nil +} + // Commit flushes the in-memory batch into tx, commits tx, and only then applies // the flushed domain bytes to the in-memory caches — CommitmentDomain to the // BranchCache, Accounts/Storage/Code to the StateCache. The flush is implicit in @@ -1104,9 +1143,14 @@ type branchCacheUpdate struct { // invalidation is tx-precise: an unwind to a txNum inside the latest step drops // exactly the entries above it, not the whole step. All caches honor the // same (txNum, epoch) model. tx MUST be a flush-specific transaction: it is -// committed here. +// committed here. The domain flush advances PlainStateVersion exactly once; +// Commit verifies both its starting version and the version it will publish. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) + sourceStateVersion, committedStateVersion, err := sd.stateVersionsForCommit(tx) + if err != nil { + return err + } runValidate := func() error { for _, v := range validate { @@ -1127,15 +1171,10 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun if err := runValidate(); err != nil { return err } - return tx.Commit() - } - var sourceStateVersion uint64 - if sd.stateCache != nil { - stateVersion, err := rawdb.GetStateVersion(tx) - if err != nil { - return fmt.Errorf("read state version before flush: %w", err) + if err := requireStateVersion(tx, committedStateVersion); err != nil { + return err } - sourceStateVersion = stateVersion + return tx.Commit() } // Stash every cache-bound domain tuple during the flush; apply them only @@ -1254,13 +1293,8 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader, factory, provider) } } - var committedStateVersion uint64 - if sd.stateCache != nil { - stateVersion, err := rawdb.GetStateVersion(tx) - if err != nil { - return fmt.Errorf("read state version before commit: %w", err) - } - committedStateVersion = stateVersion + if err := requireStateVersion(tx, committedStateVersion); err != nil { + return err } if err := tx.Commit(); err != nil { return err diff --git a/db/state/execctx/state_version_commit_test.go b/db/state/execctx/state_version_commit_test.go new file mode 100644 index 00000000000..73b1cdd8410 --- /dev/null +++ b/db/state/execctx/state_version_commit_test.go @@ -0,0 +1,93 @@ +// 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/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/execctx" +) + +func TestSharedDomainsCommitAdvancesStateVersionOnce(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 16) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer sd.Close() + projected, err := sd.ProjectedStateVersion() + require.NoError(t, err) + + require.NoError(t, sd.Commit(ctx, rwTx)) + require.NoError(t, db.View(ctx, func(tx kv.Tx) error { + committed, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + require.Equal(t, projected, committed) + return nil + })) +} + +func TestSharedDomainsCommitRejectsAnotherStateVersionWriter(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 16) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer sd.Close() + require.NoError(t, sd.InitBlockOverlay(rwTx, t.TempDir())) + _, err = rawdb.IncrementStateVersion(sd.BlockOverlay()) + require.NoError(t, err) + + err = sd.Commit(ctx, rwTx) + require.ErrorContains(t, err, "unexpected state version after flush") +} + +func TestSharedDomainsCommitRejectsStaleBaseStateVersion(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 16) + baseTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer baseTx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, baseTx, log.New()) + require.NoError(t, err) + defer sd.Close() + + advanceTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer advanceTx.Rollback() + _, err = rawdb.IncrementStateVersion(advanceTx) + require.NoError(t, err) + require.NoError(t, advanceTx.Commit()) + + commitTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer commitTx.Rollback() + err = sd.Commit(ctx, commitTx) + require.ErrorContains(t, err, "state version changed since SharedDomains was created") +} diff --git a/db/state/temporal_mem_batch.go b/db/state/temporal_mem_batch.go index 5f642e846b8..01b411d627a 100644 --- a/db/state/temporal_mem_batch.go +++ b/db/state/temporal_mem_batch.go @@ -736,7 +736,8 @@ func (sd *TemporalMemBatch) Merge(o kv.TemporalMemBatch) error { } // flushLocked is the body of Flush, factored so the callback path can run it -// inside latestStateLock without re-acquiring. +// inside latestStateLock without re-acquiring. PlainStateVersion advances here +// with the domain writes; metadata overlays must not advance it independently. func (sd *TemporalMemBatch) flushLocked(ctx context.Context, tx kv.RwTx) error { if sd.unwindChangesetRaw != nil { for domain := range sd.unwindChangesetRaw { diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 1f10df0c991..de2b388d9bb 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -784,6 +784,10 @@ func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, if err != nil { return err } + stateVersion, err := sd.ProjectedStateVersion() + if err != nil { + return fmt.Errorf("project notification state version: %w", err) + } // Publish the overlay BEFORE dispatching notifications. This ensures // the BlockListener (overlay-aware shutter) sees the overlay as active // before any StateChangeBatch arrives, so it can buffer events properly. @@ -794,6 +798,7 @@ func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, if err := dispatcher.Dispatch( e.bacgroundCtx, overlay, + stateVersion, e.accum.Accumulator, e.accum.RecentReceipts, finishProgressBefore, diff --git a/execution/execmodule/notification_dispatcher.go b/execution/execmodule/notification_dispatcher.go index 03fe89e3ad4..ae96ac23d19 100644 --- a/execution/execmodule/notification_dispatcher.go +++ b/execution/execmodule/notification_dispatcher.go @@ -43,9 +43,9 @@ func NewAccumulation() *Accumulation { // Shared between the DevP2P StageLoop path (via Hook) and the Engine API path // (via PipelineExecutor). // -// Key design: reads from a kv.Tx which can be either the SD's blockOverlay -// (before commit) or a committed DB tx (legacy path). This decouples -// notification dispatch from commit ordering. +// Key design: reads block metadata from a kv.Tx which can be either the SD's +// blockOverlay (before commit) or a committed DB tx. The state version is +// supplied separately because only the durable state flush owns that value. type Dispatcher struct { chainConfig *chain.Config events *shards.Events @@ -68,12 +68,13 @@ func NewDispatcher( } // Dispatch sends all pending notifications. The tx parameter is the data source -// for headers, state version, and forkchoice markers — it can be the SD's -// blockOverlay (MemoryMutation) for pre-commit dispatch, or a committed DB tx. +// for headers and forkchoice markers — it can be the SD's blockOverlay +// (MemoryMutation) for pre-commit dispatch, or a committed DB tx. // // Parameters: // - ctx: context for cancellation // - tx: data source (overlay or committed tx) +// - stateVersion: durable version represented by the accumulated state changes // - accumulator: state change accumulator (may be nil) // - recentReceipts: receipt/log cache (may be nil) // - finishProgressBefore: Finish stage progress before the sync run @@ -82,20 +83,15 @@ func NewDispatcher( func (d *Dispatcher) Dispatch( ctx context.Context, tx kv.Tx, + stateVersion uint64, accumulator *notifications.Accumulator, recentReceipts *notifications.RecentReceipts, finishProgressBefore uint64, finishProgressAfter uint64, prevUnwindPoint *uint64, ) error { - // Update the accumulator with the current plain state version so downstream - // consumers (e.g. state cache) know state has moved on. if accumulator != nil { - plainStateVersion, err := rawdb.GetStateVersion(tx) - if err != nil { - return err - } - accumulator.SetStateID(plainStateVersion) + accumulator.SetStateID(stateVersion) } if d.events != nil { diff --git a/execution/execmodule/notification_dispatcher_test.go b/execution/execmodule/notification_dispatcher_test.go new file mode 100644 index 00000000000..4bb3077031f --- /dev/null +++ b/execution/execmodule/notification_dispatcher_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package execmodule + +import ( + "context" + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/chain" + "github.com/erigontech/erigon/execution/notifications" + "github.com/erigontech/erigon/execution/types" + "github.com/erigontech/erigon/node/gointerfaces/remoteproto" +) + +type stateChangesCapture struct { + batch *remoteproto.StateChangeBatch +} + +func (c *stateChangesCapture) SendStateChanges(_ context.Context, batch *remoteproto.StateChangeBatch) { + c.batch = batch +} + +func TestDispatcherUsesSuppliedStateVersion(t *testing.T) { + _, tx := temporaltest.NewTestTx(t) + header := &types.Header{ + Number: *uint256.NewInt(1), + GasLimit: 30_000_000, + BaseFee: uint256.NewInt(1_000_000_000), + } + require.NoError(t, rawdb.WriteHeader(tx, header)) + require.NoError(t, rawdb.WriteHeadHeaderHash(tx, header.Hash())) + + accumulator := notifications.NewAccumulator() + accumulator.StartChange(header, nil, false) + capture := new(stateChangesCapture) + dispatcher := NewDispatcher(chain.AllProtocolChanges, nil, capture, log.New()) + + const projectedStateVersion = uint64(7) + require.NoError(t, dispatcher.Dispatch( + t.Context(), + tx, + projectedStateVersion, + accumulator, + nil, + 0, + 1, + nil, + )) + require.NotNil(t, capture.batch) + require.Equal(t, projectedStateVersion, capture.batch.StateVersionId) +} diff --git a/execution/stagedsync/exec3.go b/execution/stagedsync/exec3.go index afa06e3ae50..a1dbe3bfd9e 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -846,7 +846,7 @@ type FlushAndComputeCommitmentTimes struct { ComputeCommitment time.Duration } -// computeAndCheckCommitmentV3 - does write state to db and then check commitment +// computeAndCheckCommitmentV3 records execution progress and checks the commitment. func computeAndCheckCommitmentV3(ctx context.Context, header *types.Header, applyTx kv.TemporalRwTx, doms *execctx.SharedDomains, cfg ExecuteBlockCfg, e *StageState, parallel bool, logger log.Logger, u Unwinder) (ok bool, times FlushAndComputeCommitmentTimes, err error) { if header == nil { return false, times, errors.New("header is nil") @@ -860,9 +860,6 @@ func computeAndCheckCommitmentV3(ctx context.Context, header *types.Header, appl if err := e.Update(applyTx, header.Number.Uint64()); err != nil { return false, times, err } - if _, err := rawdb.IncrementStateVersion(applyTx); err != nil { - return false, times, fmt.Errorf("writing plain state version: %w", err) - } } if cfg.discardCommitment { diff --git a/execution/stagedsync/exec3_state_version_test.go b/execution/stagedsync/exec3_state_version_test.go new file mode 100644 index 00000000000..ebbda02b26d --- /dev/null +++ b/execution/stagedsync/exec3_state_version_test.go @@ -0,0 +1,63 @@ +// 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 stagedsync + +import ( + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/kv/membatchwithdb" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/execution/stagedsync/stages" + "github.com/erigontech/erigon/execution/types" +) + +func TestComputeAndCheckCommitmentDoesNotAdvanceStateVersionInOverlay(t *testing.T) { + _, tx := temporaltest.NewTestTx(t) + overlay, err := membatchwithdb.NewMemoryBatch(tx, t.TempDir(), log.New()) + require.NoError(t, err) + defer overlay.Close() + + before, err := rawdb.GetStateVersion(overlay) + require.NoError(t, err) + + stage := &StageState{ID: stages.Execution} + ok, _, err := computeAndCheckCommitmentV3( + t.Context(), + &types.Header{Number: *uint256.NewInt(1)}, + overlay, + nil, + ExecuteBlockCfg{discardCommitment: true}, + stage, + false, + log.New(), + nil, + ) + require.NoError(t, err) + require.True(t, ok) + + progress, err := stages.GetStageProgress(overlay, stages.Execution) + require.NoError(t, err) + require.Equal(t, uint64(1), progress) + after, err := rawdb.GetStateVersion(overlay) + require.NoError(t, err) + require.Equal(t, before, after, "execution metadata must not advance the durable state generation") +} diff --git a/execution/stagedsync/stageloop/stageloop.go b/execution/stagedsync/stageloop/stageloop.go index a2d12776f6e..f5969147616 100644 --- a/execution/stagedsync/stageloop/stageloop.go +++ b/execution/stagedsync/stageloop/stageloop.go @@ -52,7 +52,7 @@ import ( // an implementation defined in another package (e.g. execmodule.Dispatcher) // without creating a circular import. type NotificationSender interface { - Dispatch(ctx context.Context, tx kv.Tx, accumulator *shards.Accumulator, recentReceipts *shards.RecentReceipts, finishProgressBefore, finishProgressAfter uint64, prevUnwindPoint *uint64) error + Dispatch(ctx context.Context, tx kv.Tx, stateVersion uint64, accumulator *shards.Accumulator, recentReceipts *shards.RecentReceipts, finishProgressBefore, finishProgressAfter uint64, prevUnwindPoint *uint64) error } type FrozenBlocksReader interface { @@ -148,8 +148,15 @@ func (h *Hook) SendNotifications(tx kv.Tx, finishProgressBefore uint64) error { if err != nil { return err } + var stateVersion uint64 + if h.notifications.Accumulator != nil { + stateVersion, err = rawdb.GetStateVersion(tx) + if err != nil { + return err + } + } return h.dispatcher.Dispatch( - h.ctx, tx, + h.ctx, tx, stateVersion, h.notifications.Accumulator, h.notifications.RecentReceipts, finishProgressBefore, From 009990db06435c629951fdb867c0957e80b760c4 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:09:24 +0200 Subject: [PATCH 16/36] db/kv/membatchwithdb: lazily overlay sequences --- db/kv/membatchwithdb/memory_mutation.go | 105 +++++-------------- db/kv/membatchwithdb/memory_mutation_test.go | 42 +++++++- 2 files changed, 63 insertions(+), 84 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 0b7e1feca04..82b381fe9ee 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -19,6 +19,7 @@ package membatchwithdb import ( "bytes" "context" + "encoding/binary" "fmt" "sync" "time" @@ -55,7 +56,6 @@ type MemoryMutation struct { deletedEntries map[string]map[string]struct{} deletedDups map[string]map[string]map[string]struct{} clearedTables map[string]struct{} - sequenceWrites map[string]struct{} db kv.TemporalTx statelessCursors map[string]kv.RwCursor DomainReader DomainReader @@ -73,9 +73,6 @@ type MemoryMutation struct { func NewMemoryBatch(tx kv.TemporalTx, tmpDir string, logger log.Logger) (*MemoryMutation, error) { mem := newMemStore() memDB := &memStoreDB{store: mem} - if err := initSequences(tx, mem); err != nil { - return nil, fmt.Errorf("NewMemoryBatch: init sequences: %w", err) - } return &MemoryMutation{ mu: &sync.RWMutex{}, @@ -85,7 +82,6 @@ func NewMemoryBatch(tx kv.TemporalTx, tmpDir string, logger log.Logger) (*Memory deletedEntries: make(map[string]map[string]struct{}), deletedDups: map[string]map[string]map[string]struct{}{}, clearedTables: make(map[string]struct{}), - sequenceWrites: make(map[string]struct{}), }, nil } @@ -103,11 +99,6 @@ func NewMemoryBatchMDBX(tx kv.TemporalTx, tmpDir string, logger log.Logger) (mm if err != nil { return nil, fmt.Errorf("NewMemoryBatchMDBX: begin tx: %w", err) } - if err = initSequences(tx, memTx); err != nil { - memTx.Rollback() - return nil, fmt.Errorf("NewMemoryBatchMDBX: init sequences: %w", err) - } - return &MemoryMutation{ mu: &sync.RWMutex{}, db: tx, @@ -116,7 +107,6 @@ func NewMemoryBatchMDBX(tx kv.TemporalTx, tmpDir string, logger log.Logger) (mm deletedEntries: make(map[string]map[string]struct{}), deletedDups: map[string]map[string]map[string]struct{}{}, clearedTables: make(map[string]struct{}), - sequenceWrites: make(map[string]struct{}), }, nil } @@ -188,54 +178,44 @@ func (m *MemoryMutation) DBSize() (uint64, error) { panic("not implemented") } -func initSequences(db kv.Tx, memTx kv.RwTx) error { - cursor, err := db.Cursor(kv.Sequence) - if err != nil { - return err - } - defer cursor.Close() - for k, v, err := cursor.First(); k != nil; k, v, err = cursor.Next() { - if err != nil { - return err - } - if err := memTx.Put(kv.Sequence, k, v); err != nil { - return err - } - } - return nil -} - func (m *MemoryMutation) IncrementSequence(bucket string, amount uint64) (uint64, error) { m.mu.Lock() defer m.mu.Unlock() - previous, err := m.memTx.IncrementSequence(bucket, amount) - if err == nil && amount != 0 { - m.markSequenceWrite(bucket) + current, err := m.readSequenceLocked(bucket) + if err != nil || amount == 0 { + return current, err } - return previous, err + return current, m.memTx.ResetSequence(bucket, current+amount) } func (m *MemoryMutation) ReadSequence(bucket string) (uint64, error) { m.mu.RLock() defer m.mu.RUnlock() - return m.memTx.ReadSequence(bucket) + return m.readSequenceLocked(bucket) } func (m *MemoryMutation) ResetSequence(bucket string, newValue uint64) error { m.mu.Lock() defer m.mu.Unlock() - if err := m.memTx.ResetSequence(bucket, newValue); err != nil { - return err - } - m.markSequenceWrite(bucket) - return nil + return m.memTx.ResetSequence(bucket, newValue) } -func (m *MemoryMutation) markSequenceWrite(key string) { - if m.sequenceWrites == nil { - m.sequenceWrites = make(map[string]struct{}) +func (m *MemoryMutation) readSequenceLocked(bucket string) (uint64, error) { + key := []byte(bucket) + value, err := m.memTx.GetOne(kv.Sequence, key) + if err != nil { + return 0, err + } + if value != nil { + if len(value) == 0 { + return 0, nil + } + return binary.BigEndian.Uint64(value), nil + } + if m.isTableCleared(kv.Sequence) || m.isEntryDeleted(kv.Sequence, key) || m.db == nil { + return 0, nil } - m.sequenceWrites[key] = struct{}{} + return m.db.ReadSequence(bucket) } func (m *MemoryMutation) ForAmount(bucket string, prefix []byte, amount uint32, walker func(k, v []byte) error) error { @@ -339,25 +319,13 @@ func (m *MemoryMutation) Has(table string, key []byte) (bool, error) { func (m *MemoryMutation) Put(table string, k, v []byte) error { m.mu.Lock() defer m.mu.Unlock() - if err := m.memTx.Put(table, k, v); err != nil { - return err - } - if table == kv.Sequence { - m.markSequenceWrite(string(k)) - } - return nil + return m.memTx.Put(table, k, v) } func (m *MemoryMutation) Append(table string, key []byte, value []byte) error { m.mu.Lock() defer m.mu.Unlock() - if err := m.memTx.Append(table, key, value); err != nil { - return err - } - if table == kv.Sequence { - m.markSequenceWrite(string(key)) - } - return nil + return m.memTx.Append(table, key, value) } func (m *MemoryMutation) AppendDup(table string, key []byte, value []byte) error { @@ -706,14 +674,6 @@ func (m *MemoryMutation) Flush(ctx context.Context, tx kv.RwTx) error { return ctx.Err() default: } - if bucket == kv.Sequence { - // Constructor-copied sequence values support overlay reads but are not - // writes. Replay only keys explicitly changed through this mutation. - if err := m.flushSequenceWrites(tx); err != nil { - return err - } - continue - } if isTablePurelyDupsort(bucket) { if err := flushDupsortBucket(m.memTx, tx, bucket); err != nil { return err @@ -727,22 +687,6 @@ func (m *MemoryMutation) Flush(ctx context.Context, tx kv.RwTx) error { return nil } -func (m *MemoryMutation) flushSequenceWrites(tx kv.RwTx) error { - for key := range m.sequenceWrites { - value, err := m.memTx.GetOne(kv.Sequence, []byte(key)) - if err != nil { - return err - } - if value == nil { - continue - } - if err := tx.Put(kv.Sequence, []byte(key), value); err != nil { - return err - } - } - return nil -} - // flushPlainBucket copies all keys from the in-memory bucket to the destination // transaction. When the destination's largest existing key is strictly less than // the source's smallest key — the common case for canonical chain advance, where @@ -1151,7 +1095,6 @@ func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { deletedEntries: m.deletedEntries, deletedDups: m.deletedDups, clearedTables: m.clearedTables, - sequenceWrites: m.sequenceWrites, db: dbTx, DomainReader: m.DomainReader, } diff --git a/db/kv/membatchwithdb/memory_mutation_test.go b/db/kv/membatchwithdb/memory_mutation_test.go index 1ae16fefb03..f7d1c85461b 100644 --- a/db/kv/membatchwithdb/memory_mutation_test.go +++ b/db/kv/membatchwithdb/memory_mutation_test.go @@ -507,22 +507,58 @@ func TestIncReadSequence(t *testing.T) { _, rwTx := newTestTx(t) initializeDbNonDupSort(rwTx) + require.NoError(t, rwTx.ResetSequence(kv.HeaderNumber, 7)) batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root()) require.NoError(t, err) defer batch.Close() - _, err = batch.IncrementSequence(kv.HeaderNumber, uint64(12)) + previous, err := batch.IncrementSequence(kv.HeaderNumber, uint64(12)) require.NoError(t, err) + require.Equal(t, uint64(7), previous) val, err := batch.ReadSequence(kv.HeaderNumber) require.NoError(t, err) - require.Equal(t, uint64(12), val) + require.Equal(t, uint64(19), val) require.NoError(t, batch.Flush(t.Context(), rwTx)) val, err = rwTx.ReadSequence(kv.HeaderNumber) require.NoError(t, err) - require.Equal(t, uint64(12), val, "an explicitly changed sequence must be flushed") + require.Equal(t, uint64(19), val, "an explicitly changed sequence must be flushed") +} + +func TestMemoryMutationUntouchedSequenceFollowsUpdatedTransaction(t *testing.T) { + db, seedTx := newTestTx(t) + ctx := t.Context() + + _, err := rawdb.IncrementStateVersion(seedTx) + require.NoError(t, err) + require.NoError(t, seedTx.Commit()) + + initialTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer initialTx.Rollback() + batch, err := membatchwithdb.NewMemoryBatch(initialTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + + advanceTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer advanceTx.Rollback() + _, err = rawdb.IncrementStateVersion(advanceTx) + require.NoError(t, err) + wantVersion, err := rawdb.GetStateVersion(advanceTx) + require.NoError(t, err) + require.NoError(t, advanceTx.Commit()) + + latestTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer latestTx.Rollback() + batch.UpdateTxn(latestTx) + + gotVersion, err := rawdb.GetStateVersion(batch) + require.NoError(t, err) + require.Equal(t, wantVersion, gotVersion) } func TestMemoryMutationFlushDoesNotOverwriteUnchangedStateVersion(t *testing.T) { From 0b77ca6f97e1b88745c7da53e893e351c0fc5359 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:27:46 +0200 Subject: [PATCH 17/36] execution/cache: keep view binding live during publication --- execution/cache/cache.go | 5 +- execution/cache/cache_test.go | 84 +++++++++++++++++++++++++++++++++ execution/cache/state_cache.go | 85 ++++++++++++++++++++++++---------- execution/cache/view.go | 20 +++++--- 4 files changed, 161 insertions(+), 33 deletions(-) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index b108f138f59..eb98c7025f3 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -28,8 +28,9 @@ // 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 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 -// publications take. +// frontier N sees txNums < N — against the applied end. Publication disables +// state fills while its authoritative update batch is incomplete, without +// blocking cache reads or view binding. // // The Applier handle, held by the SharedDomains commit/unwind path, performs // the authoritative writes: post-commit publications, unwinds and clears. diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index e5f9d88b94b..6843aa4a436 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -21,6 +21,7 @@ import ( "encoding/binary" "sync" "testing" + "time" "github.com/c2h5oh/datasize" "github.com/stretchr/testify/assert" @@ -44,6 +45,26 @@ func makeAddr(i int) []byte { return addr } +type blockingPutCache struct { + started chan struct{} + release chan struct{} + filled chan struct{} + once sync.Once +} + +func (c *blockingPutCache) Get([]byte) ([]byte, bool) { return nil, false } +func (c *blockingPutCache) GetWithTxNum([]byte) ([]byte, uint64, bool) { return nil, 0, false } +func (c *blockingPutCache) Put([]byte, []byte, uint64) { + c.once.Do(func() { close(c.started) }) + <-c.release +} +func (c *blockingPutCache) PutIfAbsent([]byte, []byte, uint64) { c.filled <- struct{}{} } +func (c *blockingPutCache) Delete([]byte) {} +func (c *blockingPutCache) Clear() {} +func (c *blockingPutCache) Unwind(uint64) {} +func (c *blockingPutCache) Close() {} +func (c *blockingPutCache) Len() int { return 0 } + func makeHash(i int) common.Hash { var h common.Hash h[31] = byte(i) @@ -1031,6 +1052,69 @@ func TestStateCache_PublishRejectsOlderStateVersion(t *testing.T) { require.False(t, ok, "a rejected publication must not move fill admission backward") } +func TestStateCache_PublicationDoesNotBlockViewBinding(t *testing.T) { + cache := &blockingPutCache{ + started: make(chan struct{}), + release: make(chan struct{}), + filled: make(chan struct{}, 1), + } + sc := &StateCache{} + sc.caches[kv.AccountsDomain] = cache + sc.Applier().Initialize(1) + existingView := sc.View(frontierAtVersion(21, 1)) + + published := make(chan struct{}) + go func() { + sc.Applier().Publish(1, 2, []StateUpdate{{ + Domain: kv.AccountsDomain, + Key: makeAddr(1), + Value: makeValue(1), + TxNum: 20, + }}) + close(published) + }() + <-cache.started + publicationDone := false + defer func() { + if !publicationDone { + close(cache.release) + <-published + } + }() + + existingView.Fill(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) + select { + case <-cache.filled: + t.Fatal("cache fill was admitted during publication") + default: + } + + viewBound := make(chan ReadView, 1) + go func() { + viewBound <- sc.View(frontierAtVersion(21, 2)) + }() + var duringPublication ReadView + select { + case view := <-viewBound: + duringPublication = view + require.False(t, view.CanFill(), "a view bound during publication must not fill partial state") + case <-time.After(time.Second): + t.Fatal("cache publication blocked view binding") + } + + close(cache.release) + <-published + publicationDone = true + require.False(t, duringPublication.CanFill(), "an inert view must be rebound explicitly") + require.True(t, sc.View(frontierAtVersion(21, 2)).CanFill(), "the committed version must admit new views") + existingView.Fill(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) + select { + case <-cache.filled: + case <-time.After(time.Second): + t.Fatal("continuous publication did not restore fill admission") + } +} + func TestStateCache_PublishClearsOnSkippedStateVersion(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 05b63abe7aa..5042972d9f2 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -55,10 +55,13 @@ const ( // Code uses CodeCache (two-level for deduplication). type StateCache struct { caches [kv.DomainLen]Cache - // admissionMu serializes fill admission with canonical publication. - // stateVersion identifies the durable state allowed to fill; appliedEnd - // rejects older domain frontiers within that state version. + // applierMu serializes authoritative operations while a batch publication + // releases admissionMu around cache writes. + applierMu sync.Mutex + // admissionMu protects fill eligibility and publication identity. + // publishing disables admission-gated fills while an update is incomplete. admissionMu sync.RWMutex + publishing bool appliedEnd [kv.DomainLen]uint64 stateVersion uint64 stateVersionKnown bool @@ -224,7 +227,8 @@ func (c *StateCache) seedAddrCodeHash(addr []byte, h [32]byte, txNum, visibleEnd } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || + if c.publishing || + viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.AccountsDomain] { return } @@ -260,8 +264,7 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea if cache == nil { return } - // Clone outside the lock: a rejected fill wastes one copy (rare), but - // A publication's write lock never waits on a fill's memcpy. + // Clone outside the lock so admission never waits on the copy. cloned := bytes.Clone(value) if len(value) == 0 { readTxNum = 0 @@ -271,7 +274,8 @@ func (c *StateCache) fillIfFresh(domain kv.Domain, key []byte, value []byte, rea } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || + if c.publishing || + viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[domain] { return } @@ -293,7 +297,8 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if viewEpoch != c.readViewEpoch.Load() || + if c.publishing || + viewEpoch != c.readViewEpoch.Load() || visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { return @@ -335,12 +340,14 @@ func prepareStateUpdate(update StateUpdate) preparedStateUpdate { // apply makes a committed domain update authoritative for subsequent fills. func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { prepared := prepareStateUpdate(StateUpdate{Domain: domain, Key: key, Value: value, TxNum: txNum}) + c.applierMu.Lock() + defer c.applierMu.Unlock() c.admissionMu.Lock() defer c.admissionMu.Unlock() - c.applyLocked(prepared) + c.applyPrepared(prepared) } -func (c *StateCache) applyLocked(update preparedStateUpdate) { +func (c *StateCache) applyPrepared(update preparedStateUpdate) { cache := c.caches[update.domain] if cache == nil { return @@ -393,6 +400,8 @@ func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { // 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.applierMu.Lock() + defer c.applierMu.Unlock() c.admissionMu.Lock() defer c.admissionMu.Unlock() c.clearLocked() @@ -430,6 +439,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.applierMu.Lock() + defer c.applierMu.Unlock() c.admissionMu.Lock() defer c.admissionMu.Unlock() c.unwindLocked(unwindToTxNum) @@ -448,6 +459,8 @@ func (c *StateCache) unwindLocked(unwindToTxNum uint64) { } func (c *StateCache) initialize(stateVersion uint64) { + c.applierMu.Lock() + defer c.applierMu.Unlock() c.admissionMu.Lock() defer c.admissionMu.Unlock() if c.stateVersionKnown && stateVersion <= c.stateVersion { @@ -456,23 +469,17 @@ func (c *StateCache) initialize(stateVersion uint64) { c.resetForStateVersionLocked() c.stateVersion = stateVersion c.stateVersionKnown = true + c.publishing = false } -func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, - hasUnwind bool, updates []StateUpdate) { - if committedStateVersion <= sourceStateVersion { - return - } - prepared := make([]preparedStateUpdate, len(updates)) - for i := range updates { - prepared[i] = prepareStateUpdate(updates[i]) - } - +func (c *StateCache) beginPublication(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, + hasUnwind bool) bool { c.admissionMu.Lock() defer c.admissionMu.Unlock() if c.stateVersionKnown && committedStateVersion <= c.stateVersion { - return + return false } + c.publishing = true discontinuous := !c.stateVersionKnown || sourceStateVersion != c.stateVersion if discontinuous { // The cache missed part of the source state. Incremental updates cannot @@ -481,11 +488,41 @@ func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindTo } else if hasUnwind { c.unwindLocked(unwindToTxNum) } - for i := range prepared { - c.applyLocked(prepared[i]) - } + return true +} + +func (c *StateCache) finishPublication(committedStateVersion uint64) { + c.admissionMu.Lock() + defer c.admissionMu.Unlock() c.stateVersion = committedStateVersion c.stateVersionKnown = true + c.publishing = false +} + +func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, + hasUnwind bool, updates []StateUpdate) { + if committedStateVersion <= sourceStateVersion { + return + } + prepared := make([]preparedStateUpdate, len(updates)) + for i := range updates { + prepared[i] = prepareStateUpdate(updates[i]) + } + + c.applierMu.Lock() + defer c.applierMu.Unlock() + if !c.beginPublication(sourceStateVersion, committedStateVersion, unwindToTxNum, hasUnwind) { + return + } + + // Sub-caches synchronize their own reads and writes. While publishing is + // true, admission-gated fills cannot mutate state entries or read appliedEnd, + // so the serialized applier can install the batch without admissionMu. + for i := range prepared { + c.applyPrepared(prepared[i]) + } + + c.finishPublication(committedStateVersion) } // Caches reports whether the given domain has a cache attached. diff --git a/execution/cache/view.go b/execution/cache/view.go index 61a6d32aff6..d8b9a3e7839 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -84,6 +84,8 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // continuous forward publication keeps the view eligible, while the domain // frontier rejects values older than the latest update. A discontinuity also // advances the epoch and revokes every previously bound view. +// During publication, reads remain available but admission-gated view binding +// and fills are temporarily inert until the complete update batch is installed. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { c *StateCache @@ -93,7 +95,7 @@ type ReadView struct { // View creates a ReadView vouched for by f. If the cache has a durable state // version, f must report the same version when it is bound. A nil or rejected -// frontier disables admission-gated fills. +// frontier disables admission-gated fills, as does binding during publication. func (c *StateCache) View(f Frontier) ReadView { if c == nil { return ReadView{} @@ -111,8 +113,8 @@ func (c *StateCache) View(f Frontier) ReadView { } // WithFrontier binds f while preserving the original view's read-view epoch. -// Binding is serialized with publication so a transaction from an older -// durable state cannot gain fill authority after an unwind commits. +// Binding is serialized with publication boundaries so a transaction from an +// older durable state cannot gain fill authority after an unwind commits. func (v ReadView) WithFrontier(f Frontier) ReadView { if v.c == nil { return v @@ -128,7 +130,10 @@ func (v ReadView) WithFrontier(f Frontier) ReadView { } func (c *StateCache) eligibleFrontierLocked(frontier Frontier) Frontier { - if frontier == nil || !c.stateVersionKnown { + if frontier == nil || c.publishing { + return nil + } + if !c.stateVersionKnown { return frontier } versioned, ok := frontier.(stateVersionFrontier) @@ -268,9 +273,10 @@ func (a Applier) Initialize(stateVersion uint64) { } // Publish applies one successful commit and advances the cache from its source -// state version to the committed state version in one critical section. Source -// continuity lets unchanged entries survive even if one commit advances the -// durable counter more than once. +// state version to the committed state version. Admission-gated fills are +// disabled while the update batch is incomplete, but readers do not wait for +// the batch. Source continuity lets unchanged entries survive even if one +// commit advances the durable counter more than once. func (a Applier) Publish(sourceStateVersion, committedStateVersion uint64, updates []StateUpdate) { if a.c == nil { return From 3ef99ed21002d3fb4d9853a203b723d00f3e0aca Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:05:28 +0200 Subject: [PATCH 18/36] db/state/execctx: preserve cache unwind across merge --- db/state/execctx/domain_shared.go | 14 +++++++ db/state/execctx/statecache_readfill_test.go | 43 ++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index a5ee9dc269d..7dba6484402 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -462,6 +462,14 @@ func (sd *SharedDomains) Merge(ctx context.Context, sdTxNum uint64, other *Share if err := sd.mem.Merge(other.mem); err != nil { return err } + if other.cacheUnwindPending { + // A shared cache was invalidated when the child staged the unwind; + // otherwise invalidate the parent's cache before it serves merged state. + if sd.stateCache != other.stateCache { + sd.cacheApplier.Unwind(other.cacheUnwindTo) + } + sd.stageCacheUnwind(other.cacheUnwindTo) + } // Merge block-level metadata from other's overlay into ours by flushing // other's overlay writes directly into our overlay (which implements kv.RwTx). @@ -847,6 +855,12 @@ func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][ // they are not below the reorg window. Commit repeats the invalidation at the // durable state-version boundary, so no fill admitted while staged survives. sd.cacheApplier.Unwind(txNumUnwindTo) + sd.stageCacheUnwind(txNumUnwindTo) +} + +// stageCacheUnwind retains the lowest staged boundary because merged batches +// may contain cache entries from either discarded range. +func (sd *SharedDomains) stageCacheUnwind(txNumUnwindTo uint64) { if !sd.cacheUnwindPending || txNumUnwindTo < sd.cacheUnwindTo { sd.cacheUnwindTo = txNumUnwindTo } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index d8b41cc058d..9aafffd131d 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -168,6 +168,49 @@ func TestReadFill_RetriesStateVersionAfterConstructionError(t *testing.T) { require.True(t, ok, "the recovered state-version read must restore fill authority") } +func TestStateCache_MergedUnwindPublishesInvalidation(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + parent, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer parent.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + parent.SetStateCacheForTest(stateCache) + + key := make([]byte, 20) + key[0] = 0x01 + seed(t, stateCache, tx, kv.AccountsDomain, key, encAccount(1), 12) + _, ok := stateCache.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok) + var parentDiffs [kv.DomainLen][]kv.DomainEntryDiff + parent.Unwind(15, &parentDiffs) + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "an entry below the parent's unwind boundary must remain live") + + child, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + var childDiffs [kv.DomainLen][]kv.DomainEntryDiff + child.Unwind(10, &childDiffs) + require.NoError(t, parent.Merge(ctx, 0, child, 0)) + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the merged unwind must invalidate the cache before the parent serves reads") + + seed(t, stateCache, tx, kv.AccountsDomain, key, encAccount(1), 12) + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok, "a fill admitted after staging exercises commit-time invalidation") + require.NoError(t, parent.Commit(ctx, tx)) + + _, ok = stateCache.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the merged unwind must invalidate entries from the discarded range") +} + func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { t.Parallel() From 94cf65bf4b40b62c58800422e063b5393fb7abc9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:54:56 +0200 Subject: [PATCH 19/36] execution/cache: test stale fills through applier --- execution/cache/cache_test.go | 44 ++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 6843aa4a436..cf473c726e7 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1382,16 +1382,24 @@ func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { t.Cleanup(sc.Close) key := makeAddr(1) - oldView := sc.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true })) + applier := sc.Applier() + applier.Initialize(1) + oldView := sc.View(FrontierWithStateVersion( + FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true }), + 1, + )) - sc.apply(kv.AccountsDomain, key, nil, 20) // canonical delete - sc.Applier().Clear() + applier.Publish(1, 2, []StateUpdate{{Domain: kv.AccountsDomain, Key: key, TxNum: 20}}) + 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 := sc.View(FrontierWithStateVersion( + FrontierFunc(func(kv.Domain) (uint64, bool) { return 21, true }), + 2, + )) 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") @@ -1408,20 +1416,30 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { addr, code := makeAddr(1), makeCode(1) other, otherCode := makeAddr(2), makeCode(2) - c.apply(kv.CodeDomain, addr, code, 100) - c.apply(kv.AccountsDomain, addr, nil, 200) + applier := c.Applier() + applier.Initialize(1) + stale := c.View(FrontierWithStateVersion( + FrontierFunc(func(kv.Domain) (uint64, bool) { return 101, true }), + 1, + )) + applier.Publish(1, 2, []StateUpdate{ + {Domain: kv.CodeDomain, Key: addr, Value: code, TxNum: 100}, + {Domain: kv.AccountsDomain, Key: addr, TxNum: 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 := c.View(FrontierWithStateVersion( + FrontierFunc(func(d kv.Domain) (uint64, bool) { + if d == kv.AccountsDomain { + return 201, true + } + return 101, true + }), + 2, + )) 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") From 93f712be91df5bc1869d21e6efdcdd5d7793ebe7 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:03:24 +0200 Subject: [PATCH 20/36] execution/cache: clarify read view fill contract --- execution/cache/view.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/execution/cache/view.go b/execution/cache/view.go index d8b9a3e7839..dd6097bd11f 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -66,10 +66,9 @@ 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, and it must not outlive the transaction. A nil 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. +// disables Fill and SeedAddrCodeHash. FillCodeSize remains available because +// code size is content-addressed. 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 From 6a72e4c4c542a93d289cc6593200f180bca568d0 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:23:53 +0200 Subject: [PATCH 21/36] execution/cache: share state version advance check --- execution/cache/state_cache.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 5042972d9f2..caae4838c86 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -458,12 +458,16 @@ func (c *StateCache) unwindLocked(unwindToTxNum uint64) { } } +func (c *StateCache) canAdvanceStateVersionLocked(stateVersion uint64) bool { + return !c.stateVersionKnown || stateVersion > c.stateVersion +} + func (c *StateCache) initialize(stateVersion uint64) { c.applierMu.Lock() defer c.applierMu.Unlock() c.admissionMu.Lock() defer c.admissionMu.Unlock() - if c.stateVersionKnown && stateVersion <= c.stateVersion { + if !c.canAdvanceStateVersionLocked(stateVersion) { return } c.resetForStateVersionLocked() @@ -476,7 +480,7 @@ func (c *StateCache) beginPublication(sourceStateVersion, committedStateVersion, hasUnwind bool) bool { c.admissionMu.Lock() defer c.admissionMu.Unlock() - if c.stateVersionKnown && committedStateVersion <= c.stateVersion { + if !c.canAdvanceStateVersionLocked(committedStateVersion) { return false } c.publishing = true From 0b3c9f7c670af7f1b05252a136315db77e85a757 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:47:57 +0200 Subject: [PATCH 22/36] db/state/execctx: group staged cache unwind state --- db/state/execctx/domain_shared.go | 38 +++++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 7dba6484402..4d78685eeee 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -279,10 +279,9 @@ type SharedDomains struct { // stateCache is an optional cache for state data (accounts, storage, code); // cacheApplier is its authoritative writer handle (commit/unwind only). - stateCache *cache.StateCache - cacheApplier cache.Applier - cacheUnwindTo uint64 - cacheUnwindPending bool + stateCache *cache.StateCache + cacheApplier cache.Applier + cacheUnwind cacheUnwindState // Backing frontiers stay fixed while writes and staged unwinds remain in // mem; both reach the transaction during flush, which resets the memo. @@ -329,6 +328,15 @@ type SharedDomains struct { adaptivePinController *commitment.AdaptivePinController } +// cacheUnwindState records the lowest boundary that the next durable cache +// publication must invalidate. It is separate from mem-batch changesets +// because an unwind without changesets must still revoke cache entries; +// merging states keeps the lowest boundary to cover every discarded range. +type cacheUnwindState struct { + toTxNum uint64 + pending bool +} + // PickTrieVariant returns the commitment trie variant selected by the // process-wide statecfg experimental-commitment flags. Callers that // build a commitment.TrieConfig inline (e.g. short-lived RPC/builder/integrity @@ -462,13 +470,13 @@ func (sd *SharedDomains) Merge(ctx context.Context, sdTxNum uint64, other *Share if err := sd.mem.Merge(other.mem); err != nil { return err } - if other.cacheUnwindPending { + if other.cacheUnwind.pending { // A shared cache was invalidated when the child staged the unwind; // otherwise invalidate the parent's cache before it serves merged state. if sd.stateCache != other.stateCache { - sd.cacheApplier.Unwind(other.cacheUnwindTo) + sd.cacheApplier.Unwind(other.cacheUnwind.toTxNum) } - sd.stageCacheUnwind(other.cacheUnwindTo) + sd.stageCacheUnwind(other.cacheUnwind.toTxNum) } // Merge block-level metadata from other's overlay into ours by flushing @@ -858,13 +866,13 @@ func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][ sd.stageCacheUnwind(txNumUnwindTo) } -// stageCacheUnwind retains the lowest staged boundary because merged batches -// may contain cache entries from either discarded range. +// stageCacheUnwind retains the lowest boundary so every staged discarded +// range is covered by the next durable cache publication. func (sd *SharedDomains) stageCacheUnwind(txNumUnwindTo uint64) { - if !sd.cacheUnwindPending || txNumUnwindTo < sd.cacheUnwindTo { - sd.cacheUnwindTo = txNumUnwindTo + if !sd.cacheUnwind.pending || txNumUnwindTo < sd.cacheUnwind.toTxNum { + sd.cacheUnwind.toTxNum = txNumUnwindTo } - sd.cacheUnwindPending = true + sd.cacheUnwind.pending = true } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -1322,12 +1330,12 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun } } if sd.stateCache != nil { - if sd.cacheUnwindPending { - sd.cacheApplier.PublishUnwind(sourceStateVersion, committedStateVersion, sd.cacheUnwindTo, pendingState) + if sd.cacheUnwind.pending { + sd.cacheApplier.PublishUnwind(sourceStateVersion, committedStateVersion, sd.cacheUnwind.toTxNum, pendingState) } else { sd.cacheApplier.Publish(sourceStateVersion, committedStateVersion, pendingState) } - sd.cacheUnwindPending = false + sd.cacheUnwind = cacheUnwindState{} } return nil } From bb2c92e13f672dd4896af71bbf3ad899a7e51fe1 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:52:30 +0200 Subject: [PATCH 23/36] db/state/execctx: close state caches in tests --- db/state/execctx/statecache_readfill_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 9aafffd131d..0f89b2892b5 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -268,6 +268,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) key, v1, _, diffs := twoStepRows(t, db, sc) roTx, err := db.BeginTemporalRo(ctx) @@ -309,6 +310,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) key := make([]byte, 20) key[0] = 0xbb @@ -363,6 +365,7 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) key, _, v2, diffs := twoStepRows(t, db, sc) roTx, err := db.BeginTemporalRo(ctx) @@ -538,6 +541,7 @@ func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() + t.Cleanup(sc.Close) rwTx, err := db.BeginTemporalRw(ctx) require.NoError(t, err) From 7d1b7950d98e7f6f9b5cbde103727555ed67a45c Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:13:29 +0200 Subject: [PATCH 24/36] execution/cache: clarify read view publication docs --- execution/cache/view.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/execution/cache/view.go b/execution/cache/view.go index dd6097bd11f..905cb74beeb 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -83,8 +83,9 @@ func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return // continuous forward publication keeps the view eligible, while the domain // frontier rejects values older than the latest update. A discontinuity also // advances the epoch and revokes every previously bound view. -// During publication, reads remain available but admission-gated view binding -// and fills are temporarily inert until the complete update batch is installed. +// During publication, reads remain available. Existing eligible views cannot +// fill until the complete update batch is installed. A view bound during +// publication has no frontier and remains fill-inert until explicitly rebound. // Snapshot-isolated caching is kvcache's job (node/shards). type ReadView struct { c *StateCache From 64ab1f7ecc32ce81e0e230a4c352959d185240b4 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:36:07 +0200 Subject: [PATCH 25/36] db/state/execctx: require known base state version --- db/state/execctx/domain_shared.go | 38 ++++++++++---------- db/state/execctx/statecache_readfill_test.go | 22 ++++-------- 2 files changed, 25 insertions(+), 35 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 4d78685eeee..332dc2ff96e 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -199,8 +199,7 @@ func (sd *SharedDomains) cacheFrontierFor(tx kv.TemporalTx) cache.Frontier { _, txWritable := generationTx.(kv.TemporalRwTx) // A write transaction's ViewID is the snapshot ID it will create. After // commit, a new read transaction can have that ID but a newer state version. - useBaseStateVersion := sd.baseStateVersionKnown && - generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable + useBaseStateVersion := generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable if !useBaseStateVersion { var err error stateVersion, err = rawdb.GetStateVersion(generationTx) @@ -244,10 +243,9 @@ type SharedDomains struct { logger log.Logger - baseViewID uint64 - baseTxWritable bool - baseStateVersion uint64 - baseStateVersionKnown bool + baseViewID uint64 + baseTxWritable bool + baseStateVersion uint64 txNum uint64 currentStep kv.Step @@ -368,16 +366,21 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, trieCfg := o.trieCfg generationTx := cacheGenerationTx(tx) - stateVersion, stateVersionErr := rawdb.GetStateVersion(generationTx) + if generationTx == nil { + return nil, errors.New("state version transaction is nil") + } + stateVersion, err := rawdb.GetStateVersion(generationTx) + if err != nil { + return nil, fmt.Errorf("read base state version: %w", err) + } _, baseTxWritable := generationTx.(kv.TemporalRwTx) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: tx.Debug().StepSize(), - baseViewID: generationTx.ViewID(), - baseTxWritable: baseTxWritable, - baseStateVersion: stateVersion, - baseStateVersionKnown: stateVersionErr == nil, + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: tx.Debug().StepSize(), + baseViewID: generationTx.ViewID(), + baseTxWritable: baseTxWritable, + baseStateVersion: stateVersion, } if o.mem != nil { @@ -943,9 +946,7 @@ func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { func (sd *SharedDomains) bindStateCache(stateCache *cache.StateCache) { sd.stateCache = stateCache sd.cacheApplier = stateCache.Applier() - if sd.baseStateVersionKnown { - sd.cacheApplier.Initialize(sd.baseStateVersion) - } + sd.cacheApplier.Initialize(sd.baseStateVersion) } // GuardAggregatorForCache forbids visibility lowering on db's aggregator when @@ -1119,9 +1120,6 @@ type branchCacheUpdate struct { // ProjectedStateVersion returns the durable state version produced by the next // successful Commit. func (sd *SharedDomains) ProjectedStateVersion() (uint64, error) { - if !sd.baseStateVersionKnown { - return 0, errors.New("state version was unavailable when SharedDomains was created") - } if sd.baseStateVersion == math.MaxUint64 { return 0, errors.New("state version overflow") } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 0f89b2892b5..ef65e2125e9 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -141,7 +141,7 @@ func (tx *failStateVersionOnceRwTx) ReadSequence(table string) (uint64, error) { return tx.TemporalRwTx.ReadSequence(table) } -func TestReadFill_RetriesStateVersionAfterConstructionError(t *testing.T) { +func TestNewSharedDomains_StateVersionReadErrorFailsConstruction(t *testing.T) { t.Parallel() ctx := t.Context() @@ -152,20 +152,12 @@ func TestReadFill_RetriesStateVersionAfterConstructionError(t *testing.T) { tx := &failStateVersionOnceRwTx{TemporalRwTx: baseTx} domains, err := execctx.NewSharedDomains(ctx, tx, log.New()) - require.NoError(t, err) - defer domains.Close() - stateCache := newSmallStateCache() - t.Cleanup(stateCache.Close) - domains.SetStateCacheForTest(stateCache) - - missing := make([]byte, 20) - missing[0] = 0x01 - value, _, err := domains.AsGetter(tx).GetLatest(kv.AccountsDomain, missing) - require.NoError(t, err) - require.Empty(t, value) - require.Equal(t, 2, tx.stateVersionReads, "binding the getter must retry the failed construction-time read") - _, ok := stateCache.View(nil).Get(kv.AccountsDomain, missing) - require.True(t, ok, "the recovered state-version read must restore fill authority") + if domains != nil { + defer domains.Close() + } + require.ErrorContains(t, err, "read base state version") + require.Nil(t, domains) + require.Equal(t, 1, tx.stateVersionReads) } func TestStateCache_MergedUnwindPublishesInvalidation(t *testing.T) { From 2d1cf7e6f33a13b007a26391d7f0bf234b5c6d48 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Thu, 13 Aug 2026 12:51:14 +0700 Subject: [PATCH 26/36] execution/cache: benchmarks for publication under concurrent readers Two benchmarks for the contention this design introduces, since the existing ones cover single-threaded latency only. BenchmarkStateCachePublicationUnderLoad publishes batches of 1/1k/20k updates with 0/8/32 concurrent readers bound to current, stale, or mixed state versions. b.N counts publications; reader throughput and the share of offered fills that land are reported as custom metrics, so a publication that stalls readers shows as Mreads/s dropping rather than ns/op moving. BenchmarkPublishVsViewBindLock isolates admissionMu: readers do identical work and only the bind differs, since View(nil) returns without taking the read lock. Claude-Session: https://claude.ai/code/session_01UBdSF83umTVsvDDfkub2Va --- execution/cache/cache_test.go | 191 ++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index cf473c726e7..e9a1d610704 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -19,7 +19,9 @@ package cache import ( "bytes" "encoding/binary" + "fmt" "sync" + "sync/atomic" "testing" "time" @@ -1459,3 +1461,192 @@ func TestApplyOnlyCacheReportsFillsDisabled(t *testing.T) { t.Cleanup(c2.Close) require.True(t, c2.FillsEnabled()) } + +// BenchmarkStateCachePublicationUnderLoad measures what a commit costs the +// readers running beside it. b.N counts publications; the reported +// reads/s and fill-reject ratio come from reader goroutines that run for the +// whole timed region, so a publication that stalls readers shows up as reads/s +// collapsing rather than as ns/op moving. +// +// version=current models a reader bound to the state the cache just published. +// version=stale models an RPC transaction opened before the last commit: its +// frontier is rejected, so it can read but never fill, and every miss re-runs +// the eligibility resolution. +func BenchmarkStateCachePublicationUnderLoad(b *testing.B) { + const keySpace = 4096 + + mkKey := func(i int) []byte { + return []byte{byte(i), byte(i >> 8), 0x5A} + } + + for _, batch := range []int{1, 1000, 20000} { + for _, readers := range []int{0, 8, 32} { + for _, mix := range []string{"current", "stale", "half"} { + if readers == 0 && mix != "current" { + continue // reader mix is meaningless with no readers + } + b.Run(fmt.Sprintf("batch=%d/readers=%d/version=%s", batch, readers, mix), func(b *testing.B) { + c := NewStateCache(64<<20, 64<<20, 16<<20, 8<<20) + defer c.Close() + ap := c.Applier() + + var version atomic.Uint64 + version.Store(1) + ap.Initialize(1) + + // Seed so readers mostly hit. + seed := make([]StateUpdate, keySpace) + for i := range seed { + seed[i] = StateUpdate{Domain: kv.AccountsDomain, Key: mkKey(i), + Value: []byte{byte(i), 0xEE}, TxNum: uint64(i)} + } + ap.Publish(1, 2, seed) + version.Store(2) + + updates := make([]StateUpdate, batch) + for i := range updates { + updates[i] = StateUpdate{Domain: kv.AccountsDomain, Key: mkKey(i % keySpace), + Value: []byte{byte(i), 0xFF}, TxNum: uint64(i)} + } + + var reads, fillsOffered, fillsLanded atomic.Uint64 + stop := make(chan struct{}) + var wg sync.WaitGroup + + for r := range readers { + wg.Add(1) + go func(r int) { + defer wg.Done() + useStale := mix == "stale" || (mix == "half" && r%2 == 0) + n := uint64(r * 7919) + for { + select { + case <-stop: + return + default: + } + for range 64 { + n = n*1103515245 + 12345 + idx := int(n>>16) % keySpace + key := mkKey(idx) + + sv := version.Load() + if useStale { + sv = 1 // the version the cache has moved past + } + v := c.View(FrontierWithStateVersion( + FrontierFunc(func(kv.Domain) (uint64, bool) { return uint64(keySpace), true }), sv)) + + if _, ok := v.Get(kv.AccountsDomain, key); !ok { + fillsOffered.Add(1) + v.Fill(kv.AccountsDomain, key, []byte{byte(idx), 0xEE}, uint64(idx)) + if _, ok := c.View(nil).Get(kv.AccountsDomain, key); ok { + fillsLanded.Add(1) + } + } + reads.Add(1) + } + } + }(r) + } + + b.ResetTimer() + start := time.Now() + for i := 0; b.Loop(); i++ { + src := version.Load() + ap.Publish(src, src+1, updates) + version.Store(src + 1) + } + elapsed := time.Since(start) + b.StopTimer() + + close(stop) + wg.Wait() + + if readers > 0 { + b.ReportMetric(float64(reads.Load())/elapsed.Seconds()/1e6, "Mreads/s") + if off := fillsOffered.Load(); off > 0 { + b.ReportMetric(float64(fillsLanded.Load())/float64(off)*100, "%fills-landed") + } + } + b.ReportMetric(float64(batch), "updates/publish") + }) + } + } + } +} + +// BenchmarkPublishVsViewBindLock isolates what admissionMu costs a publication. +// Readers do identical work; only the bind differs. View(nil) returns without +// touching admissionMu, so the delta is the read-lock's contribution to both +// the publisher's cost and reader throughput. +func BenchmarkPublishVsViewBindLock(b *testing.B) { + const keySpace = 4096 + mkKey := func(i int) []byte { return []byte{byte(i), byte(i >> 8), 0x5A} } + + for _, bind := range []string{"frontier-RLock", "nil-nolock"} { + b.Run(bind, func(b *testing.B) { + c := NewStateCache(64<<20, 64<<20, 16<<20, 8<<20) + defer c.Close() + ap := c.Applier() + ap.Initialize(1) + + seed := make([]StateUpdate, keySpace) + for i := range seed { + seed[i] = StateUpdate{Domain: kv.AccountsDomain, Key: mkKey(i), Value: []byte{byte(i), 0xEE}, TxNum: uint64(i)} + } + ap.Publish(1, 2, seed) + var version atomic.Uint64 + version.Store(2) + + updates := make([]StateUpdate, 20000) + for i := range updates { + updates[i] = StateUpdate{Domain: kv.AccountsDomain, Key: mkKey(i % keySpace), Value: []byte{byte(i), 0xFF}, TxNum: uint64(i)} + } + + var reads atomic.Uint64 + stop := make(chan struct{}) + var wg sync.WaitGroup + for r := range 32 { + wg.Add(1) + go func(r int) { + defer wg.Done() + n := uint64(r * 7919) + for { + select { + case <-stop: + return + default: + } + for range 64 { + n = n*1103515245 + 12345 + key := mkKey(int(n>>16) % keySpace) + var v ReadView + if bind == "frontier-RLock" { + v = c.View(FrontierWithStateVersion( + FrontierFunc(func(kv.Domain) (uint64, bool) { return keySpace, true }), version.Load())) + } else { + v = c.View(nil) + } + v.Get(kv.AccountsDomain, key) + reads.Add(1) + } + } + }(r) + } + + b.ResetTimer() + start := time.Now() + for b.Loop() { + src := version.Load() + ap.Publish(src, src+1, updates) + version.Store(src + 1) + } + el := time.Since(start) + b.StopTimer() + close(stop) + wg.Wait() + b.ReportMetric(float64(reads.Load())/el.Seconds()/1e6, "Mreads/s") + }) + } +} From 452aab4700f81b6f3ce25e27d0d87320a03533fd Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:24:51 +0200 Subject: [PATCH 27/36] db/kv/membatchwithdb: use plain tx for overlay reads --- db/kv/membatchwithdb/memory_mutation.go | 44 ++++++++++++-------- db/kv/membatchwithdb/memory_mutation_test.go | 39 +++++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 82b381fe9ee..7d7d4e57dca 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -56,6 +56,7 @@ type MemoryMutation struct { deletedEntries map[string]map[string]struct{} deletedDups map[string]map[string]map[string]struct{} clearedTables map[string]struct{} + readTx kv.Tx db kv.TemporalTx statelessCursors map[string]kv.RwCursor DomainReader DomainReader @@ -76,6 +77,7 @@ func NewMemoryBatch(tx kv.TemporalTx, tmpDir string, logger log.Logger) (*Memory return &MemoryMutation{ mu: &sync.RWMutex{}, + readTx: tx, db: tx, memDb: memDB, memTx: mem, @@ -101,6 +103,7 @@ func NewMemoryBatchMDBX(tx kv.TemporalTx, tmpDir string, logger log.Logger) (mm } return &MemoryMutation{ mu: &sync.RWMutex{}, + readTx: tx, db: tx, memDb: tmpDB, memTx: memTx, @@ -130,6 +133,7 @@ func (m *MemoryMutation) Pin() kv.TemporalFilesPin { func (m *MemoryMutation) UpdateTxn(tx kv.TemporalTx) { m.mu.Lock() defer m.mu.Unlock() + m.readTx = tx m.db = tx m.statelessCursors = nil } @@ -142,6 +146,7 @@ func (m *MemoryMutation) DetachDB() kv.TemporalTx { m.mu.Lock() defer m.mu.Unlock() db := m.db + m.readTx = nil m.db = nil m.statelessCursors = nil return db @@ -212,10 +217,13 @@ func (m *MemoryMutation) readSequenceLocked(bucket string) (uint64, error) { } return binary.BigEndian.Uint64(value), nil } - if m.isTableCleared(kv.Sequence) || m.isEntryDeleted(kv.Sequence, key) || m.db == nil { + if m.isTableCleared(kv.Sequence) || m.isEntryDeleted(kv.Sequence, key) { return 0, nil } - return m.db.ReadSequence(bucket) + if m.readTx == nil { + return 0, nil + } + return m.readTx.ReadSequence(bucket) } func (m *MemoryMutation) ForAmount(bucket string, prefix []byte, amount uint32, walker func(k, v []byte) error) error { @@ -283,11 +291,11 @@ func (m *MemoryMutation) GetOne(table string, key []byte) ([]byte, error) { if v != nil { return v, nil } - // Fall back to underlying DB (nil when overlay is detached for publishing). - if m.db == nil { + // Fall back to the caller's transaction (nil on an unbound detached overlay). + if m.readTx == nil { return nil, nil } - return m.db.GetOne(table, key) + return m.readTx.GetOne(table, key) } func (m *MemoryMutation) Last(table string) ([]byte, []byte, error) { @@ -310,10 +318,10 @@ func (m *MemoryMutation) Has(table string, key []byte) (bool, error) { if err != nil || has { return has, err } - if m.db == nil { + if m.readTx == nil { return false, nil } - return m.db.Has(table, key) + return m.readTx.Has(table, key) } func (m *MemoryMutation) Put(table string, k, v []byte) error { @@ -380,8 +388,8 @@ func (m *MemoryMutation) StreamDescend(table string, fromPrefix, toPrefix []byte func (m *MemoryMutation) Range(table string, fromPrefix, toPrefix []byte, asc order.By, limit int) (stream.KV, error) { s := &rangeIter{orderAscend: bool(asc), limit: int64(limit)} var err error - if m.db != nil { - if s.iterDb, err = m.db.Range(table, fromPrefix, toPrefix, asc, limit); err != nil { + if m.readTx != nil { + if s.iterDb, err = m.readTx.Range(table, fromPrefix, toPrefix, asc, limit); err != nil { return s, err } } @@ -465,8 +473,8 @@ func (s *rangeIter) Next() (k, v []byte, err error) { func (m *MemoryMutation) RangeDupSort(table string, key []byte, fromPrefix, toPrefix []byte, asc order.By, limit int) (stream.KV, error) { s := &rangeDupSortIter{key: key, orderAscend: bool(asc), limit: int64(limit)} var err error - if m.db != nil { - if s.iterDb, err = m.db.RangeDupSort(table, key, fromPrefix, toPrefix, asc, limit); err != nil { + if m.readTx != nil { + if s.iterDb, err = m.readTx.RangeDupSort(table, key, fromPrefix, toPrefix, asc, limit); err != nil { return s, err } } @@ -885,8 +893,8 @@ func (m *MemoryMutation) makeCursor(bucket string) (kv.RwCursorDupSort, error) { c.table = bucket var err error - if m.db != nil { - c.cursor, err = m.db.CursorDupSort(bucket) //nolint:gocritic + if m.readTx != nil { + c.cursor, err = m.readTx.CursorDupSort(bucket) //nolint:gocritic if err != nil { return nil, err } @@ -928,7 +936,7 @@ func (m *MemoryMutation) ApplyRw(_ context.Context, f func(tx kv.RwTx) error) er } func (m *MemoryMutation) ViewID() uint64 { - return m.db.ViewID() + return m.readTx.ViewID() } func (m *MemoryMutation) CHandle() unsafe.Pointer { @@ -1069,10 +1077,9 @@ func (m *MemoryMutation) Unwind(ctx context.Context, txNumUnwindTo uint64, chang } // NewReadView creates a lightweight read-only view of this overlay backed by -// the given tx for fallback reads. The view shares the same in-memory data -// (memTx, deletedEntries, clearedTables) and the parent's mutex, but has its -// own db field set to the caller's tx. All existing cursor/read logic works -// naturally — memTx first, then db fallback. +// the given tx for fallback reads. The view shares the in-memory data and the +// parent's mutex, but uses the caller's tx for its own backing reads. Temporal +// methods also use it when it implements kv.TemporalTx. // // The returned kv.TemporalTx only exposes read methods. Callers cannot write // to the overlay through this view. The caller must not Close the returned @@ -1095,6 +1102,7 @@ func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation { deletedEntries: m.deletedEntries, deletedDups: m.deletedDups, clearedTables: m.clearedTables, + readTx: tx, db: dbTx, DomainReader: m.DomainReader, } diff --git a/db/kv/membatchwithdb/memory_mutation_test.go b/db/kv/membatchwithdb/memory_mutation_test.go index f7d1c85461b..2692f8f5505 100644 --- a/db/kv/membatchwithdb/memory_mutation_test.go +++ b/db/kv/membatchwithdb/memory_mutation_test.go @@ -561,6 +561,45 @@ func TestMemoryMutationUntouchedSequenceFollowsUpdatedTransaction(t *testing.T) require.Equal(t, wantVersion, gotVersion) } +type nonTemporalTx struct{ kv.Tx } + +func TestMemoryMutationReadViewUsesPlainTx(t *testing.T) { + _, rwTx := newTestTx(t) + require.NoError(t, rwTx.ResetSequence(kv.HeaderNumber, 7)) + require.NoError(t, rwTx.Put(kv.HeaderNumber, []byte("key"), []byte("value"))) + + batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + + view := batch.NewReadView(nonTemporalTx{Tx: rwTx}) + gotSequence, err := view.ReadSequence(kv.HeaderNumber) + require.NoError(t, err) + require.Equal(t, uint64(7), gotSequence) + gotValue, err := view.GetOne(kv.HeaderNumber, []byte("key")) + require.NoError(t, err) + require.Equal(t, []byte("value"), gotValue) +} + +func TestMemoryMutationDetachedReadViewUsesPlainTx(t *testing.T) { + _, rwTx := newTestTx(t) + require.NoError(t, rwTx.ResetSequence(kv.HeaderNumber, 7)) + require.NoError(t, rwTx.Put(kv.HeaderNumber, []byte("key"), []byte("value"))) + + batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + require.NotNil(t, batch.DetachDB()) + + view := batch.NewReadView(nonTemporalTx{Tx: rwTx}) + gotSequence, err := view.ReadSequence(kv.HeaderNumber) + require.NoError(t, err) + require.Equal(t, uint64(7), gotSequence) + gotValue, err := view.GetOne(kv.HeaderNumber, []byte("key")) + require.NoError(t, err) + require.Equal(t, []byte("value"), gotValue) +} + func TestMemoryMutationFlushDoesNotOverwriteUnchangedStateVersion(t *testing.T) { db, seedTx := newTestTx(t) ctx := t.Context() From 31050995f070cb076b48a3d766f35cd0b326425c Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:52:50 +0200 Subject: [PATCH 28/36] execution/execmodule: guard validation unwind from read-ahead --- execution/execmodule/exec_module.go | 56 ++++++++++--------- .../execmodule/exec_module_internal_test.go | 49 +++++++++++++++- execution/execmodule/fork_validator.go | 14 +++-- 3 files changed, 87 insertions(+), 32 deletions(-) diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index bfd3a356a70..c5df821db39 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -402,14 +402,14 @@ func (e *ExecModule) suspendReadAhead() func() { return e.readAheader.SuspendWarmup() } -// unwindToCommonCanonical leaves read-ahead suspended after an unwind. The -// caller resumes it after validation stops reading the staged unwind state. -func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) (func(), error) { +// unwindToCommonCanonical keeps read-ahead suspended after staging an unwind. +// Its caller must resume only after all reads of the staged state have ended. +func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, ensureReadAheadSuspended func()) error { currentHeader := header for { isCanonical, err := e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash()) if err != nil { - return nil, err + return err } if isCanonical { break @@ -417,10 +417,10 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te parentBlockHash, parentBlockNum := currentHeader.ParentHash, currentHeader.Number.Uint64()-1 currentHeader, err = e.getHeader(e.bacgroundCtx, tx, parentBlockHash, parentBlockNum) if err != nil { - return nil, err + return err } if currentHeader == nil { - return nil, makeErrMissingChainSegment(parentBlockHash) + return makeErrMissingChainSegment(parentBlockHash) } } // Check if you can skip unwind by comparing the current header number with the progress of all stages. @@ -429,31 +429,24 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te commonProgress, allEqual, err := stages.GetStageProgressIfAllEqual(tx, stages.Headers, stages.Senders, stages.Execution) if err != nil { - return nil, err + return err } if allEqual && commonProgress == unwindPoint { - return nil, nil + return nil } if err := e.hook.BeforeRun(tx, true); err != nil { - return nil, err + return err } - resumeReadAhead := e.suspendReadAhead() - handedOff := false - defer func() { - if !handedOff { - resumeReadAhead() - } - }() + ensureReadAheadSuspended() if err := e.pipelineExecutor.UnwindTo(unwindPoint, stagedsync.ExecUnwind, tx); err != nil { - return nil, err + return err } if err := e.pipelineExecutor.RunUnwind(sd, tx); err != nil { - return nil, err + return err } - handedOff = true - return resumeReadAhead, nil + return nil } const nextForkBanner = ` @@ -597,16 +590,27 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b // Set state cache in SharedDomains for use during state reading doms.SetStateCache(e.stateCache) doms.SetCodeStore(e.codeStore) - resumeReadAhead, err := e.unwindToCommonCanonical(doms, tx, header) - if err != nil { + // Either unwind path may run, and both can run in one validation. Share one + // lazy suspension so it spans every staged-state read without penalising the + // common case where validation needs no unwind. + var resumeReadAhead func() + ensureReadAheadSuspended := func() { + if resumeReadAhead == nil { + resumeReadAhead = e.suspendReadAhead() + } + } + defer func() { + if resumeReadAhead != nil { + resumeReadAhead() + } + }() + + if err := e.unwindToCommonCanonical(doms, tx, header, ensureReadAheadSuspended); err != nil { doms.Close() return ValidationResult{}, err } - if resumeReadAhead != nil { - defer resumeReadAhead() - } - status, lvh, validationError, criticalError := e.forkValidator.ValidatePayload(ctx, doms, tx, header, body.RawBody(), e.logger) + status, lvh, validationError, criticalError := e.forkValidator.ValidatePayload(ctx, doms, tx, header, body.RawBody(), ensureReadAheadSuspended, e.logger) if criticalError != nil { return ValidationResult{}, criticalError } diff --git a/execution/execmodule/exec_module_internal_test.go b/execution/execmodule/exec_module_internal_test.go index b3e489498cc..1432292c11d 100644 --- a/execution/execmodule/exec_module_internal_test.go +++ b/execution/execmodule/exec_module_internal_test.go @@ -27,6 +27,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/execution/types" @@ -49,6 +50,31 @@ func (emptyStageProgressTx) GetOne(string, []byte) ([]byte, error) { return nil, nil } +type sideForkReader struct { + dbservices.FullBlockReader + canonicalHash common.Hash + forkHeader *types.Header + forkBody *types.Body +} + +func (r sideForkReader) IsCanonical(_ context.Context, _ kv.Getter, hash common.Hash, _ uint64) (bool, error) { + return hash == r.canonicalHash, nil +} + +func (r sideForkReader) Header(_ context.Context, _ kv.Getter, hash common.Hash, _ uint64) (*types.Header, error) { + if hash == r.forkHeader.Hash() { + return r.forkHeader, nil + } + return nil, nil +} + +func (r sideForkReader) BodyWithTransactions(_ context.Context, _ kv.Getter, hash common.Hash, _ uint64) (*types.Body, error) { + if hash == r.forkHeader.Hash() { + return r.forkBody, nil + } + return nil, nil +} + // 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. @@ -77,7 +103,28 @@ func TestUnwindToCommonCanonicalReturnsCanonicalityError(t *testing.T) { } header := &types.Header{Number: *uint256.NewInt(0)} - _, err := e.unwindToCommonCanonical(nil, emptyStageProgressTx{}, header) + err := e.unwindToCommonCanonical(nil, emptyStageProgressTx{}, header, func() {}) require.ErrorIs(t, err, expectedErr) } + +func TestForkValidatorSuspendsReadAheadBeforeItsOwnUnwind(t *testing.T) { + canonicalHash := common.HexToHash("0x01") + forkHeader := &types.Header{ParentHash: canonicalHash, Number: *uint256.NewInt(2)} + payloadHeader := &types.Header{ParentHash: forkHeader.Hash(), Number: *uint256.NewInt(3)} + reader := sideForkReader{ + canonicalHash: canonicalHash, + forkHeader: forkHeader, + forkBody: &types.Body{}, + } + fv := newForkValidator(t.Context(), 10, &PipelineExecutor{}, reader, 16) + + // Stop at the suspension boundary; this test needs no execution pipeline to + // prove that read-ahead is suspended before the validator stages its unwind. + const stopAfterSuspension = "read-ahead suspended" + require.PanicsWithValue(t, stopAfterSuspension, func() { + fv.ValidatePayload(t.Context(), nil, nil, payloadHeader, &types.RawBody{}, func() { + panic(stopAfterSuspension) + }, log.New()) + }) +} diff --git a/execution/execmodule/fork_validator.go b/execution/execmodule/fork_validator.go index f43a4dccba4..c179ee0f429 100644 --- a/execution/execmodule/fork_validator.go +++ b/execution/execmodule/fork_validator.go @@ -158,11 +158,12 @@ type HasDiff interface { Diff() (*membatchwithdb.MemoryDiff, error) } -// ValidatePayload returns whether a payload is valid or invalid, or if cannot be determined, it will be accepted. -// if the payload extends the canonical chain, then we stack it in extendingFork without any unwind. -// if the payload is a fork then we unwind to the point where the fork meets the canonical chain, and there we check whether it is valid. -// if for any reason none of the actions above can be performed due to lack of information, we accept the payload and avoid validation. -func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, body *types.RawBody, logger log.Logger) (status engine_types.EngineStatus, latestValidHash common.Hash, validationError error, criticalError error) { +// ValidatePayload checks a payload against canonical state. It validates a +// fork after staging an unwind to the common canonical ancestor and accepts a +// payload when required chain data is unavailable. Before a fork unwind it +// invokes ensureReadAheadSuspended, which must idempotently acquire a +// caller-owned suspension lasting until validation stops reading staged state. +func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, body *types.RawBody, ensureReadAheadSuspended func(), logger log.Logger) (status engine_types.EngineStatus, latestValidHash common.Hash, validationError error, criticalError error) { fv.lock.Lock() defer fv.lock.Unlock() if fv.executor == nil { @@ -244,6 +245,9 @@ func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.Shared if unwindPoint == fv.currentHeight { unwindPoint = 0 } + if unwindPoint != 0 { + ensureReadAheadSuspended() + } if fv.sharedDom != nil { fv.sharedDom.Close() } From 4c3a6b31c80c751f78b9b795f48717ad4a695a60 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:09:29 +0200 Subject: [PATCH 29/36] execution/cache: preserve unwind on rejected publication --- execution/cache/cache_test.go | 27 +++++++++++++++++++++++++++ execution/cache/state_cache.go | 10 ++++++---- execution/cache/view.go | 3 ++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index e9a1d610704..3fad0d105cd 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1196,6 +1196,33 @@ func TestStateCache_PublishUnwindSerializesWithFill(t *testing.T) { } } +func TestStateCache_RejectedPublishUnwindStillInvalidates(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + deadForkValue := makeValue(1) + applier := sc.Applier() + applier.Initialize(10) + applier.Publish(10, 11, []StateUpdate{{Domain: kv.AccountsDomain, Key: key, Value: deadForkValue, TxNum: 100}}) + applier.Unwind(50) + _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok) + + inWindow := sc.View(frontierAtVersion(101, 11)) + inWindow.Fill(kv.AccountsDomain, key, deadForkValue, 100) + got, ok := sc.View(nil).Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, deadForkValue, got) + applier.Publish(11, 20, nil) + applier.PublishUnwind(11, 12, 50, nil) + + _, ok = sc.View(nil).Get(kv.AccountsDomain, key) + require.False(t, ok, "the durable unwind must invalidate fills even when its publication is older") + require.True(t, sc.View(frontierAtVersion(51, 20)).CanFill(), "the rejected publication must not move the cache generation backwards") +} + func TestStateCache_FileEndViewCannotFillAtAppliedTx(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 caae4838c86..914df52bf37 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -480,7 +480,12 @@ func (c *StateCache) beginPublication(sourceStateVersion, committedStateVersion, hasUnwind bool) bool { c.admissionMu.Lock() defer c.admissionMu.Unlock() - if !c.canAdvanceStateVersionLocked(committedStateVersion) { + if committedStateVersion <= sourceStateVersion || !c.canAdvanceStateVersionLocked(committedStateVersion) { + // PublishUnwind follows a durable commit, so its invalidation remains + // authoritative even when a newer cache generation rejects its updates. + if hasUnwind { + c.unwindLocked(unwindToTxNum) + } return false } c.publishing = true @@ -505,9 +510,6 @@ func (c *StateCache) finishPublication(committedStateVersion uint64) { func (c *StateCache) publish(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, hasUnwind bool, updates []StateUpdate) { - if committedStateVersion <= sourceStateVersion { - return - } prepared := make([]preparedStateUpdate, len(updates)) for i := range updates { prepared[i] = prepareStateUpdate(updates[i]) diff --git a/execution/cache/view.go b/execution/cache/view.go index 905cb74beeb..d05c793dc57 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -285,7 +285,8 @@ func (a Applier) Publish(sourceStateVersion, committedStateVersion uint64, updat } // PublishUnwind republishes an unwind at commit so fills admitted after the -// staged invalidation cannot survive into the committed state version. +// staged invalidation cannot survive into the committed state version. An +// older rejected publication still invalidates without moving the version. func (a Applier) PublishUnwind(sourceStateVersion, committedStateVersion, unwindToTxNum uint64, updates []StateUpdate) { if a.c == nil { return From 7386067f4f93774ed61f4c24f49583e8b344ec36 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:05:30 +0200 Subject: [PATCH 30/36] execution/cache, db/state/execctx: avoid retrying stale cache bindings --- db/state/execctx/domain_shared.go | 13 ++-- db/state/execctx/statecache_readfill_test.go | 64 ++++++++++++++++++++ execution/cache/cache_test.go | 26 +++++++- execution/cache/view.go | 46 ++++++++++---- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 85f2b8926ad..3f54ce9262d 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1493,10 +1493,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k if maxStep == kv.NoStepBound && sd.stateCache != nil && sd.stateCache.Caches(domain) { readTxNum := (uint64(step)+1)*sd.StepSize() - 1 fillView := view - if !fillView.CanFill() { - // 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. + if fillView.NeedsFrontier() { + // Frontier-less views retry on the miss path, where binding cost is + // amortized by the backing read. Stale views do not request a retry. fillView = fillView.WithFrontier(sd.cacheFrontierFor(tx)) } fillView.Fill(domain, k, v, readTxNum) @@ -1711,9 +1710,9 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, // bound (>= the resolved account's write txNum), so the mapping drops // on any unwind that reverts that account. 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. + if seedView.NeedsFrontier() { + // Resolve fill authority only after the account lookup has missed the + // cache. A stale view is terminal and skips this retry. seedView = seedView.WithFrontier(sd.cacheFrontierFor(tx)) } seedView.SeedAddrCodeHash(addr, fixed, txNum) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index ef65e2125e9..f5e84f8ca21 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -141,6 +141,18 @@ func (tx *failStateVersionOnceRwTx) ReadSequence(table string) (uint64, error) { return tx.TemporalRwTx.ReadSequence(table) } +type stateVersionCountingTx struct { + kv.TemporalTx + stateVersionReads int +} + +func (tx *stateVersionCountingTx) ReadSequence(table string) (uint64, error) { + if table == string(kv.PlainStateVersion) { + tx.stateVersionReads++ + } + return tx.TemporalTx.ReadSequence(table) +} + func TestNewSharedDomains_StateVersionReadErrorFailsConstruction(t *testing.T) { t.Parallel() @@ -160,6 +172,58 @@ func TestNewSharedDomains_StateVersionReadErrorFailsConstruction(t *testing.T) { require.Equal(t, 1, tx.stateVersionReads) } +func TestStaleGetterResolvesCacheStateVersionOnce(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + staleTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer staleTx.Rollback() + + advanceTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer advanceTx.Rollback() + advanceDomains, err := execctx.NewSharedDomains(ctx, advanceTx, log.New()) + require.NoError(t, err) + require.NoError(t, advanceDomains.Commit(ctx, advanceTx)) + advanceDomains.Close() + + currentTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer currentTx.Rollback() + currentDomains, err := execctx.NewSharedDomains(ctx, currentTx, log.New()) + require.NoError(t, err) + defer currentDomains.Close() + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + currentDomains.SetStateCacheForTest(stateCache) + + countingTx := &stateVersionCountingTx{TemporalTx: staleTx} + getter := currentDomains.AsGetter(countingTx) + require.Equal(t, 1, countingTx.stateVersionReads, "getter construction resolves its transaction version") + + for i := byte(1); i <= 3; i++ { + missing := make([]byte, 20) + missing[0] = i + value, _, err := getter.GetLatest(kv.AccountsDomain, missing) + require.NoError(t, err) + require.Empty(t, value) + } + + require.Equal(t, 1, countingTx.stateVersionReads, + "a transaction older than the cache cannot become eligible, so misses must not retry its binding") +} + func TestStateCache_MergedUnwindPublishesInvalidation(t *testing.T) { t.Parallel() diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 3fad0d105cd..07ec5c8d1d3 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -1100,6 +1100,7 @@ func TestStateCache_PublicationDoesNotBlockViewBinding(t *testing.T) { case view := <-viewBound: duringPublication = view require.False(t, view.CanFill(), "a view bound during publication must not fill partial state") + require.True(t, view.NeedsFrontier(), "publication is temporary, so the view may retry binding afterward") case <-time.After(time.Second): t.Fatal("cache publication blocked view binding") } @@ -1108,6 +1109,8 @@ func TestStateCache_PublicationDoesNotBlockViewBinding(t *testing.T) { <-published publicationDone = true require.False(t, duringPublication.CanFill(), "an inert view must be rebound explicitly") + duringPublication = duringPublication.WithFrontier(frontierAtVersion(21, 2)) + require.True(t, duringPublication.CanFill(), "an explicitly rebound view may fill after publication") require.True(t, sc.View(frontierAtVersion(21, 2)).CanFill(), "the committed version must admit new views") existingView.Fill(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) select { @@ -1117,6 +1120,23 @@ func TestStateCache_PublicationDoesNotBlockViewBinding(t *testing.T) { } } +func TestStateCache_OnlyRetryPotentiallyEligibleFrontier(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + sc.Applier().Initialize(2) + + require.True(t, sc.View(nil).NeedsFrontier(), "an unbound view may acquire a frontier later") + stale := sc.View(frontierAtVersion(21, 1)) + require.False(t, stale.CanFill(), "a stale transaction must remain fill-inert") + require.False(t, stale.NeedsFrontier(), + "a stale transaction cannot become current as state versions advance") + require.False(t, sc.View(frontierAtVersion(21, 2)).NeedsFrontier(), + "an accepted frontier needs no retry") + require.True(t, sc.View(frontierAtVersion(21, 3)).NeedsFrontier(), + "a transaction ahead of the cache may become eligible when publication catches up") +} + func TestStateCache_PublishClearsOnSkippedStateVersion(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) @@ -1496,9 +1516,9 @@ func TestApplyOnlyCacheReportsFillsDisabled(t *testing.T) { // collapsing rather than as ns/op moving. // // version=current models a reader bound to the state the cache just published. -// version=stale models an RPC transaction opened before the last commit: its -// frontier is rejected, so it can read but never fill, and every miss re-runs -// the eligibility resolution. +// version=stale repeatedly constructs views for a transaction opened before +// the last commit. Production getters retain this rejection; constructing each +// view here deliberately measures the worst-case binding contention. func BenchmarkStateCachePublicationUnderLoad(b *testing.B) { const keySpace = 4096 diff --git a/execution/cache/view.go b/execution/cache/view.go index d05c793dc57..027c7010aa3 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -63,12 +63,18 @@ type FrontierFunc func(domain kv.Domain) (visibleEnd uint64, ok bool) func (f FrontierFunc) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return f(domain) } +// rejectedFrontier distinguishes a non-retryable rejection from a nil, retryable +// binding without growing ReadView, which is embedded in every state getter. +type rejectedFrontier struct{} + +func (rejectedFrontier) DomainVisibleEnd(kv.Domain) (uint64, bool) { return 0, false } + // 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, and it must not outlive the transaction. A nil frontier -// disables Fill and SeedAddrCodeHash. FillCodeSize remains available because -// code size is content-addressed. The zero value is inert: reads miss, fills -// no-op. +// view's frontier, and it must not outlive the transaction. Without an accepted +// frontier, Fill and SeedAddrCodeHash are no-ops. FillCodeSize remains available +// because code size is content-addressed. 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 @@ -94,8 +100,9 @@ type ReadView struct { } // View creates a ReadView vouched for by f. If the cache has a durable state -// version, f must report the same version when it is bound. A nil or rejected -// frontier disables admission-gated fills, as does binding during publication. +// version, f must report the same version when it is bound. A stale or +// versionless frontier is not retried automatically. A nil frontier, +// publication in progress, or a cache behind f may be retried later. func (c *StateCache) View(f Frontier) ReadView { if c == nil { return ReadView{} @@ -107,7 +114,7 @@ func (c *StateCache) View(f Frontier) ReadView { defer c.admissionMu.RUnlock() return ReadView{ c: c, - frontier: c.eligibleFrontierLocked(f), + frontier: c.bindFrontierLocked(f), readViewEpoch: c.readViewEpoch.Load(), } } @@ -125,11 +132,11 @@ func (v ReadView) WithFrontier(f Frontier) ReadView { } v.c.admissionMu.RLock() defer v.c.admissionMu.RUnlock() - v.frontier = v.c.eligibleFrontierLocked(f) + v.frontier = v.c.bindFrontierLocked(f) return v } -func (c *StateCache) eligibleFrontierLocked(frontier Frontier) Frontier { +func (c *StateCache) bindFrontierLocked(frontier Frontier) Frontier { if frontier == nil || c.publishing { return nil } @@ -138,9 +145,13 @@ func (c *StateCache) eligibleFrontierLocked(frontier Frontier) Frontier { } versioned, ok := frontier.(stateVersionFrontier) if !ok { - return nil + return rejectedFrontier{} + } + stateVersion := versioned.StateVersion() + if stateVersion < c.stateVersion { + return rejectedFrontier{} } - if versioned.StateVersion() != c.stateVersion { + if stateVersion > c.stateVersion { return nil } return frontier @@ -191,9 +202,18 @@ 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 +// CanFill reports whether this view carries an accepted frontier, i.e. Fill and // SeedAddrCodeHash can admit values through it. -func (v ReadView) CanFill() bool { return v.c != nil && v.frontier != nil } +func (v ReadView) CanFill() bool { + if v.c == nil || v.frontier == nil { + return false + } + _, rejected := v.frontier.(rejectedFrontier) + return !rejected +} + +// NeedsFrontier reports whether rebinding could make this view fill-eligible. +func (v ReadView) NeedsFrontier() 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; From eb41519663462b0d2cedb3b9e04ae4e8c3f262e1 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:41:19 +0200 Subject: [PATCH 31/36] execution: make read-ahead suspension cancellable --- execution/exec/blocks_read_ahead.go | 73 ++++++------- execution/exec/blocks_read_ahead_test.go | 102 +++++++++++++----- execution/execmodule/exec_module.go | 26 +++-- .../execmodule/exec_module_internal_test.go | 15 ++- execution/execmodule/fork_validator.go | 9 +- execution/execmodule/forkchoice.go | 5 +- execution/execmodule/set_head.go | 5 +- 7 files changed, 148 insertions(+), 87 deletions(-) diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 1c14463c18d..e38558056fc 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -4,11 +4,11 @@ import ( "bytes" "context" "sync" - "sync/atomic" "time" lru "github.com/hashicorp/golang-lru/v2" "golang.org/x/sync/errgroup" + "golang.org/x/sync/semaphore" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" @@ -32,10 +32,10 @@ type BlockReadAheader struct { senders *lru.Cache[common.Hash, []byte] // just do raw senders bals *lru.Cache[common.Hash, []byte] - // this is for warming state - warming atomic.Bool // only one warmBody can run at a time - warmWg sync.WaitGroup - warmupGate sync.RWMutex + // The single permit belongs either to one warmup or to the code suspending + // warmup across an unwind. Warmups never wait for it: read-ahead is + // best-effort, and queued work would be stale by the time an unwind ends. + warmupGate *semaphore.Weighted // stateCache is the process-global state cache that SharedDomains.GetLatest // consults on the EVM hot path. When set, warmBody routes its prefetches @@ -64,10 +64,11 @@ func NewBlockReadAheader() *BlockReadAheader { panic(err) } return &BlockReadAheader{ - headers: headers, - bodies: bodies, - senders: senders, - bals: bals, + headers: headers, + bodies: bodies, + senders: senders, + bals: bals, + warmupGate: semaphore.NewWeighted(1), } } @@ -120,45 +121,40 @@ func (bra *BlockReadAheader) AddHeaderAndBody(ctx context.Context, db kv.RoDB, h bra.headers.Add(blockHash, header) bra.bodies.Add(blockHash, body) if db != nil && ctx != nil { - // Only allow one warmBody to run at a time - if !bra.warming.CompareAndSwap(false, true) { - return - } - bra.warmWg.Go(func() { - defer bra.warming.Store(false) - bra.withWarmupPermit(func() { - bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming - }) + bra.startWarmup(func() { + bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming }) } } -func (bra *BlockReadAheader) withWarmupPermit(warm func()) { - bra.warmupGate.RLock() - defer bra.warmupGate.RUnlock() - warm() +func (bra *BlockReadAheader) startWarmup(warm func()) bool { + if !bra.warmupGate.TryAcquire(1) { + return false + } + go func() { + defer bra.warmupGate.Release(1) + warm() + }() + return true } // SuspendWarmup waits for active state-cache warmup and prevents another // warmup from starting until the returned function is called. Keep it -// suspended while staged unwind state is being read or published. -func (bra *BlockReadAheader) SuspendWarmup() func() { - bra.warmupGate.Lock() - return bra.warmupGate.Unlock +// suspended while staged unwind state is being read or published. If ctx is +// cancelled first, no suspension remains pending. +func (bra *BlockReadAheader) SuspendWarmup(ctx context.Context) (func(), error) { + if err := bra.warmupGate.Acquire(ctx, 1); err != nil { + return nil, err + } + return sync.OnceFunc(func() { bra.warmupGate.Release(1) }), nil } -// WaitForWarmup blocks until any in-flight warmBody goroutine finishes or -// the context is cancelled. Call before closing the database to avoid -// waitTxsAllDoneOnClose hangs. +// WaitForWarmup waits until neither a warmup nor a suspension owns the permit, +// or until the context is cancelled. Call it before closing the database to +// avoid waitTxsAllDoneOnClose hangs. func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) { - done := make(chan struct{}) - go func() { - bra.warmWg.Wait() - close(done) - }() - select { - case <-done: - case <-ctx.Done(): + if err := bra.warmupGate.Acquire(ctx, 1); err == nil { + bra.warmupGate.Release(1) } } @@ -179,7 +175,8 @@ func (bra *BlockReadAheader) AddBlockAccessList(blockHash common.Hash, bal []byt // warmBody warms state for all transactions in a body using multiple workers. // 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. +// AddHeaderAndBody permits only one warmBody at a time; concurrent requests +// skip warming. func (bra *BlockReadAheader) warmBody(ctx context.Context, db kv.RoDB, header *types.Header, body *types.Body, workers int) { if !dbg.ReadAhead { return diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 32a104faa3e..9a2f5dccd82 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -74,25 +74,29 @@ func TestBlockReadAheaderSuspendWarmupWaitsForActiveWarmup(t *testing.T) { warmupStarted := make(chan struct{}) finishWarmup := make(chan struct{}) warmupDone := make(chan struct{}) - go func() { - bra.withWarmupPermit(func() { - close(warmupStarted) - <-finishWarmup - }) + require.True(t, bra.startWarmup(func() { + close(warmupStarted) + <-finishWarmup close(warmupDone) - }() + })) <-warmupStarted suspendStarted := make(chan struct{}) - resumeReadAhead := make(chan func()) + type suspendResult struct { + resume func() + err error + } + suspended := make(chan suspendResult) go func() { close(suspendStarted) - resumeReadAhead <- bra.SuspendWarmup() + resume, err := bra.SuspendWarmup(t.Context()) + suspended <- suspendResult{resume: resume, err: err} }() <-suspendStarted select { - case resume := <-resumeReadAhead: - resume() + case result := <-suspended: + require.NoError(t, result.err) + result.resume() close(finishWarmup) <-warmupDone t.Fatal("SuspendWarmup returned while a warmup was active") @@ -100,35 +104,81 @@ func TestBlockReadAheaderSuspendWarmupWaitsForActiveWarmup(t *testing.T) { } close(finishWarmup) - resume := <-resumeReadAhead - resume() + result := <-suspended + require.NoError(t, result.err) + result.resume() <-warmupDone } -func TestBlockReadAheaderSuspendWarmupBlocksNewWarmup(t *testing.T) { +func TestBlockReadAheaderSuspendWarmupSkipsNewWarmup(t *testing.T) { bra := NewBlockReadAheader() - resume := bra.SuspendWarmup() + resume, err := bra.SuspendWarmup(t.Context()) + require.NoError(t, err) warmupStarted := make(chan struct{}) - warmupAttempted := make(chan struct{}) - go func() { - close(warmupAttempted) - bra.withWarmupPermit(func() { close(warmupStarted) }) - }() - <-warmupAttempted + require.False(t, bra.startWarmup(func() { close(warmupStarted) }), + "warmup must be skipped rather than queued behind the suspension") + + resume() select { case <-warmupStarted: - resume() - t.Fatal("warmup started while suspended") - case <-time.After(50 * time.Millisecond): + t.Fatal("a skipped warmup started after suspension ended") + default: } - resume() + nextWarmupDone := make(chan struct{}) + require.True(t, bra.startWarmup(func() { close(nextWarmupDone) })) select { - case <-warmupStarted: + case <-nextWarmupDone: case <-time.After(time.Second): - t.Fatal("warmup did not start after suspension ended") + t.Fatal("a new warmup did not start after suspension ended") } + bra.WaitForWarmup(t.Context()) +} + +func TestBlockReadAheaderSuspendWarmupHonorsContext(t *testing.T) { + bra := NewBlockReadAheader() + warmupStarted := make(chan struct{}) + finishWarmup := make(chan struct{}) + warmupDone := make(chan struct{}) + require.True(t, bra.startWarmup(func() { + close(warmupStarted) + <-finishWarmup + close(warmupDone) + })) + <-warmupStarted + + ctx, cancel := context.WithCancel(t.Context()) + suspendStarted := make(chan struct{}) + suspendResult := make(chan error) + go func() { + close(suspendStarted) + resume, err := bra.SuspendWarmup(ctx) + if resume != nil { + resume() + } + suspendResult <- err + }() + <-suspendStarted + cancel() + + select { + case err := <-suspendResult: + require.ErrorIs(t, err, context.Canceled) + case <-time.After(time.Second): + close(finishWarmup) + <-warmupDone + <-suspendResult + t.Fatal("SuspendWarmup did not return when its context was cancelled") + } + + close(finishWarmup) + <-warmupDone + bra.WaitForWarmup(t.Context()) + nextWarmupDone := make(chan struct{}) + require.True(t, bra.startWarmup(func() { close(nextWarmupDone) }), + "a cancelled suspension must not retain the warmup permit") + <-nextWarmupDone } // seedFill places an entry with an exact txNum stamp through the public fill diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index c5df821db39..9fe227cbf38 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -394,17 +394,18 @@ func (e *ExecModule) canonicalHash(ctx context.Context, tx kv.Tx, blockNumber ui } // suspendReadAhead prevents raw-database warmup from filling the shared state -// cache while an unwind's staged state is being read or published. -func (e *ExecModule) suspendReadAhead() func() { +// cache while an unwind's staged state is being read or published. It returns +// the context error rather than allowing the unwind to proceed unsuspended. +func (e *ExecModule) suspendReadAhead(ctx context.Context) (func(), error) { if e.readAheader == nil { - return func() {} + return func() {}, nil } - return e.readAheader.SuspendWarmup() + return e.readAheader.SuspendWarmup(ctx) } // unwindToCommonCanonical keeps read-ahead suspended after staging an unwind. // Its caller must resume only after all reads of the staged state have ended. -func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, ensureReadAheadSuspended func()) error { +func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, ensureReadAheadSuspended func() error) error { currentHeader := header for { isCanonical, err := e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash()) @@ -439,7 +440,9 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te return err } - ensureReadAheadSuspended() + if err := ensureReadAheadSuspended(); err != nil { + return fmt.Errorf("suspend read-ahead: %w", err) + } if err := e.pipelineExecutor.UnwindTo(unwindPoint, stagedsync.ExecUnwind, tx); err != nil { return err } @@ -594,10 +597,13 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b // lazy suspension so it spans every staged-state read without penalising the // common case where validation needs no unwind. var resumeReadAhead func() - ensureReadAheadSuspended := func() { - if resumeReadAhead == nil { - resumeReadAhead = e.suspendReadAhead() - } + var suspendReadAheadErr error + var suspendReadAheadOnce sync.Once + ensureReadAheadSuspended := func() error { + suspendReadAheadOnce.Do(func() { + resumeReadAhead, suspendReadAheadErr = e.suspendReadAhead(ctx) + }) + return suspendReadAheadErr } defer func() { if resumeReadAhead != nil { diff --git a/execution/execmodule/exec_module_internal_test.go b/execution/execmodule/exec_module_internal_test.go index 1432292c11d..ce50ac66813 100644 --- a/execution/execmodule/exec_module_internal_test.go +++ b/execution/execmodule/exec_module_internal_test.go @@ -103,7 +103,7 @@ func TestUnwindToCommonCanonicalReturnsCanonicalityError(t *testing.T) { } header := &types.Header{Number: *uint256.NewInt(0)} - err := e.unwindToCommonCanonical(nil, emptyStageProgressTx{}, header, func() {}) + err := e.unwindToCommonCanonical(nil, emptyStageProgressTx{}, header, func() error { return nil }) require.ErrorIs(t, err, expectedErr) } @@ -120,11 +120,10 @@ func TestForkValidatorSuspendsReadAheadBeforeItsOwnUnwind(t *testing.T) { fv := newForkValidator(t.Context(), 10, &PipelineExecutor{}, reader, 16) // Stop at the suspension boundary; this test needs no execution pipeline to - // prove that read-ahead is suspended before the validator stages its unwind. - const stopAfterSuspension = "read-ahead suspended" - require.PanicsWithValue(t, stopAfterSuspension, func() { - fv.ValidatePayload(t.Context(), nil, nil, payloadHeader, &types.RawBody{}, func() { - panic(stopAfterSuspension) - }, log.New()) - }) + // prove that suspension failure aborts before the validator stages its unwind. + suspendErr := errors.New("read-ahead suspension cancelled") + _, _, _, criticalErr := fv.ValidatePayload(t.Context(), nil, nil, payloadHeader, &types.RawBody{}, func() error { + return suspendErr + }, log.New()) + require.ErrorIs(t, criticalErr, suspendErr) } diff --git a/execution/execmodule/fork_validator.go b/execution/execmodule/fork_validator.go index c179ee0f429..1d6761bbd43 100644 --- a/execution/execmodule/fork_validator.go +++ b/execution/execmodule/fork_validator.go @@ -162,8 +162,9 @@ type HasDiff interface { // fork after staging an unwind to the common canonical ancestor and accepts a // payload when required chain data is unavailable. Before a fork unwind it // invokes ensureReadAheadSuspended, which must idempotently acquire a -// caller-owned suspension lasting until validation stops reading staged state. -func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, body *types.RawBody, ensureReadAheadSuspended func(), logger log.Logger) (status engine_types.EngineStatus, latestValidHash common.Hash, validationError error, criticalError error) { +// caller-owned suspension lasting until validation stops reading staged state; +// an acquisition error aborts validation before the unwind. +func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header, body *types.RawBody, ensureReadAheadSuspended func() error, logger log.Logger) (status engine_types.EngineStatus, latestValidHash common.Hash, validationError error, criticalError error) { fv.lock.Lock() defer fv.lock.Unlock() if fv.executor == nil { @@ -246,7 +247,9 @@ func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.Shared unwindPoint = 0 } if unwindPoint != 0 { - ensureReadAheadSuspended() + if criticalError = ensureReadAheadSuspended(); criticalError != nil { + return + } } if fv.sharedDom != nil { fv.sharedDom.Close() diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index de2b388d9bb..cc1703ca175 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -360,7 +360,10 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa }) defer cleanupBeforeSemaRelease() - resumeReadAhead := e.suspendReadAhead() + resumeReadAhead, err := e.suspendReadAhead(ctx) + if err != nil { + return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, fmt.Errorf("suspend read-ahead: %w", err), false) + } defer resumeReadAhead() var validationError string diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 38dab815afe..9f88c56c9f7 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -58,7 +58,10 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { } defer e.semaphore.Release(1) - resumeReadAhead := e.suspendReadAhead() + resumeReadAhead, err := e.suspendReadAhead(ctx) + if err != nil { + return fmt.Errorf("suspend read-ahead: %w", err) + } defer resumeReadAhead() tx, err := e.db.BeginTemporalRw(ctx) From ba03dd895b8679475112ff31b66400e74c5c2abc Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:17:53 +0200 Subject: [PATCH 32/36] db: harden detached sequence access --- db/kv/membatchwithdb/memory_mutation.go | 4 +++- db/kv/membatchwithdb/memory_mutation_test.go | 24 ++++++++++++++++++++ db/state/execctx/domain_shared.go | 6 +++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 7d7d4e57dca..84aa478a1db 100644 --- a/db/kv/membatchwithdb/memory_mutation.go +++ b/db/kv/membatchwithdb/memory_mutation.go @@ -142,6 +142,8 @@ func (m *MemoryMutation) UpdateTxn(tx kv.TemporalTx) { // is a pure in-memory structure with no external resources — Close/Rollback // only frees the in-memory memDb. This makes the overlay safe to publish via // Events for concurrent RPC reads (consumers create ReadViews with their own tx). +// Untouched sequences also require a read view because the overlay stores only +// explicit sequence changes. func (m *MemoryMutation) DetachDB() kv.TemporalTx { m.mu.Lock() defer m.mu.Unlock() @@ -221,7 +223,7 @@ func (m *MemoryMutation) readSequenceLocked(bucket string) (uint64, error) { return 0, nil } if m.readTx == nil { - return 0, nil + return 0, fmt.Errorf("read sequence %q: no backing transaction is attached", bucket) } return m.readTx.ReadSequence(bucket) } diff --git a/db/kv/membatchwithdb/memory_mutation_test.go b/db/kv/membatchwithdb/memory_mutation_test.go index 2692f8f5505..952d448f388 100644 --- a/db/kv/membatchwithdb/memory_mutation_test.go +++ b/db/kv/membatchwithdb/memory_mutation_test.go @@ -600,6 +600,30 @@ func TestMemoryMutationDetachedReadViewUsesPlainTx(t *testing.T) { require.Equal(t, []byte("value"), gotValue) } +func TestMemoryMutationDetachedSequenceAccessRequiresReadView(t *testing.T) { + _, rwTx := newTestTx(t) + require.NoError(t, rwTx.ResetSequence(kv.HeaderNumber, 7)) + + batch, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root()) + require.NoError(t, err) + defer batch.Close() + require.NoError(t, batch.ResetSequence(kv.EthTx, 9)) + require.NotNil(t, batch.DetachDB()) + + _, err = batch.ReadSequence(kv.HeaderNumber) + require.ErrorContains(t, err, "no backing transaction") + _, err = batch.IncrementSequence(kv.HeaderNumber, 1) + require.ErrorContains(t, err, "no backing transaction") + previous, err := batch.IncrementSequence(kv.EthTx, 1) + require.NoError(t, err) + require.Equal(t, uint64(9), previous, "an explicitly written sequence needs no backing transaction") + + view := batch.NewReadView(nonTemporalTx{Tx: rwTx}) + got, err := view.ReadSequence(kv.HeaderNumber) + require.NoError(t, err) + require.Equal(t, uint64(7), got, "a failed increment must not create a zero-based sequence") +} + func TestMemoryMutationFlushDoesNotOverwriteUnchangedStateVersion(t *testing.T) { db, seedTx := newTestTx(t) ctx := t.Context() diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 3f54ce9262d..81052bd8756 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1157,8 +1157,10 @@ func requireStateVersion(tx kv.Tx, expected uint64) error { // invalidation is tx-precise: an unwind to a txNum inside the latest step drops // exactly the entries above it, not the whole step. All caches honor the // same (txNum, epoch) model. tx MUST be a flush-specific transaction: it is -// committed here. The domain flush advances PlainStateVersion exactly once; -// Commit verifies both its starting version and the version it will publish. +// committed here. Commit is terminal for this SharedDomains value; continue +// with a new one on a fresh transaction. The domain flush advances +// PlainStateVersion exactly once; Commit verifies both its starting version and +// the version it will publish. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) sourceStateVersion, committedStateVersion, err := sd.stateVersionsForCommit(tx) From 85d89251b67ca2b883f58849c9cdd67b61769bd9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:30:48 +0200 Subject: [PATCH 33/36] db/state/execctx: clarify cache publication comments --- db/state/execctx/domain_shared.go | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 81052bd8756..c88d43ab2e7 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1193,11 +1193,9 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return tx.Commit() } - // 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 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.) + // Stash every cache-bound domain tuple during the flush and publish it only + // after the commit succeeds. If the commit fails, the stash is discarded, so + // the cache never advances ahead of durable MDBX state. var pendingBranches []branchCacheUpdate var pendingState []cache.StateUpdate stash := func(domain kv.Domain) kv.FlushOption { @@ -1846,12 +1844,9 @@ func (sd *SharedDomains) domainPut(domain kv.Domain, roTx kv.TemporalTx, k, v [] } } - // The state cache is NOT updated here. This write goes into sd.mem and - // is served from there (checked first on every read, fork-isolated via - // the parent chain); the shared cache is refreshed only on flush - // (SharedDomains.Flush → FlushWithCallback), so it mirrors committed, - // fork-agnostic state. A per-write update would leak non-flushed, - // fork-specific bytes into a sibling fork's reads. + // The shared state cache is not updated here. The write remains isolated in + // sd.mem and is published to the cache only after a successful Commit; + // publishing it earlier could expose uncommitted, fork-specific state. // Serialize against the calculator's accumulator-swap window — see // changesetMu doc on the SharedDomains struct. Skipped when the caller @@ -1908,9 +1903,9 @@ func (sd *SharedDomains) DomainDel(domain kv.Domain, tx kv.TemporalTx, k []byte, return nil } - // State cache is refreshed on flush only — see DomainPut. Serialize against - // the calculator's swap window for non-commitment domains; CommitmentDomain - // skipped — see DomainPut comment. + // As in DomainPut, a deletion reaches the shared state cache only after a + // successful Commit. Serialize against the calculator's swap window for + // non-commitment domains; CommitmentDomain is skipped as described there. if domain != kv.CommitmentDomain { sd.changesetMu.Lock() defer sd.changesetMu.Unlock() From e93217777578305db8ace94d23d7d0792239e122 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:37:18 +0200 Subject: [PATCH 34/36] execution/cache: keep raw mutation helpers test-only --- execution/cache/cache_test.go | 19 +++++++++++++++++++ execution/cache/state_cache.go | 26 ++------------------------ execution/execmodule/exec_module.go | 8 +++----- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 07ec5c8d1d3..ab45a8487ef 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -41,6 +41,25 @@ func closeOnCleanup[T interface{ Close() }](tb testing.TB, c T) T { return c } +// These helpers bypass the public fill and publication protocols so tests can +// exercise the underlying entry and frontier mechanics directly. +func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64) { + cache := c.caches[domain] + if cache == nil { + return + } + cache.Put(key, bytes.Clone(value), txNum) +} + +func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { + prepared := prepareStateUpdate(StateUpdate{Domain: domain, Key: key, Value: value, TxNum: txNum}) + c.applierMu.Lock() + defer c.applierMu.Unlock() + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + c.applyPrepared(prepared) +} + func makeAddr(i int) []byte { addr := make([]byte, 20) addr[19] = byte(i) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 914df52bf37..d06189235d8 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -243,18 +243,6 @@ func (c *StateCache) deleteAddrCodeHash(addr []byte) { cc.DeleteAddrCodeHash(addr) } -// 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 Applier.Publish, 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 - } - cache.Put(key, bytes.Clone(value), 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. @@ -306,8 +294,8 @@ func (c *StateCache) fillCodeIfFresh(key []byte, value []byte, readTxNum, visibl codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) } -// deleteKey removes the data for the given domain and key. Authoritative -// deletions go through apply, which also advances the fill-admission frontier. +// deleteKey removes the data for the given domain and key. The authoritative +// publication path advances the fill-admission frontier before calling it. func (c *StateCache) deleteKey(domain kv.Domain, key []byte) { cache := c.caches[domain] if cache == nil { @@ -337,16 +325,6 @@ func prepareStateUpdate(update StateUpdate) preparedStateUpdate { return prepared } -// apply makes a committed domain update authoritative for subsequent fills. -func (c *StateCache) apply(domain kv.Domain, key, value []byte, txNum uint64) { - prepared := prepareStateUpdate(StateUpdate{Domain: domain, Key: key, Value: value, TxNum: txNum}) - c.applierMu.Lock() - defer c.applierMu.Unlock() - c.admissionMu.Lock() - defer c.admissionMu.Unlock() - c.applyPrepared(prepared) -} - func (c *StateCache) applyPrepared(update preparedStateUpdate) { cache := c.caches[update.domain] if cache == nil { diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 9fe227cbf38..0f49a322cd7 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -621,11 +621,9 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b return ValidationResult{}, criticalError } - // No cache invalidation needed on an invalid payload: the state cache is - // populated only at flush (committed, fork-agnostic state) and this - // validation path never flushes, so a rejected payload leaves nothing - // fork-specific in the cache. Reads during validation only add canonical - // committed bytes. (Cache invalidation happens solely on unwind.) + // An invalid payload needs no additional cache cleanup. Validation never + // publishes its writes, staged-unwind reads cannot fill, and an unwind has + // already performed its own cache invalidation. // Validation tx is the SD's BlockOverlay; defer doms.Close() above handles // its rollback. By design we do not persist validation-run writes — there From 4da24f1a41e5ddacd551f9a34921b11eb66bfdcb Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:57:16 +0200 Subject: [PATCH 35/36] db/state/execctx: enforce unwind bounds on parent reads --- db/state/execctx/domain_shared.go | 69 ++++++++--------- db/state/execctx/statecache_readfill_test.go | 78 ++++++++++++++++++++ 2 files changed, 110 insertions(+), 37 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index c88d43ab2e7..462c5cb177b 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1347,16 +1347,36 @@ func (sd *SharedDomains) GetLatestContext(ctx context.Context, domain kv.Domain, return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheReader()) } -// servableUnderBound gates a cached entry against an in-flight unwind's -// per-key maxStep: a hit above the bound would diverge from the bounded read -// the cache-disabled path takes (the epoch floor usually drops such entries -// already; the gate keeps the two paths identical regardless). Callers convert -// their unit first — the StateCache stamps txNums (divide by step size), the -// BranchCache stores step indices (no divide). +// servableUnderBound gates a value against an in-flight unwind's per-key +// maxStep. Callers convert their unit first: StateCache stamps txNums, while +// mem batches and BranchCache already use step indices. func servableUnderBound(cStep, maxStep kv.Step) bool { return cStep <= maxStep } +// latestFromMem carries a child's staged-unwind bound into its parent lookup. +// A parent value above that bound belongs to the discarded fork and is skipped. +func (sd *SharedDomains) latestFromMem(domain kv.Domain, key []byte) (v []byte, step, maxStep kv.Step, ok bool) { + maxStep = kv.NoStepBound + v, step, ok = sd.mem.GetLatest(domain, key) + if ok { + return v, step, maxStep, true + } + maxStep = min(maxStep, step) + + if sd.parent == nil { + return nil, 0, maxStep, false + } + v, step, ok = sd.parent.mem.GetLatest(domain, key) + if ok { + if servableUnderBound(step, maxStep) { + return v, step, maxStep, true + } + return nil, 0, maxStep, false + } + return nil, 0, min(maxStep, step), false +} + // getLatestMetered is the read implementation. wm is the caller's lock-free // per-task/per-worker metrics accumulator (nil disables metrics for the call). // No global metrics lock is taken on this hot path — accumulators are combined @@ -1375,30 +1395,14 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k wm = sd.reqMetrics } } - maxStep := kv.NoStepBound - - // Check mem batch first - it has the current transaction's uncommitted state. - // No need to populate stateCache here — mem is checked first on every read, - // so the value is already accessible without caching it again. - if v, step, ok := sd.mem.GetLatest(domain, k); ok { + // Mem batches hold the current transaction's uncommitted state, so a hit + // needs no shared-cache fill. Parent hits also obey any bound from the child. + v, step, maxStep, ok := sd.latestFromMem(domain, k) + if ok { if dbg.KVReadLevelledMetrics { wm.UpdateCacheReads(domain, start) } return v, step, nil - } else if step < maxStep { - maxStep = step - } - - // Check parent's mem batch (read-through chaining for child SDs) - if sd.parent != nil { - if v, step, ok := sd.parent.mem.GetLatest(domain, k); ok { - if dbg.KVReadLevelledMetrics { - wm.UpdateCacheReads(domain, start) - } - return v, step, nil - } else if step < maxStep { - maxStep = step - } } type MeteredGetter interface { @@ -1639,24 +1643,15 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if len(addr) == 0 { return nil } - hasUnwindBound := false // In-batch state is authoritative: sd.mem / parent.mem hold this batch's // uncommitted account writes, while the addr→codeHash LRU is invalidated only // on flush. Route mem-first; the LRU is a committed-state layer that may only // answer once mem has missed. - v, step, ok := sd.mem.GetLatest(kv.AccountsDomain, addr) + v, _, maxStep, ok := sd.latestFromMem(kv.AccountsDomain, addr) if ok { return accounts.DeserialiseV3CodeHash(v) } - hasUnwindBound = step != kv.NoStepBound - if sd.parent != nil { - v, step, ok = sd.parent.mem.GetLatest(kv.AccountsDomain, addr) - if ok { - return accounts.DeserialiseV3CodeHash(v) - } - hasUnwindBound = hasUnwindBound || step != kv.NoStepBound - } - if hasUnwindBound { + if maxStep != kv.NoStepBound { // A staged unwind bounds the committed lookup. Reuse the normal account // path so every cache and database source observes the same bound. v, _, err := sd.getLatestMetered(kv.AccountsDomain, tx, addr, nil, view) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index f5e84f8ca21..fdbd3215ef7 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -476,6 +476,84 @@ func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { require.False(t, ok, "a bounded in-flight unwind read must not populate the shared cache") } +func TestGetLatest_RejectsParentMemHitAboveStagedUnwindBound(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + + parent, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer parent.Close() + child, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer child.Close() + child.SetParent(parent) + + addr := make([]byte, 20) + addr[0] = 0xc1 + deadForkAccount := encAccount(1) + require.NoError(t, parent.DomainPut(kv.AccountsDomain, rwTx, addr, deadForkAccount, 40, nil)) // step 2 + + stepBytes := make([]byte, 8) + binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) + var diffs [kv.DomainLen][]kv.DomainEntryDiff + diffs[kv.AccountsDomain] = []kv.DomainEntryDiff{{Key: string(addr) + string(stepBytes), Value: nil}} + child.Unwind(10, &diffs) + + got, _, err := child.GetLatest(kv.AccountsDomain, rwTx, addr) + require.NoError(t, err) + require.Empty(t, got, "a parent value above the child's unwind bound belongs to the discarded fork") +} + +func TestGetCode_RejectsParentAccountAboveStagedUnwindBound(t *testing.T) { + t.Parallel() + + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + + parent, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer parent.Close() + child, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer child.Close() + child.SetParent(parent) + + addr := make([]byte, 20) + addr[0] = 0xc2 + deadForkCode := []byte{0x60, 0x01, 0x60, 0x00, 0x55} + codeHash := crypto.Keccak256Hash(deadForkCode) + deadForkAccount := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + CodeHash: accounts.InternCodeHash(codeHash), + }) + require.NoError(t, parent.DomainPut(kv.AccountsDomain, rwTx, addr, deadForkAccount, 40, nil)) // step 2 + + codeStore := cache.NewCodeStore(1<<20, 1<<20) + require.NoError(t, codeStore.PutByHash(rwTx, codeHash[:], deadForkCode)) + child.SetCodeStore(codeStore) + + stepBytes := make([]byte, 8) + binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) + var diffs [kv.DomainLen][]kv.DomainEntryDiff + diffs[kv.AccountsDomain] = []kv.DomainEntryDiff{{Key: string(addr) + string(stepBytes), Value: nil}} + child.Unwind(10, &diffs) + + got, ok, err := child.GetCode(rwTx, addr, 40) + require.NoError(t, err) + require.False(t, ok, "an above-bound parent account must not resolve dead-fork code") + require.Empty(t, got) +} + func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { t.Parallel() From d52c260254bda95df54d108ab8ea7a84af28b7b1 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:25:24 +0200 Subject: [PATCH 36/36] execution/execmodule: clarify notification state version ownership --- execution/execmodule/forkchoice.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index cc1703ca175..b998b0db5b1 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -767,11 +767,11 @@ func (e *ExecModule) logTimings(msg string, timings []any) { e.logger.Info(msg, timings...) } -// dispatchNotificationsFromOverlay sends notifications reading from the SD's -// blockOverlay (MemoryMutation). All required data — headers, canonical hashes, -// state version, forkchoice markers — exists in the overlay before flush/commit. -// Called inline (under semaphore) so consumers have the data before the next -// FCU can start. +// dispatchNotificationsFromOverlay sends pre-commit notifications from the +// SD's block overlay. The state version is supplied separately because the +// domain flush, not the metadata overlay, owns its durable sequence advance. +// Dispatch must finish before the execution semaphore is released so the next +// FCU cannot overtake these notifications. func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, finishProgressBefore uint64) error { dispatcher := e.pipelineExecutor.Dispatcher() if dispatcher == nil || e.accum == nil {