From 2ec529730c562b8df06473a8249caf36d7ca8bfe Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:10:06 +0000 Subject: [PATCH] execution/cache: prevent dead-fork StateCache fills across unwind (#23005) Fixes #22463. `StateCache` stores latest committed state. Unwind already made resident dead-fork entries stale, but readers could add those values again from an old or transient view: a transaction could survive or first bind during unwind, staged unwind rows still existed in the backing database, and read-ahead could fill concurrently. This PR closes those windows by binding fill authority to both the durable `PlainStateVersion` and the lifetime of the original `ReadView`. Reads constrained by a staged unwind cannot fill, cache changes are published only after the database commit, and read-ahead cannot cross the unwind transition. Snapshot and immutable-file publication are a separate coherence boundary. #23028 still requires #23047 or an equivalent publication hook and is not addressed here. Bounded speculative-unwind fills in the separate commitment `BranchCache` pre-exist this PR and are tracked in Suggested order: 1. `execution/cache/view.go` and `state_cache.go`: fill admission and publication. 2. `db/state/execctx/domain_shared.go`: transaction identity, bounded reads, and commit/unwind integration. 3. `db/kv/membatchwithdb/memory_mutation.go` and `db/state/temporal_mem_batch.go`: `PlainStateVersion` ownership and monotonicity. 4. `execution/exec/blocks_read_ahead.go` and `execution/execmodule`: read-ahead exclusion and lifecycle. Focused regression tests sit beside each area. | Marker | Protects | | --- | --- | | `PlainStateVersion` | The durable state visible to a transaction | | `readViewEpoch` | Whether a `ReadView` predates the latest unwind or state discontinuity | | Per-cache entry epoch and unwind floor | Whether a stored value belongs to the retained fork | Once the cache has a durable state version, an admission-gated state fill is accepted only if the view has the published state version and current epoch, publication is not in progress, its exact domain frontier is not behind the cache, and the read has no staged-unwind step bound. Content-addressed code-size fills do not need these state-view checks. An ineligible view may still read cache hits; only its fill authority is revoked. `WithFrontier` preserves the original epoch, so rebinding cannot renew an old view. Stored entries remain O(1) to invalidate and are discarded lazily. The three markers stay separate because durable state, reader, and stored-entry lifetimes change at different boundaries. 1. Staging an unwind revokes existing views, invalidates stored entries, and records the lowest staged boundary. Bounded reads cannot fill. 2. Flush advances `PlainStateVersion` exactly once with the domain writes and collects cache updates without publishing them. 3. The database transaction commits. 4. Cache publication applies the complete batch. It repeats unwind invalidation at the durable boundary, rejects delayed or out-of-order versions, preserves entries after a continuous forward commit, and clears them when continuity is unknown. During publication, reads and view binding remain available, but fills are disabled. A view bound during publication remains fill-inert until explicitly rebound. `MemoryMutation` resolves untouched sequences from its backing transaction and flushes only changed sequence keys, so it cannot replay an older state version. Pre-commit notifications receive the projected version explicitly rather than deriving it from overlay sequence writes. Notification ordering itself is unchanged and remains tracked in #23240. One semaphore permit covers read-ahead warmup and unwind exclusion. A warmup acquires it without blocking, so work requested while another warmup or an unwind owns or waits for the permit is skipped rather than queued. Unwind callers acquire it with their context and abort before staging if cancellation wins. `updateForkChoice` and `SetHead` hold the permit through unwind and publication; `ValidateChain` acquires it only when it stages an unwind. Every FCU currently excludes warmup, including FCUs that do not unwind; narrowing that scope is tracked in #23003. - The cache-hit path is unchanged. - `View(nil)` adds one atomic load. Binding a fill-enabled `ReadView` also takes `admissionMu.RLock` to check publication and state-version eligibility; getters retain that view instead of paying the binding cost per key. - Normal getters reuse the `SharedDomains` transaction's memoized state version. A different transaction resolves its version at initial binding. If that resolution temporarily fails, later cache misses retry it; each retry is local to that miss. - Unwind invalidation remains O(1), with no cache scan or diff replay. - Each accepted warmup performs one uncontended semaphore acquisition. Rejected warmups do not start a goroutine, and the gate is never touched per key. Regression tests cover old and newly bound views across every unwind phase, bounded state and code-hash reads, delayed publications, memory-overlay state versions, read-ahead exclusion and cancellation, and valid forward fills. --------- Co-authored-by: Alexey Sharov --- db/kv/membatchwithdb/memory_mutation.go | 94 +-- db/kv/membatchwithdb/memory_mutation_test.go | 153 ++++- db/state/execctx/codehash_routing_test.go | 5 +- db/state/execctx/domain_shared.go | 335 +++++++--- db/state/execctx/export_test.go | 3 +- db/state/execctx/state_version_commit_test.go | 93 +++ db/state/execctx/statecache_readfill_test.go | 385 +++++++++++- .../statecache_rpc_integration_test.go | 303 +++++++++ db/state/temporal_mem_batch.go | 3 +- execution/cache/cache.go | 12 +- execution/cache/cache_test.go | 585 +++++++++++++++++- execution/cache/state_cache.go | 221 +++++-- execution/cache/view.go | 176 +++++- execution/exec/blocks_read_ahead.go | 71 ++- execution/exec/blocks_read_ahead_test.go | 119 +++- execution/execmodule/exec_module.go | 58 +- .../execmodule/exec_module_internal_test.go | 53 ++ execution/execmodule/fork_validator.go | 17 +- execution/execmodule/forkchoice.go | 24 +- .../execmodule/notification_dispatcher.go | 20 +- .../notification_dispatcher_test.go | 71 +++ execution/execmodule/set_head.go | 11 +- execution/stagedsync/exec3.go | 5 +- .../stagedsync/exec3_state_version_test.go | 77 +++ execution/stagedsync/stageloop/stageloop.go | 11 +- 25 files changed, 2568 insertions(+), 337 deletions(-) create mode 100644 db/state/execctx/state_version_commit_test.go create mode 100644 execution/execmodule/notification_dispatcher_test.go create mode 100644 execution/stagedsync/exec3_state_version_test.go diff --git a/db/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,