diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go
index 086a242411e..84aa478a1db 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,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
@@ -72,12 +74,10 @@ 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{},
+ readTx: tx,
db: tx,
memDb: memDB,
memTx: mem,
@@ -101,13 +101,9 @@ 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{},
+ readTx: tx,
db: tx,
memDb: tmpDB,
memTx: memTx,
@@ -137,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
}
@@ -145,10 +142,13 @@ 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()
db := m.db
+ m.readTx = nil
m.db = nil
m.statelessCursors = nil
return db
@@ -185,33 +185,20 @@ 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()
- return m.memTx.IncrementSequence(bucket, amount)
+ current, err := m.readSequenceLocked(bucket)
+ if err != nil || amount == 0 {
+ return current, 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 {
@@ -220,6 +207,27 @@ func (m *MemoryMutation) ResetSequence(bucket string, newValue uint64) error {
return m.memTx.ResetSequence(bucket, newValue)
}
+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) {
+ return 0, nil
+ }
+ if m.readTx == nil {
+ return 0, fmt.Errorf("read sequence %q: no backing transaction is attached", bucket)
+ }
+ return m.readTx.ReadSequence(bucket)
+}
+
func (m *MemoryMutation) ForAmount(bucket string, prefix []byte, amount uint32, walker func(k, v []byte) error) error {
if amount == 0 {
return nil
@@ -285,11 +293,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) {
@@ -312,10 +320,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 {
@@ -382,8 +390,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
}
}
@@ -467,8 +475,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
}
}
@@ -887,8 +895,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
}
@@ -930,7 +938,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 {
@@ -1071,10 +1079,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
@@ -1097,6 +1104,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 661da20428f..952d448f388 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) {
@@ -506,17 +507,165 @@ 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(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)
+}
+
+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 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()
+
+ _, 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) {
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 071176eb652..462c5cb177b 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"
@@ -34,6 +35,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 +174,42 @@ func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) {
return f.sd.domainVisibleEnd(f.tx, domain)
}
+// 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 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 {
+ generationTx := cacheGenerationTx(tx)
+ if generationTx == nil {
+ 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.
+ useBaseStateVersion := generationTx.ViewID() == sd.baseViewID && txWritable == sd.baseTxWritable
+ if !useBaseStateVersion {
+ var err error
+ stateVersion, err = rawdb.GetStateVersion(generationTx)
+ if err != nil {
+ return nil
+ }
+ }
+ return cache.FrontierWithStateVersion(sdFrontier{sd: sd, tx: tx}, stateVersion)
+}
+
// 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 +217,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 +243,10 @@ type SharedDomains struct {
logger log.Logger
+ baseViewID uint64
+ baseTxWritable bool
+ baseStateVersion uint64
+
txNum uint64
currentStep kv.Step
// disableInlineTouchKey when true, DomainPut skips the TouchKey call.
@@ -237,6 +279,7 @@ type SharedDomains struct {
// cacheApplier is its authoritative writer handle (commit/unwind only).
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.
@@ -283,6 +326,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
@@ -307,10 +359,22 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger,
}
trieCfg := o.trieCfg
+ generationTx := cacheGenerationTx(tx)
+ 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(),
+ 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 {
@@ -403,6 +467,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.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.cacheUnwind.toTxNum)
+ }
+ sd.stageCacheUnwind(other.cacheUnwind.toTxNum)
+ }
// Merge block-level metadata from other's overlay into ours by flushing
// other's overlay writes directly into our overlay (which implements kv.RwTx).
@@ -785,8 +857,19 @@ 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)
+ sd.stageCacheUnwind(txNumUnwindTo)
+}
+
+// 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.cacheUnwind.pending || txNumUnwindTo < sd.cacheUnwind.toTxNum {
+ sd.cacheUnwind.toTxNum = txNumUnwindTo
+ }
+ sd.cacheUnwind.pending = true
}
func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem }
@@ -851,8 +934,13 @@ 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()
+ sd.cacheApplier.Initialize(sd.baseStateVersion)
}
// GuardAggregatorForCache forbids visibility lowering on db's aggregator when
@@ -1016,12 +1104,46 @@ 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
+}
+
+// ProjectedStateVersion returns the durable state version produced by the next
+// successful Commit.
+func (sd *SharedDomains) ProjectedStateVersion() (uint64, error) {
+ 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
@@ -1035,9 +1157,16 @@ type cacheUpdate 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. 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)
+ if err != nil {
+ return err
+ }
runValidate := func() error {
for _, v := range validate {
@@ -1058,23 +1187,33 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun
if err := runValidate(); err != nil {
return err
}
+ if err := requireStateVersion(tx, committedStateVersion); err != nil {
+ return err
+ }
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.)
- var pending []cacheUpdate
+ // 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 {
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,
})
})
}
@@ -1096,12 +1235,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,
})
}
}))
@@ -1169,20 +1307,27 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun
sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader, factory, provider)
}
}
+ if err := requireStateVersion(tx, committedStateVersion); err != nil {
+ return err
+ }
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)
}
- sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN)
+ }
+ if sd.stateCache != nil {
+ if sd.cacheUnwind.pending {
+ sd.cacheApplier.PublishUnwind(sourceStateVersion, committedStateVersion, sd.cacheUnwind.toTxNum, pendingState)
+ } else {
+ sd.cacheApplier.Publish(sourceStateVersion, committedStateVersion, pendingState)
+ }
+ sd.cacheUnwind = cacheUnwindState{}
}
return nil
}
@@ -1202,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
@@ -1230,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 {
@@ -1344,16 +1493,14 @@ 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)
+ 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)
}
@@ -1500,13 +1647,18 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView,
// 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, _, maxStep, ok := sd.latestFromMem(kv.AccountsDomain, addr)
+ if ok {
return accounts.DeserialiseV3CodeHash(v)
}
- if sd.parent != nil {
- if v, _, ok := sd.parent.mem.GetLatest(kv.AccountsDomain, addr); ok {
- return accounts.DeserialiseV3CodeHash(v)
+ 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)
+ if err != nil {
+ return nil
}
+ return accounts.DeserialiseV3CodeHash(v)
}
// Below mem: the addr → codeHash LRU caches committed state
@@ -1553,10 +1705,10 @@ 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.
- seedView = sd.cacheViewFor(tx)
+ 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)
}
@@ -1687,12 +1839,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
@@ -1749,9 +1898,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()
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/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/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go
index 7c198a3034e..fdbd3215ef7 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"
@@ -25,9 +26,12 @@ import (
"github.com/holiman/uint256"
"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"
+ "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"
@@ -80,16 +84,24 @@ func frontierAt(end uint64) cache.Frontier {
return cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return end, 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 cache.FrontierWithStateVersion(frontier, 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 {
@@ -114,6 +126,147 @@ 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)
+}
+
+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()
+
+ 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())
+ 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 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()
+
+ 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()
@@ -171,6 +324,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)
@@ -185,7 +339,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
@@ -212,6 +366,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
@@ -241,7 +396,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
@@ -266,6 +421,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)
@@ -281,7 +437,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)
@@ -292,6 +448,224 @@ 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()
+ t.Cleanup(sc.Close)
+ 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 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()
+
+ const stepSize = uint64(16)
+ ctx := t.Context()
+ db := newTestDb(t, stepSize)
+ sc := newSmallStateCache()
+ t.Cleanup(sc.Close)
+
+ 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")
+}
+
+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")
+}
+
// 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) {
@@ -301,6 +675,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)
diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go
index aea67d930c3..a30718b0626 100644
--- a/db/state/execctx/statecache_rpc_integration_test.go
+++ b/db/state/execctx/statecache_rpc_integration_test.go
@@ -45,6 +45,309 @@ 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 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()
+ 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)
+ defer unwindDomains.Close()
+ 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()
+ 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)
+ 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 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)
+ defer unwindDomains.Close()
+ 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()
+ 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)
+ defer unwindDomains.Close()
+ 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()
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/cache/cache.go b/execution/cache/cache.go
index d30c41a0f4d..eb98c7025f3 100644
--- a/execution/cache/cache.go
+++ b/execution/cache/cache.go
@@ -22,16 +22,18 @@
// 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.
+// 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.
package cache
// Cache is the interface for domain caches.
diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go
index 42e0e740eab..ab45a8487ef 100644
--- a/execution/cache/cache_test.go
+++ b/execution/cache/cache_test.go
@@ -19,8 +19,11 @@ package cache
import (
"bytes"
"encoding/binary"
+ "fmt"
"sync"
+ "sync/atomic"
"testing"
+ "time"
"github.com/c2h5oh/datasize"
"github.com/stretchr/testify/assert"
@@ -38,12 +41,51 @@ 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)
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)
@@ -58,6 +100,14 @@ 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 })
+}
+
+func frontierAtVersion(end, stateVersion uint64) Frontier {
+ return FrontierWithStateVersion(frontierAt(end), stateVersion)
+}
+
// =============================================================================
// DomainCache Tests
// =============================================================================
@@ -921,11 +971,297 @@ 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")
}
+// 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.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_PreReorgViewCannotFillAfterUnwind(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)
+
+ 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")
+
+ 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")
+}
+
+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)
+ 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) {
+ 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_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")
+ 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")
+ }
+
+ close(cache.release)
+ <-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 {
+ case <-cache.filled:
+ case <-time.After(time.Second):
+ t.Fatal("continuous publication did not restore fill admission")
+ }
+}
+
+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)
+ t.Cleanup(sc.Close)
+
+ key := makeAddr(1)
+ sc.Applier().Initialize(1)
+ 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) {
+ b := 1 * datasize.MB
+ sc := NewStateCache(b, b, b, b)
+ t.Cleanup(sc.Close)
+
+ key := makeAddr(1)
+ sc.Applier().Initialize(1)
+ 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)
+ 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_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)
@@ -935,12 +1271,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)
@@ -967,7 +1303,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()
@@ -984,7 +1320,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)
@@ -1075,7 +1411,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
@@ -1099,9 +1435,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)
}
@@ -1114,16 +1450,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.Applier().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")
@@ -1140,20 +1484,30 @@ 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)
+ 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")
@@ -1173,3 +1527,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 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
+
+ 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")
+ })
+ }
+}
diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go
index 5bd0d4ecfa2..d06189235d8 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"
@@ -54,13 +55,24 @@ 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
+ // 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
+ // 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
}
@@ -73,7 +85,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)
@@ -207,14 +219,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 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 visibleEnd < c.appliedEnd[kv.AccountsDomain] {
+ if c.publishing ||
+ viewEpoch != c.readViewEpoch.Load() ||
+ visibleEnd < c.appliedEnd[kv.AccountsDomain] {
return
}
cc.PutAddrCodeHash(addr, h, txNum)
@@ -228,28 +243,16 @@ 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.Apply, read fills through
-// ReadView.Fill.
-func (c *StateCache) put(domain kv.Domain, key []byte, value []byte, txNum uint64) {
- cache := c.caches[domain]
- if cache == nil {
- return
- }
- 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.
-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,
+ 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.
+ // Clone outside the lock so admission never waits on the copy.
cloned := bytes.Clone(value)
if len(value) == 0 {
readTxNum = 0
@@ -259,7 +262,9 @@ 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 c.publishing ||
+ viewEpoch != c.readViewEpoch.Load() ||
+ visibleEnd < c.appliedEnd[domain] {
return
}
cache.PutIfAbsent(key, cloned, readTxNum)
@@ -270,7 +275,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 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
@@ -279,14 +285,17 @@ 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 c.publishing ||
+ viewEpoch != c.readViewEpoch.Load() ||
+ visibleEnd < c.appliedEnd[kv.CodeDomain] ||
+ accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] {
return
}
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 {
@@ -295,45 +304,55 @@ 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
+}
- c.admissionMu.Lock()
- defer c.admissionMu.Unlock()
- c.noteApplied(domain, txNum)
+func (c *StateCache) applyPrepared(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)
}
}
@@ -342,7 +361,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) {
@@ -359,8 +378,14 @@ 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()
+}
+
+func (c *StateCache) clearLocked() {
for _, cache := range c.caches {
if cache != nil {
cache.Clear()
@@ -368,6 +393,14 @@ func (c *StateCache) clear() {
}
}
+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[:])
+}
+
// Close releases every sub-cache's slot in the shared memory envelope so later
// caches size against real concurrency. Idempotent.
func (c *StateCache) Close() {
@@ -384,8 +417,15 @@ 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)
+}
+
+func (c *StateCache) unwindLocked(unwindToTxNum uint64) {
+ c.readViewEpoch.Add(1)
for _, cache := range c.caches {
if cache != nil {
cache.Unwind(unwindToTxNum)
@@ -396,6 +436,79 @@ func (c *StateCache) unwind(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.canAdvanceStateVersionLocked(stateVersion) {
+ return
+ }
+ c.resetForStateVersionLocked()
+ c.stateVersion = stateVersion
+ c.stateVersionKnown = true
+ c.publishing = false
+}
+
+func (c *StateCache) beginPublication(sourceStateVersion, committedStateVersion, unwindToTxNum uint64,
+ hasUnwind bool) bool {
+ c.admissionMu.Lock()
+ defer c.admissionMu.Unlock()
+ 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
+ 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()
+ } else if hasUnwind {
+ c.unwindLocked(unwindToTxNum)
+ }
+ 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) {
+ 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.
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 0de781920d6..027c7010aa3 100644
--- a/execution/cache/view.go
+++ b/execution/cache/view.go
@@ -34,31 +34,128 @@ 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() 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.
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 alone, 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.
+// 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
// 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.
+// 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:
+// 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. 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
- frontier Frontier
+ c *StateCache
+ frontier Frontier
+ readViewEpoch 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} }
+// 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 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{}
+ }
+ if f == nil {
+ return ReadView{c: c, readViewEpoch: c.readViewEpoch.Load()}
+ }
+ c.admissionMu.RLock()
+ defer c.admissionMu.RUnlock()
+ return ReadView{
+ c: c,
+ frontier: c.bindFrontierLocked(f),
+ readViewEpoch: c.readViewEpoch.Load(),
+ }
+}
+
+// WithFrontier binds f while preserving the original view's read-view epoch.
+// 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
+ }
+ if f == nil {
+ v.frontier = nil
+ return v
+ }
+ v.c.admissionMu.RLock()
+ defer v.c.admissionMu.RUnlock()
+ v.frontier = v.c.bindFrontierLocked(f)
+ return v
+}
+
+func (c *StateCache) bindFrontierLocked(frontier Frontier) Frontier {
+ if frontier == nil || c.publishing {
+ return nil
+ }
+ if !c.stateVersionKnown {
+ return frontier
+ }
+ versioned, ok := frontier.(stateVersionFrontier)
+ if !ok {
+ return rejectedFrontier{}
+ }
+ stateVersion := versioned.StateVersion()
+ if stateVersion < c.stateVersion {
+ return rejectedFrontier{}
+ }
+ if 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 —
@@ -105,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;
@@ -128,10 +234,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.readViewEpoch)
return
}
- v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd)
+ v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd, v.readViewEpoch)
}
// SeedAddrCodeHash offers an addr → codeHash mapping derived from an account
@@ -145,7 +251,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.readViewEpoch)
}
// FillCodeSize records the code length for codeHash. Content-addressed and
@@ -159,23 +265,53 @@ 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
}
+// 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} }
-// 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) {
+// 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. 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
+ }
+ 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. 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
}
- a.c.apply(domain, key, value, txNum)
+ a.c.publish(sourceStateVersion, committedStateVersion, unwindToTxNum, true, updates)
}
// Unwind invalidates, across all caches, entries reflecting state above
diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go
index 0dd5e11964d..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"
@@ -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"
@@ -31,9 +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
+ // 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
@@ -62,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),
}
}
@@ -96,7 +99,12 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter
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 := cache.FrontierWithStateVersion(debug, 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) {
@@ -113,28 +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() {
+ bra.startWarmup(func() {
bra.warmBody(ctx, db, header, body, 8) // use 8 workers for warming
})
}
}
-// WaitForWarmup blocks until any in-flight warmBody goroutine finishes or
-// the context is cancelled. Call before closing the database to avoid
-// waitTxsAllDoneOnClose hangs.
-func (bra *BlockReadAheader) WaitForWarmup(ctx context.Context) {
- done := make(chan struct{})
+func (bra *BlockReadAheader) startWarmup(warm func()) bool {
+ if !bra.warmupGate.TryAcquire(1) {
+ return false
+ }
go func() {
- bra.warmWg.Wait()
- close(done)
+ defer bra.warmupGate.Release(1)
+ warm()
}()
- select {
- case <-done:
- case <-ctx.Done():
+ 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. 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 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) {
+ if err := bra.warmupGate.Acquire(ctx, 1); err == nil {
+ bra.warmupGate.Release(1)
}
}
@@ -155,10 +175,9 @@ 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) {
- 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 439ac18982e..9a2f5dccd82 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,118 @@ 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{})
+ require.True(t, bra.startWarmup(func() {
+ close(warmupStarted)
+ <-finishWarmup
+ close(warmupDone)
+ }))
+ <-warmupStarted
+
+ suspendStarted := make(chan struct{})
+ type suspendResult struct {
+ resume func()
+ err error
+ }
+ suspended := make(chan suspendResult)
+ go func() {
+ close(suspendStarted)
+ resume, err := bra.SuspendWarmup(t.Context())
+ suspended <- suspendResult{resume: resume, err: err}
+ }()
+ <-suspendStarted
+ select {
+ case result := <-suspended:
+ require.NoError(t, result.err)
+ result.resume()
+ close(finishWarmup)
+ <-warmupDone
+ t.Fatal("SuspendWarmup returned while a warmup was active")
+ case <-time.After(50 * time.Millisecond):
+ }
+
+ close(finishWarmup)
+ result := <-suspended
+ require.NoError(t, result.err)
+ result.resume()
+ <-warmupDone
+}
+
+func TestBlockReadAheaderSuspendWarmupSkipsNewWarmup(t *testing.T) {
+ bra := NewBlockReadAheader()
+ resume, err := bra.SuspendWarmup(t.Context())
+ require.NoError(t, err)
+
+ warmupStarted := make(chan struct{})
+ require.False(t, bra.startWarmup(func() { close(warmupStarted) }),
+ "warmup must be skipped rather than queued behind the suspension")
+
+ resume()
+ select {
+ case <-warmupStarted:
+ t.Fatal("a skipped warmup started after suspension ended")
+ default:
+ }
+
+ nextWarmupDone := make(chan struct{})
+ require.True(t, bra.startWarmup(func() { close(nextWarmupDone) }))
+ select {
+ case <-nextWarmupDone:
+ case <-time.After(time.Second):
+ 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
// API without moving the applied frontier.
func seedFill(sc *cache.StateCache, domain kv.Domain, k, v []byte, txNum uint64) {
@@ -190,11 +303,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)
diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go
index 1423ba5ac94..0f49a322cd7 100644
--- a/execution/execmodule/exec_module.go
+++ b/execution/execmodule/exec_module.go
@@ -393,25 +393,19 @@ 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. 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
- }
- ctx := e.bacgroundCtx
- if ctx == nil {
- ctx = context.Background()
+ return func() {}, nil
}
- e.readAheader.WaitForWarmup(ctx)
+ return e.readAheader.SuspendWarmup(ctx)
}
-func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) 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) error {
currentHeader := header
for {
isCanonical, err := e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash())
@@ -446,7 +440,9 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te
return err
}
- e.drainReadAhead()
+ 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
}
@@ -597,21 +593,37 @@ 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 {
+ // 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()
+ 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 {
+ resumeReadAhead()
+ }
+ }()
+
+ if err := e.unwindToCommonCanonical(doms, tx, header, ensureReadAheadSuspended); err != nil {
doms.Close()
return ValidationResult{}, err
}
- 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
}
- // 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
diff --git a/execution/execmodule/exec_module_internal_test.go b/execution/execmodule/exec_module_internal_test.go
index 725dcbfb292..ce50ac66813 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,27 @@ 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() error { return nil })
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 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 f43a4dccba4..1d6761bbd43 100644
--- a/execution/execmodule/fork_validator.go
+++ b/execution/execmodule/fork_validator.go
@@ -158,11 +158,13 @@ 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;
+// 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 {
@@ -244,6 +246,11 @@ func (fv *ForkValidator) ValidatePayload(ctx context.Context, sd *execctx.Shared
if unwindPoint == fv.currentHeight {
unwindPoint = 0
}
+ if unwindPoint != 0 {
+ 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 7186fa73322..b998b0db5b1 100644
--- a/execution/execmodule/forkchoice.go
+++ b/execution/execmodule/forkchoice.go
@@ -360,10 +360,11 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa
})
defer cleanupBeforeSemaRelease()
- // Drain any warmup a preceding newPayload spawned: a fill from a pre-unwind
- // view would survive this FCU's possible unwind epoch-bump as a live entry
- // (see drainReadAhead). No new warmup starts while we hold the semaphore.
- e.drainReadAhead()
+ 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
@@ -766,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 {
@@ -786,6 +787,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.
@@ -796,6 +801,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/execmodule/set_head.go b/execution/execmodule/set_head.go
index 9a9c11c2004..9f88c56c9f7 100644
--- a/execution/execmodule/set_head.go
+++ b/execution/execmodule/set_head.go
@@ -58,6 +58,12 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error {
}
defer e.semaphore.Release(1)
+ resumeReadAhead, err := e.suspendReadAhead(ctx)
+ if err != nil {
+ return fmt.Errorf("suspend read-ahead: %w", err)
+ }
+ defer resumeReadAhead()
+
tx, err := e.db.BeginTemporalRw(ctx)
if err != nil {
return fmt.Errorf("failed to begin rw transaction: %w", err)
@@ -111,11 +117,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)
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,