diff --git a/db/kv/membatchwithdb/memory_mutation.go b/db/kv/membatchwithdb/memory_mutation.go index 1d24f6ce070..48279159cca 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 } } @@ -882,8 +890,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 } @@ -925,7 +933,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 { @@ -1066,10 +1074,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 @@ -1092,6 +1099,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 45ddc89b449..c2582b71cb8 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 932cae9c18f..28bd97c8546 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. @@ -232,6 +274,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. @@ -278,6 +321,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 @@ -308,10 +360,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, } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -400,6 +464,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). @@ -782,8 +854,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 } @@ -848,8 +931,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 @@ -1007,12 +1095,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 @@ -1026,9 +1148,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 { @@ -1049,23 +1178,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, }) }) } @@ -1087,12 +1226,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, }) } })) @@ -1160,20 +1298,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) + } + } + 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.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) + sd.cacheUnwind = cacheUnwindState{} } return nil } @@ -1193,16 +1338,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 @@ -1221,34 +1386,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 { @@ -1339,16 +1484,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) } @@ -1495,13 +1638,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 @@ -1548,10 +1696,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) } @@ -1682,12 +1830,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 @@ -1744,9 +1889,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 e8efb326da2..0aaa60e100b 100644 --- a/db/state/temporal_mem_batch.go +++ b/db/state/temporal_mem_batch.go @@ -734,7 +734,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 87c44d3664b..7c5392e7629 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" @@ -30,9 +31,10 @@ type BlockReadAheader struct { bodies *lru.Cache[common.Hash, *types.Body] senders *lru.Cache[common.Hash, []byte] // just do raw senders - // 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 @@ -57,9 +59,10 @@ func NewBlockReadAheader() *BlockReadAheader { panic(err) } return &BlockReadAheader{ - headers: headers, - bodies: bodies, - senders: senders, + headers: headers, + bodies: bodies, + senders: senders, + warmupGate: semaphore.NewWeighted(1), } } @@ -90,7 +93,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) { @@ -107,28 +115,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) } } @@ -142,10 +162,9 @@ func (bra *BlockReadAheader) AddSenders(senders []byte, blockHash common.Hash) { // 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 b5d153feef3..a2ae04a06f7 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -17,7 +17,9 @@ package exec import ( + "context" "testing" + "time" "github.com/c2h5oh/datasize" "github.com/stretchr/testify/require" @@ -49,6 +51,118 @@ func newTestStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } +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) { @@ -171,11 +285,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 9a5b3204633..d90b491f869 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -396,25 +396,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()); !isCanonical && err == nil; isCanonical, err = e.isCanonicalHash(e.bacgroundCtx, tx, currentHeader.Hash()) { parentBlockHash, parentBlockNum := currentHeader.ParentHash, currentHeader.Number.Uint64()-1 @@ -442,7 +436,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 } @@ -593,21 +589,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 f2420c0ff57..3c98c232f0a 100644 --- a/execution/execmodule/exec_module_internal_test.go +++ b/execution/execmodule/exec_module_internal_test.go @@ -17,14 +17,47 @@ package execmodule import ( + "context" + "errors" "testing" "github.com/c2h5oh/datasize" + "github.com/holiman/uint256" "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/dbservices" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/execution/types" ) +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. @@ -44,3 +77,23 @@ func TestNewDomainStateCacheRespectsUseStateCache(t *testing.T) { require.NotNil(t, scDefault, "zero budget means the production default, not no cache") scDefault.Close() } + +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 1fc6a033ccc..622a4f8eeed 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 @@ -807,11 +808,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 { @@ -827,6 +828,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. @@ -837,6 +842,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 ddfc9929849..71f3f4a6e3d 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 72edfcb0921..6f7496acc3a 100644 --- a/execution/stagedsync/exec3.go +++ b/execution/stagedsync/exec3.go @@ -770,7 +770,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") @@ -784,9 +784,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 dbg.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..b897bf40cef --- /dev/null +++ b/execution/stagedsync/exec3_state_version_test.go @@ -0,0 +1,77 @@ +// 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 ( + "os" + "os/exec" + "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) { + const childProcess = "ERIGON_TEST_STATE_VERSION_CHILD" + if os.Getenv(childProcess) == "" { + // DiscardCommitment is initialized before tests run, so use a child + // process to exercise that early-return path. + t.Setenv("DISCARD_COMMITMENT", "true") + cmd := exec.Command(os.Args[0], "-test.run=^TestComputeAndCheckCommitmentDoesNotAdvanceStateVersionInOverlay$") + cmd.Env = append(os.Environ(), childProcess+"=1") + output, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", output) + return + } + + _, 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{}, + 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 67f98000697..f25b24ba260 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 Hook struct { @@ -138,8 +138,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,