From 70721745fff8d60a9e00cf6f7c389f71f687c6c3 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:37:09 +0200 Subject: [PATCH 01/50] execution/cache: test unwind fill-readmission window --- execution/cache/cache_test.go | 39 +++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 42e0e740eab..37952ccc450 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -926,6 +926,45 @@ func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { require.False(t, ok, "a view older than the deletion must not fill afterward") } +// SharedDomains commits the tx and only then walks `pending` into the cache, so +// between those steps a reader opening a new tx legitimately sees txNums the +// cache has not applied yet: its frontier is ahead of appliedEnd. Rejecting +// "ahead" would drop fills on every flush for the length of the apply loop. +func TestStateCache_ReaderAheadOfApplyWindowCanFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + sc.apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 100) + + key := makeAddr(2) + sc.fillIfFresh(kv.AccountsDomain, key, makeValue(2), 200, 201) + + _, ok := sc.get(kv.AccountsDomain, key) + require.True(t, ok, + "a reader ahead of appliedEnd is the normal commit-then-apply window, not a dead fork") +} + +func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { + b := 1 * datasize.MB + sc := NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + + key := makeAddr(1) + canonical, fork := makeValue(1), makeValue(2) + sc.apply(kv.AccountsDomain, key, canonical, 40) + sc.apply(kv.AccountsDomain, key, fork, 100) + + sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + sc.unwind(50) + _, ok := sc.get(kv.AccountsDomain, key) + require.False(t, ok, "the unwind must evict the fork's value") + + sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) + _, ok = sc.get(kv.AccountsDomain, key) + require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") +} + func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { b := 1 * datasize.MB sc := NewStateCache(b, b, b, b) From 9a4e898e521fda2f2a80fe1414770d7d5270ca58 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:55:51 +0200 Subject: [PATCH 02/50] execution/cache, db/state: publish StateCache by state version --- cmd/integration/commands/stages.go | 2 +- db/state/execctx/codehash_routing_test.go | 23 +- db/state/execctx/domain_shared.go | 378 +++++------- .../execctx/domain_visible_end_memo_test.go | 103 ---- db/state/execctx/export_test.go | 11 +- db/state/execctx/flush_storage_cache_test.go | 4 +- .../execctx/statecache_readfill_bench_test.go | 19 +- db/state/execctx/statecache_readfill_test.go | 217 +++---- .../statecache_rpc_integration_test.go | 207 +++++-- execution/cache/cache.go | 57 +- execution/cache/cache_test.go | 582 ++++++------------ execution/cache/code_cache.go | 209 ++----- execution/cache/code_cache_codehash_test.go | 71 +-- .../cache/code_cache_concurrency_test.go | 27 +- execution/cache/generic_cache.go | 155 ++--- .../cache/generic_cache_concurrency_test.go | 120 +--- execution/cache/state_cache.go | 425 ++++++------- execution/cache/view.go | 194 ++---- execution/exec/blocks_read_ahead.go | 19 +- execution/exec/blocks_read_ahead_test.go | 103 ++-- execution/execmodule/exec_module.go | 21 +- execution/execmodule/forkchoice.go | 9 +- execution/execmodule/set_head.go | 13 +- execution/vm/contract.go | 2 +- 24 files changed, 1085 insertions(+), 1886 deletions(-) delete mode 100644 db/state/execctx/domain_visible_end_memo_test.go diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index e55f94ca9b8..b7f7610a6f0 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -844,7 +844,7 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn } defer doms.Close() doms.SetInMemHistoryReads(false) - doms.SetStateCache(stateCache) + doms.SetCanonicalStateCache(stateCache) doms.SetCodeStore(codeStore) execctx.GuardAggregatorForCache(db, stateCache) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 211e3cadaea..8cdf39f25da 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -45,7 +45,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - sc.View(frontierAt(0)).SeedAddrCodeHash(addr[:], staleArr, 0) + currentStateCacheView(t, sc).SeedAddrCodeHash(addr[:], staleArr) t.Run("empty in-batch account wins (codeHash-no-code repro)", func(t *testing.T) { acc := accounts.Account{Nonce: 7, CodeHash: accounts.EmptyCodeHash} @@ -69,13 +69,9 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { }) } -// The addr→codeHash admission gate vouches for the tx read view's frontier, but -// resolve() may serve the account record from the shared accounts cache, which -// lags a just-committed flush until the apply loop reaches the key. A -// cache-sourced record must therefore never seed the mapping — an apply -// interleaved between the read and the fill would leave a mapping derived from -// the pre-apply record. -func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { +// A generation-bound account-cache hit can safely seed the derived mapping: +// publication revokes the view before changing either cache layer. +func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { t.Parallel() ctx := t.Context() @@ -103,9 +99,9 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { require.NoError(t, seedSD.Commit(ctx, seedTx)) seedSD.Close() - _, ok := sc.View(nil).Get(kv.AccountsDomain, addr[:]) + _, ok := currentStateCacheView(t, sc).Get(kv.AccountsDomain, addr[:]) require.True(t, ok, "the committed record must be served by the accounts cache") - _, ok = sc.View(nil).GetAddrCodeHash(addr[:]) + _, ok = currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) require.False(t, ok, "the post-commit apply must leave the derived mapping empty") roTx, err := db.BeginTemporalRo(ctx) @@ -118,8 +114,9 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - _, ok = sc.View(nil).GetAddrCodeHash(addr[:]) - require.False(t, ok, "a cache-sourced account record must not seed the addr→codeHash mapping") + h, ok := currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) + require.True(t, ok) + require.Equal(t, [32]byte(codeHash), h) } // A record read from the tx's read view (accounts-cache miss) is exactly what @@ -163,7 +160,7 @@ func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - h, ok := sc.View(nil).GetAddrCodeHash(addr[:]) + h, ok := currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) require.True(t, ok, "a view-sourced record must seed the mapping") require.Equal(t, [32]byte(codeHash), h) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 8398bacf452..10be417b7fa 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -34,6 +34,7 @@ import ( "github.com/erigontech/erigon/db/kv/membatchwithdb" "github.com/erigontech/erigon/db/kv/order" "github.com/erigontech/erigon/db/kv/rawdbv3" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/changeset" "github.com/erigontech/erigon/db/state/kvmetrics" "github.com/erigontech/erigon/db/state/statecfg" @@ -81,111 +82,38 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } -// domainVisibleEndMemo caches DomainVisibleEnd per domain for one view at a time. -// Its sequence counter keeps lock-free reads coherent across view changes. -type domainVisibleEndMemo struct { - ends [kv.DomainLen]atomic.Uint64 - mu sync.Mutex - seq atomic.Uint64 - viewID atomic.Uint64 - state atomic.Uint32 -} - -// state packs two bits per domain into one word so a single atomic load -// returns a consistent (loaded, ok) pair: loadedBit says ends[domain] is -// memoized, okBit is the memoized ok answer of DomainVisibleEnd. The array -// size asserts at compile time that both halves fit in uint32. -var _ [32 - 2*int(kv.DomainLen)]struct{} - -func visibleEndBits(domain kv.Domain) (loadedBit, okBit uint32) { - loadedBit = uint32(1) << uint32(domain) - return loadedBit, loadedBit << uint32(kv.DomainLen) -} - -func (m *domainVisibleEndMemo) get(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { - viewID := tx.ViewID() - loadedBit, okBit := visibleEndBits(domain) - seq := m.seq.Load() - if seq&1 == 0 && m.viewID.Load() == viewID { - if state := m.state.Load(); state&loadedBit != 0 { - end := m.ends[domain].Load() - if m.seq.Load() == seq { - return end, state&okBit != 0 - } - } - } - return m.load(tx, domain, viewID, loadedBit, okBit) -} - -func (m *domainVisibleEndMemo) load(tx kv.TemporalTx, domain kv.Domain, viewID uint64, loadedBit, okBit uint32) (uint64, bool) { - m.mu.Lock() - defer m.mu.Unlock() - - cachedViewID := m.viewID.Load() - state := m.state.Load() - if cachedViewID == viewID && state&loadedBit != 0 { - return m.ends[domain].Load(), state&okBit != 0 - } - - m.seq.Add(1) - defer m.seq.Add(1) - - if cachedViewID != viewID { - state = 0 - m.viewID.Store(viewID) - } - end, ok := tx.Debug().DomainVisibleEnd(domain) - m.ends[domain].Store(end) - state |= loadedBit - if ok { - state |= okBit +func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { + if sd.stateCache == nil || tx == nil { + return cache.ReadView{} } - m.state.Store(state) - return end, ok -} - -// reset takes mu so an in-flight load can't re-store pre-reset bits. -func (m *domainVisibleEndMemo) reset() { - m.mu.Lock() - m.seq.Add(1) - m.state.Store(0) - m.seq.Add(1) - m.mu.Unlock() -} - -func (sd *SharedDomains) domainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { - if _, ok := tx.(kv.TemporalRwTx); ok { - return sd.visibleEnds.get(tx, domain) + var stateVersion uint64 + if tx.ViewID() == sd.baseViewID { + if !sd.baseStateVersionKnown || !sd.baseCacheViewEligible { + return cache.ReadView{} + } + stateVersion = sd.baseStateVersion + } else { + var err error + stateVersion, err = rawdb.GetStateVersion(tx) + if err != nil { + return cache.ReadView{} + } + if !stateCacheViewEligible(tx) { + return cache.ReadView{} + } } - return tx.Debug().DomainVisibleEnd(domain) -} - -// sdFrontier adapts one (SharedDomains, tx) pair to cache.Frontier: writable -// txs go through the SD's flush-coherent memo, read-only txs use their own -// tx-local memo. -type sdFrontier struct { - sd *SharedDomains - tx kv.TemporalTx -} - -func (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return f.sd.domainVisibleEnd(f.tx, domain) + return sd.stateCache.View(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. -func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { - if sd.stateCache == nil { - return cache.ReadView{} +func stateCacheViewEligible(tx kv.TemporalTx) bool { + for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + if _, ok := tx.Debug().DomainVisibleEnd(domain); !ok { + return false + } } - return sd.stateCache.View(sdFrontier{sd: sd, tx: tx}) + return true } -// cacheReader is a frontier-less view: admission-gated fills are disabled, -// content-addressed fills still work. Safe on a nil cache. -func (sd *SharedDomains) cacheReader() cache.ReadView { return sd.stateCache.View(nil) } - func IsDomainAheadOfBlocks(ctx context.Context, tx kv.TemporalRwTx, logger log.Logger) bool { doms, err := NewSharedDomains(ctx, tx, logger) if doms != nil { @@ -205,6 +133,11 @@ type SharedDomains struct { logger log.Logger + baseViewID uint64 + baseStateVersion uint64 + baseStateVersionKnown bool + baseCacheViewEligible bool + txNum uint64 currentStep kv.Step // disableInlineTouchKey when true, DomainPut skips the TouchKey call. @@ -228,14 +161,12 @@ type SharedDomains struct { // to read from the FCU's published SD without writing to it. parent *SharedDomains - // stateCache is an optional cache for state data (accounts, storage, code); - // cacheApplier is its authoritative writer handle (commit/unwind only). - stateCache *cache.StateCache - cacheApplier cache.Applier - - // Backing frontiers stay fixed while writes and staged unwinds remain in - // mem; both reach the transaction during flush, which resets the memo. - visibleEnds domainVisibleEndMemo + // Only canonical SharedDomains receive a publisher; speculative readers + // never change its generation or authoritative entries. + stateCache *cache.StateCache + cachePublisher cache.Publisher + cachePublication *cache.Publication + clearStateCache bool // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code // cache, reached via temporalGetter so an addr-keyed reader can serve a @@ -308,10 +239,15 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, } trieCfg := o.trieCfg + stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: tx.Debug().StepSize(), + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: tx.Debug().StepSize(), + baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, + baseStateVersionKnown: stateVersionErr == nil, + baseCacheViewEligible: stateCacheViewEligible(tx), } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -552,20 +488,18 @@ func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, // has no code. Errors propagate normally. // // Callers (ReaderV3.ReadAccountCodeSize, etc.) type-assert on this method -// so the existing kv.TemporalGetter interface is unchanged. txNum is the -// caller's read txNum, used to stamp any cache entry it populates. -func (gt *temporalGetter) GetCodeSize(addr []byte, txNum uint64) (int, bool, error) { - return gt.sd.getCodeSize(gt.tx, gt.view, addr, txNum) +// so the existing kv.TemporalGetter interface is unchanged. +func (gt *temporalGetter) GetCodeSize(addr []byte, _ uint64) (int, bool, error) { + return gt.sd.getCodeSize(gt.tx, gt.view, addr) } // GetCode returns contract code via the content-addressed fast path (see // SD.GetCode): many addresses sharing one bytecode resolve to a single cached // copy with no per-address CodeDomain read. Read-only — callers // (ReaderV3.ReadAccountCode) type-assert this method; setters must not use it -// (they resolve prevVal through GetLatest, which is addr-keyed). txNum is the -// caller's read txNum, used to stamp any cache entry it populates. -func (gt *temporalGetter) GetCode(addr []byte, txNum uint64) ([]byte, bool, error) { - return gt.sd.getCode(gt.tx, gt.view, addr, txNum) +// (they resolve prevVal through GetLatest, which is addr-keyed). +func (gt *temporalGetter) GetCode(addr []byte, _ uint64) ([]byte, bool, error) { + return gt.sd.getCode(gt.tx, gt.view, addr) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -779,11 +713,14 @@ 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. - sd.cacheApplier.Unwind(txNumUnwindTo) + if sd.cachePublisher.Enabled() { + if sd.cachePublication == nil { + sd.cachePublication = sd.cachePublisher.Begin() + } + sd.clearStateCache = true + } else { + sd.stateCache = nil + } } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -840,32 +777,36 @@ func (sd *SharedDomains) GetCommitmentCtx() *commitmentdb.SharedDomainsCommitmen func (sd *SharedDomains) Logger() log.Logger { return sd.logger } -// SetStateCache hands this SD the process-global state cache to manage: -// Commit applies committed updates after a successful DB commit, Unwind -// invalidates them, and the SD's reads populate it through admission-gated -// fills. No-op when USE_STATE_CACHE is off or the cache is nil. -func (sd *SharedDomains) SetStateCache(stateCache *cache.StateCache) { +// SetStateCacheReader attaches the process-global cache without granting +// publication authority. A speculative unwind only detaches this reader. +func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } sd.stateCache = stateCache - sd.cacheApplier = stateCache.Applier() } -// GuardAggregatorForCache forbids visibility lowering on db's aggregator when -// sc is a fill-enabled StateCache: fill admission relies on view frontiers -// never decreasing. This is the one place that binds the invariant — call it -// wherever a fill-enabled cache is wired over a DB. Duck-typed so the storage -// layer need not know the cache type (and vice versa) — but load-bearing, so -// a db that cannot produce its aggregator fails loudly instead of silently -// dropping the guard. A nil or apply-only cache needs no guard. +// SetCanonicalStateCache also grants publication authority to Commit and +// canonical unwind. +func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { + sd.SetStateCacheReader(stateCache) + if sd.stateCache == nil || !sd.baseStateVersionKnown { + return + } + sd.cachePublisher = stateCache.Publisher() + sd.cachePublisher.Initialize(sd.baseStateVersion) +} + +// GuardAggregatorForCache keeps one PlainStateVersion from exposing older +// domain data after a cache view is bound to it. Call it whenever a StateCache +// is wired over a DB, including when reader fills are disabled. func GuardAggregatorForCache(db any, sc *cache.StateCache) { - if sc == nil || !sc.FillsEnabled() { + if sc == nil { return } h, ok := db.(interface{ Agg() any }) if !ok { - panic(fmt.Sprintf("assert: fill-enabled StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) + panic(fmt.Sprintf("assert: StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) } agg := h.Agg() f, ok := agg.(interface{ ForbidVisibilityLowering() }) @@ -956,6 +897,8 @@ func (sd *SharedDomains) Close() { return } + sd.cachePublication.Abort() + sd.cachePublication = nil sd.flushRequestMetrics() sd.SetTxNum(0) sd.ResetPendingUpdates() @@ -971,36 +914,15 @@ func (sd *SharedDomains) Close() { sd.sdCtx = nil } -// SharedDomains owns the cache lifecycle for the account/storage StateCache -// and the commitment BranchCache: population, invalidation and commit-gating -// all happen here, and callers drive state through Flush / Commit / -// GetLatest / DomainPut. The one exception is read-ahead warmup, which fills -// the StateCache directly through its own ReadView, under the same -// admission. - -// Flush writes the in-memory batch into tx without committing. It deliberately -// does NOT touch the caches: plain Flush leaves the commit to the caller (who -// may still roll back), so it must not warm a cache with state that could be -// rolled back. Cache entries are populated elsewhere — by Commit after a -// successful commit, and by reads (GetLatest) — each stamped with a -// conservative upper-bound txNum. It is that txNum stamp, not population -// timing, that keeps the cache correct: an unwind lowers the floor so every -// entry reflecting a now-dead fork is evicted, and mem-first masking means a -// later in-memory write shadows a stale cached read. -// -// An SD with an attached state cache must route every flush through Commit: -// Flush neither applies nor invalidates, so a populated cache would keep -// serving pre-flush values for the flushed keys after the caller's own -// commit — and Commit collects its cache updates only from its own flush, so -// an earlier plain Flush's keys would never be applied. Cache-less callers -// may Flush and commit themselves. +// Flush writes the in-memory batch without committing or publishing cache +// updates. A canonical SharedDomains must use Commit so the database and cache +// become visible in that order. func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { defer mxFlushTook.ObserveDuration(time.Now()) return sd.flushMem(ctx, tx) } func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.FlushOption) error { - defer sd.visibleEnds.reset() if sd.sdCtx.HasPendingUpdate() { if ttx, ok := tx.(kv.TemporalTx); ok { if err := sd.FlushPendingUpdates(ctx, ttx); err != nil { @@ -1024,20 +946,14 @@ type cacheUpdate struct { txN uint64 } -// Commit flushes the in-memory batch into tx, commits tx, and only then applies -// the flushed domain bytes to the in-memory caches — CommitmentDomain to the -// BranchCache, Accounts/Storage/Code to the StateCache. The flush is implicit in -// committing the shared-domain state. Tying cache population to commit success -// makes it impossible by construction for an aggregator-lifetime cache to hold a -// value a failed commit rolled back — so no caller clears a cache or reaches into -// the SD's internal caches after committing. Entries are stamped with the value's -// per-key write txNum (delivered by the callback) as the unwind floor, so -// 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. +// Commit flushes and commits tx before publishing the resulting cache +// generation. tx must be a flush-specific transaction. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) + defer func() { + sd.cachePublication.Abort() + sd.cachePublication = nil + }() runValidate := func() error { for _, v := range validate { @@ -1051,7 +967,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return nil } - if sd.branchCache == nil && sd.stateCache == nil && sd.codeStore == nil { + if sd.branchCache == nil && !sd.cachePublisher.Enabled() && sd.codeStore == nil { if err := sd.flushMem(ctx, tx); err != nil { return err } @@ -1061,11 +977,8 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return tx.Commit() } - // Stash every cache-bound domain tuple during the flush; apply them only - // after the commit succeeds. On a failed commit the stash is discarded, so - // no cache apply ever runs ahead of durable MDBX state. (Reads through - // this SD between flush and a failed commit can still fill flushed - // values; a failed commit is fatal, so they die with the process.) + // Stash cache updates during the flush and publish them only after the + // database commit succeeds. var pending []cacheUpdate stash := func(domain kv.Domain) kv.FlushOption { return kv.WithFlushCallback(domain, func(k []byte, v []byte, step kv.Step, txNum uint64) { @@ -1082,7 +995,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun if sd.branchCache != nil { opts = append(opts, stash(kv.CommitmentDomain)) } - if sd.stateCache != nil { + if sd.cachePublisher.Enabled() { opts = append(opts, stash(kv.AccountsDomain), stash(kv.StorageDomain)) } // CodeDomain flush stashes state-cache updates and collects code for the @@ -1090,12 +1003,12 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // an in-callback tx.Put interleaves with the in-progress domain flush and // corrupts it (reorg/unwind wrong root). var codeStoreWrites [][2][]byte - if sd.stateCache != nil || sd.codeStore != nil { + if sd.cachePublisher.Enabled() || sd.codeStore != nil { opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(k []byte, v []byte, step kv.Step, txNum uint64) { if sd.codeStore != nil && len(v) > 0 { codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(v), append([]byte(nil), v...)}) } - if sd.stateCache != nil { + if sd.cachePublisher.Enabled() { pending = append(pending, cacheUpdate{ domain: kv.CodeDomain, key: append([]byte(nil), k...), @@ -1169,9 +1082,21 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader, factory, provider) } } + var stateVersion uint64 + if sd.cachePublisher.Enabled() { + var err error + stateVersion, err = rawdb.GetStateVersion(tx) + if err != nil { + return fmt.Errorf("read plain state version: %w", err) + } + if sd.cachePublication == nil { + sd.cachePublication = sd.cachePublisher.Begin() + } + } if err := tx.Commit(); err != nil { return err } + stateUpdates := make([]cache.Update, 0, len(pending)) for i := range pending { u := &pending[i] if u.domain == kv.CommitmentDomain { @@ -1182,15 +1107,23 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun } continue } - sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) + stateUpdates = append(stateUpdates, cache.Update{ + Domain: u.domain, + Key: u.key, + Value: u.val, + Step: u.step, + }) } + sd.cachePublication.Publish(stateVersion, stateUpdates, sd.clearStateCache) + sd.cachePublication = nil + sd.clearStateCache = false return nil } // TemporalDomain satisfaction. Collects no read metrics — see // temporalGetter.GetLatest for why there is no process-wide accumulator. func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) { - return sd.getLatestMetered(domain, tx, k, nil, sd.cacheReader()) + return sd.getLatestMetered(domain, tx, k, nil, sd.cacheViewFor(tx)) } // GetLatestContext is the context-aware read for callers that read on behalf of @@ -1199,15 +1132,12 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) // without any shared accumulator or lock. Mirrors temporalGetter.GetLatestContext // for readers that hold the SD directly (e.g. the committer's asOfStateReader). func (sd *SharedDomains) GetLatestContext(ctx context.Context, domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) { - return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheReader()) + return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheViewFor(tx)) } // 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). +// taken without the cache. func servableUnderBound(cStep, maxStep kv.Step) bool { return cStep <= maxStep } @@ -1268,11 +1198,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // stateCache holds committed values shared across domain readers. if sd.stateCache != nil { - v, cTxNum, ok := view.GetWithTxNum(domain, k) - // The cache stamps txNums — divide to get the step the entry reflects. - // A negative uses the last txNum included by its read-view frontier, not - // the step of a deletion. - cStep := kv.Step(cTxNum / sd.StepSize()) + v, cStep, ok := view.GetWithStep(domain, k) if ok && !servableUnderBound(cStep, maxStep) { ok = false } @@ -1344,18 +1270,10 @@ 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. + // View freshness is rechecked while the fill is serialized against cache + // publication. if sd.stateCache != nil && sd.stateCache.Caches(domain) { - readTxNum := (uint64(step)+1)*sd.StepSize() - 1 - fillView := view - if !fillView.CanFill() { - // Frontier-less view from the plain GetLatest wrappers: bind a - // frontier here, on the miss path, where the boxing amortizes - // against the backing read it follows. - fillView = sd.cacheViewFor(tx) - } - fillView.Fill(domain, k, v, readTxNum) + view.Fill(domain, k, v, step) } // Only cache a branch when the read's txN is known: a txN=0 entry would // be treated as immortal by UnwindTo, so skip the Put rather than insert @@ -1388,11 +1306,11 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // // Returns (size, true, nil) on success and (0, false, nil) only when // CodeDomain itself confirms no code. -func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, txNum uint64) (int, bool, error) { - return sd.getCodeSize(tx, sd.cacheReader(), addr, txNum) +func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, _ uint64) (int, bool, error) { + return sd.getCodeSize(tx, sd.cacheViewFor(tx), addr) } -func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) (int, bool, error) { +func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1400,14 +1318,12 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // Fast path: when we can resolve codeHash from the account cache AND // the size is in the size cache, return without loading bytes. if sd.stateCache != nil { - if codeHash := sd.codeHashForAddr(tx, view, addr, txNum); len(codeHash) > 0 { + if codeHash := sd.codeHashForAddr(tx, view, addr); len(codeHash) > 0 { if size, ok := view.GetCodeSizeByHash(codeHash); ok { return size, true, nil } if cv, ok := view.GetCodeByHash(codeHash); ok { - // txNum is a conservative upper bound: >= the live code's write - // txNum, so the size drops on any unwind that drops the code. - view.FillCodeSize(codeHash, len(cv), txNum) + view.FillCodeSize(codeHash, len(cv)) return len(cv), true, nil } } @@ -1440,11 +1356,11 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // it would see the about-to-be-written bytes and the DomainPut diff would elide // the write. Setters therefore resolve prevVal through GetLatest, which is // addr-keyed (domain-faithful); only getters use this codeHash shortcut. -func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, txNum uint64) ([]byte, bool, error) { - return sd.getCode(tx, sd.cacheReader(), addr, txNum) +func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, _ uint64) ([]byte, bool, error) { + return sd.getCode(tx, sd.cacheViewFor(tx), addr) } -func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) ([]byte, bool, error) { +func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1455,7 +1371,7 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []b // a stateObject's stale snapshot) is reorg-safe. var codeHash []byte if sd.stateCache != nil || sd.codeStore != nil { - if codeHash = sd.codeHashForAddr(tx, view, addr, txNum); len(codeHash) > 0 { + if codeHash = sd.codeHashForAddr(tx, view, addr); len(codeHash) > 0 { if sd.stateCache != nil { if cv, ok := view.GetCodeByHash(codeHash); ok { return cv, true, nil @@ -1487,12 +1403,7 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []b // // Returns nil quietly on any error or missing account — the caller falls // through to the addr-keyed file read so correctness is unaffected. -// -// txNum stamps the addr→codeHash cache entry (a conservative upper bound for -// unwind invalidation). It is passed in by the caller — never read from the -// shared sd.txNum, which a parallel exec worker on this read path must not -// touch (the exec loop advances it concurrently). -func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, addr []byte, txNum uint64) []byte { +func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, addr []byte) []byte { if len(addr) == 0 { return nil } @@ -1522,12 +1433,11 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, } // Resolve from the committed layers (stateCache → MDBX/files). mem is - // intentionally not consulted here — it was checked above. fromReadView - // reports whether the record was read from the tx's read view. + // intentionally not consulted here because it was checked above. resolve := func() ([]byte, bool) { if sd.stateCache != nil { if v, ok := view.Get(kv.AccountsDomain, addr); ok { - return accounts.DeserialiseV3CodeHash(v), false + return accounts.DeserialiseV3CodeHash(v), true } } v, _, err := tx.GetLatest(kv.AccountsDomain, addr) @@ -1540,25 +1450,13 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, return accounts.DeserialiseV3CodeHash(v), true } - h, fromReadView := resolve() - if fromReadView && sd.stateCache != nil { + h, resolved := resolve() + if resolved && sd.stateCache != nil { var fixed [32]byte if len(h) == 32 { copy(fixed[:], h) } - // Only a view-sourced record (including the zero-hash sentinel for - // misses) may seed the mapping: the admission gate vouches for the tx's - // frontier, and a cache-sourced record can lag a just-committed flush, - // slipping pre-apply state past the gate. txNum is a conservative upper - // 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) - } - seedView.SeedAddrCodeHash(addr, fixed, txNum) + view.SeedAddrCodeHash(addr, fixed) } return h } diff --git a/db/state/execctx/domain_visible_end_memo_test.go b/db/state/execctx/domain_visible_end_memo_test.go deleted file mode 100644 index 0ad82a7132a..00000000000 --- a/db/state/execctx/domain_visible_end_memo_test.go +++ /dev/null @@ -1,103 +0,0 @@ -// 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 - -import ( - "sync" - "sync/atomic" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/erigontech/erigon/db/kv" -) - -type stubVisibleEndTx struct { - kv.TemporalTx - viewID uint64 -} - -func (tx *stubVisibleEndTx) ViewID() uint64 { return tx.viewID } -func (tx *stubVisibleEndTx) Debug() kv.TemporalDebugTx { return stubVisibleEndDebug{viewID: tx.viewID} } - -type stubVisibleEndDebug struct { - kv.TemporalDebugTx - viewID uint64 -} - -func (d stubVisibleEndDebug) DomainVisibleEnd(kv.Domain) (uint64, bool) { - return d.viewID * 100, true -} - -// Parallel-exec workers share one SharedDomains and one view, so the memo -// must tolerate concurrent gets interleaved with resets, and must re-derive -// after a sequential view rotation. -func TestDomainVisibleEndMemoConcurrent(t *testing.T) { - t.Parallel() - - var memo domainVisibleEndMemo - var wg sync.WaitGroup - for range 8 { - tx := &stubVisibleEndTx{viewID: 7} - wg.Go(func() { - for range 512 { - for d := range kv.DomainLen { - end, ok := memo.get(tx, d) - if !ok || end != 700 { - t.Errorf("domain %v: got (%d, %t)", d, end, ok) - return - } - } - } - }) - } - wg.Go(func() { - for range 512 { - memo.reset() - } - }) - wg.Wait() - - rotated := &stubVisibleEndTx{viewID: 8} - end, ok := memo.get(rotated, kv.AccountsDomain) - require.True(t, ok) - require.Equal(t, uint64(800), end) -} - -func TestDomainVisibleEndMemoConcurrentViews(t *testing.T) { - t.Parallel() - - var memo domainVisibleEndMemo - var mismatches atomic.Uint64 - var wg sync.WaitGroup - for _, viewID := range []uint64{7, 8} { - for range 8 { - tx := &stubVisibleEndTx{viewID: viewID} - wg.Go(func() { - for range 100_000 { - end, ok := memo.get(tx, kv.AccountsDomain) - if !ok || end != viewID*100 { - mismatches.Add(1) - } - } - }) - } - } - wg.Wait() - - require.Zero(t, mismatches.Load(), "memo returned a frontier from another view") -} diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index 868dacd5a98..ec64ac2583b 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -9,7 +9,7 @@ import ( // external test package (which cannot import db/state to build a SharedDomains // internally without an import cycle). func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum uint64) []byte { - return sd.codeHashForAddr(tx, sd.cacheReader(), addr, txNum) + return sd.codeHashForAddr(tx, sd.cacheViewFor(tx), addr) } // SetStateCacheForTest attaches a cache unconditionally, bypassing the @@ -18,5 +18,12 @@ func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui // — 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() + if sd.baseStateVersionKnown { + sd.cachePublisher = sc.Publisher() + sd.cachePublisher.Initialize(sd.baseStateVersion) + } +} + +func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { + sd.stateCache = sc } diff --git a/db/state/execctx/flush_storage_cache_test.go b/db/state/execctx/flush_storage_cache_test.go index fd9feb429a0..7235a2a6297 100644 --- a/db/state/execctx/flush_storage_cache_test.go +++ b/db/state/execctx/flush_storage_cache_test.go @@ -77,14 +77,14 @@ func TestCommit_UpdatesStorageStateCache(t *testing.T) { // First commit: the storage callback must fire and populate the cache. commit(1, val1, nil) - got, ok := sc.View(nil).Get(kv.StorageDomain, key) + got, ok := currentStateCacheView(t, sc).Get(kv.StorageDomain, key) require.True(t, ok, "storage cache must be populated by the commit callback") require.Equal(t, val1, got) // Overwrite in a second tx: the callback must fire again and refresh the // entry — not leave the stale val1 behind. commit(stepSize+1, val2, val1) - got, ok = sc.View(nil).Get(kv.StorageDomain, key) + got, ok = currentStateCacheView(t, sc).Get(kv.StorageDomain, key) require.True(t, ok) require.Equal(t, val2, got, "commit must refresh the storage cache; stale value served on hit was the bug") } diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 8d13ae8face..f2fc5f7fa00 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -49,23 +49,8 @@ func benchSeedDb(b *testing.B) kv.TemporalRwDB { return db } -// BenchmarkDomainVisibleEnd isolates the transaction-local cached frontier -// lookup used by repeated cache fills. -func BenchmarkDomainVisibleEnd(b *testing.B) { - db := benchSeedDb(b) - roTx, err := db.BeginTemporalRo(b.Context()) - require.NoError(b, err) - defer roTx.Rollback() - _, _ = roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) - b.ResetTimer() - for i := 0; i < b.N; i++ { - _, _ = roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) - } -} - // benchColdNegativeReads drives the full cold-negative SD read: the whole -// miss stack, plus — when a cache is wired — the exact-frontier lookup and -// freshness-checked fill. +// miss stack plus the generation-checked fill when a cache is wired. func benchColdNegativeReads(b *testing.B, withCache, writable bool) { db := benchSeedDb(b) ctx := b.Context() @@ -104,7 +89,7 @@ func benchColdNegativeReads(b *testing.B, withCache, writable bool) { func BenchmarkGetLatestColdNegative(b *testing.B) { benchColdNegativeReads(b, true, false) } -// The baseline the stamp+fill cost adds to. +// The baseline for the generation check and fill. func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { benchColdNegativeReads(b, false, false) } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7c198a3034e..cd66f707915 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -18,13 +18,13 @@ package execctx_test import ( "encoding/binary" - "math" "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/kv" @@ -76,93 +76,15 @@ func newSmallStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } -func frontierAt(end uint64) cache.Frontier { - return cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return end, true }) -} - -// 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) { - end := uint64(math.MaxUint64) - if len(v) == 0 { - end = txNum + 1 - } - sc.View(frontierAt(end)).Fill(domain, k, v, txNum) -} - -type visibleEndCountingDebugTx struct { - kv.TemporalDebugTx - calls uint64 - last uint64 -} - -func (tx *visibleEndCountingDebugTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - tx.calls++ - end, ok := tx.TemporalDebugTx.DomainVisibleEnd(domain) - tx.last = end - return end, ok -} - -type visibleEndCountingRwTx struct { - kv.TemporalRwTx - debug *visibleEndCountingDebugTx -} - -func (tx *visibleEndCountingRwTx) Debug() kv.TemporalDebugTx { - return tx.debug -} - -func TestReadFill_MemoizesWritableVisibleEndUntilFlush(t *testing.T) { - t.Parallel() - - const stepSize = uint64(16) - ctx := t.Context() - db := newTestDb(t, stepSize) - - baseTx, err := db.BeginTemporalRw(ctx) - require.NoError(t, err) - defer baseTx.Rollback() - debug := &visibleEndCountingDebugTx{TemporalDebugTx: baseTx.Debug()} - rwTx := &visibleEndCountingRwTx{TemporalRwTx: baseTx, debug: debug} - domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) - require.NoError(t, err) - defer domains.Close() - stateCache := newSmallStateCache() - t.Cleanup(stateCache.Close) - domains.SetStateCacheForTest(stateCache) - - for i := byte(2); i <= 3; i++ { - missing := make([]byte, 20) - missing[0] = i - value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) - require.NoError(t, err) - require.Empty(t, value) - } - require.Equal(t, uint64(1), debug.calls) - initialEnd := debug.last - - written := make([]byte, 20) - written[0] = 4 - domains.SetTxNum(20) - require.NoError(t, domains.DomainPut(kv.AccountsDomain, rwTx, written, encAccount(2), 20, nil)) - require.NoError(t, domains.Flush(ctx, rwTx)) - - missing := make([]byte, 20) - missing[0] = 5 - value, _, err := domains.GetLatest(kv.AccountsDomain, rwTx, missing) - require.NoError(t, err) - require.Empty(t, value) - require.Equal(t, uint64(2), debug.calls) - require.Greater(t, debug.last, initialEnd) +func currentStateCacheView(t *testing.T, stateCache *cache.StateCache) cache.ReadView { + t.Helper() + stateVersion, ok := stateCache.CurrentStateVersion() + require.True(t, ok) + return stateCache.View(stateVersion) } -// During an in-flight unwind the mem overlay bounds reads of an affected key -// by maxStep while MDBX still holds the not-yet-deleted dying row inside that -// bound. A cache hit legitimately below the unwind floor then diverges from -// the maxStep-bounded DB read, and the ASSERT_STATE_CACHE comparison must not -// blame the cache for it. +// During an in-flight unwind the cache is inactive, so the assertion compares +// the bounded database read without observing the old cache generation. func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { // Mutates dbg.AssertStateCache — must not run in parallel with tests that // read it on the SD read path. @@ -171,7 +93,8 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { ctx := t.Context() db := newTestDb(t, stepSize) sc := newSmallStateCache() - key, v1, _, diffs := twoStepRows(t, db, sc) + t.Cleanup(sc.Close) + key, _, v2, diffs := twoStepRows(t, db, sc) roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) @@ -182,10 +105,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { defer sd.Close() sd.SetStateCacheForTest(sc) - 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) + sd.Unwind(10, &diffs) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -196,7 +116,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { v, _, err = sd.GetLatest(kv.AccountsDomain, roTx, key) }, "assert must not fire on a legitimately-bounded cache hit during an in-flight unwind") require.NoError(t, err) - require.Equal(t, v1, v, "the cache serves the restored value") + require.Equal(t, v2, v, "the inactive cache must fall through to the bounded database read") } // Same invariant with the unwound key bound at step 0 — a young chain's whole @@ -241,7 +161,6 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) sd2.SetStateCacheForTest(sc) sd2.Unwind(3, &diffs) - seed(sc, kv.AccountsDomain, key, nil, 2) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -252,14 +171,10 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) v, _, err = sd2.GetLatest(kv.AccountsDomain, roTx, key) }, "assert must not fire when the in-flight unwind bound is at step 0") require.NoError(t, err) - require.Empty(t, v, "the cache serves the correct negative") + require.Equal(t, v1, v, "the inactive cache must fall through to the bounded database read") } -// The read-fill after a fall-through read must not replace a live cache -// entry: it never carries newer information than a post-commit apply, and during an -// in-flight unwind the bounded DB read can even return the not-yet-deleted -// dying row. -func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { +func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -276,25 +191,17 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { require.NoError(t, err) defer sd.Close() sd.SetStateCacheForTest(sc) - sd.Unwind(10, &diffs) - // 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) - v, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) + got, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) require.NoError(t, err) - require.Equal(t, v2, v, "fall-through read serves the maxStep-bounded DB row") + require.Equal(t, v2, got) - got, ok := sc.View(nil).Get(kv.AccountsDomain, key) - require.True(t, ok) - require.Equal(t, v3, got, "read-fill must not clobber the live entry") + _, ok := sc.CurrentStateVersion() + require.False(t, ok, "the cache must stay inactive until the unwind commits") } -// 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) { +func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -302,47 +209,78 @@ func TestReadFill_NegativeUsesLastVisibleTxNum(t *testing.T) { db := newTestDb(t, stepSize) sc := newSmallStateCache() + key := make([]byte, 20) + key[0] = 0xcc + var codeHash common.Hash + codeHash[0] = 0xdd + value := accounts.SerialiseV3(&accounts.Account{ + Nonce: 1, + CodeHash: accounts.InternCodeHash(codeHash), + }) + rwTx, err := db.BeginTemporalRw(ctx) require.NoError(t, err) defer rwTx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(t, err) defer sd.Close() sd.SetStateCacheForTest(sc) - - written := make([]byte, 20) - written[0] = 0x01 - sd.SetTxNum(100) - require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, written, encAccount(7), 100, nil)) + 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() - visibleEnd, ok := roTx.Debug().DomainVisibleEnd(kv.AccountsDomain) - require.True(t, ok) - require.NotZero(t, visibleEnd) sd2, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd2.Close() sd2.SetStateCacheForTest(sc) + sd2.Unwind(10, &diffs) - missing := make([]byte, 20) - missing[0] = 0x02 - v, _, err := sd2.GetLatest(kv.AccountsDomain, roTx, missing) - require.NoError(t, err) - require.Empty(t, v) - _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) - require.True(t, ok, "the negative result must be cached") + got := sd2.CodeHashForAddr(roTx, key, 20) + require.Equal(t, codeHash[:], got) + + _, ok := sc.CurrentStateVersion() + require.False(t, ok, "the cache must stay inactive until the unwind commits") +} + +func TestSpeculativeUnwindDoesNotPublishStateCache(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) + + stateVersion, ok := sc.CurrentStateVersion() + require.True(t, ok) + got, ok := sc.View(stateVersion).Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, v2, got) - sc.Applier().Unwind(visibleEnd) - _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) - require.True(t, ok, "an unwind starting after the read view must preserve the negative") + 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.SetStateCacheReaderForTest(sc) + sd.Unwind(10, &diffs) - sc.Applier().Unwind(visibleEnd - 1) - _, ok = sc.View(nil).Get(kv.AccountsDomain, missing) - require.False(t, ok, "an unwind of the view's last included txNum must invalidate the negative") + currentVersion, ok := sc.CurrentStateVersion() + require.True(t, ok) + require.Equal(t, stateVersion, currentVersion) + got, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, v2, got) } type fakeForbidder struct{ called bool } @@ -357,9 +295,8 @@ type fakeHasBadAgg struct{} func (fakeHasBadAgg) Agg() any { return struct{}{} } -// The guard is load-bearing: for a fill-enabled cache it must either bind the -// invariant or fail loudly — never silently drop it on a DB shape mismatch. -// A nil or apply-only cache needs no guard at all. +// The guard is load-bearing for every StateCache and must fail loudly when the +// DB cannot enforce the visibility invariant. A nil cache needs no guard. func TestGuardAggregatorForCache(t *testing.T) { sc := newSmallStateCache() t.Cleanup(sc.Close) @@ -376,14 +313,12 @@ func TestGuardAggregatorForCache(t *testing.T) { "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the guard") } -// An apply-only cache (STATE_CACHE_FILLS=false) has no fills for a lowered -// frontier to poison, so the guard must not constrain the aggregator. -func TestGuardAggregatorForCache_ApplyOnlySkips(t *testing.T) { +func TestGuardAggregatorForCache_FillsDisabledStillGuards(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") sc := newSmallStateCache() t.Cleanup(sc.Close) f := &fakeForbidder{} execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) - require.False(t, f.called) + require.True(t, f.called) } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 789bea870df..a615d23edfd 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -45,6 +45,148 @@ 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 := currentStateCacheView(t, stateCache).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 TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + rpcTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer rpcTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + events := shards.NewEvents() + events.PublishOverlay(freshDomains) + rpcCache := &execmodule.Cache{} + rpcCache.SetPublishedSD(events.LatestSD) + rpcView, err := rpcCache.View(ctx, rpcTx) + require.NoError(t, err) + + got, err := rpcView.Get(key) + require.NoError(t, err) + require.Equal(t, v2, got, "the old RPC transaction still sees the discarded fork") + + _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, key) + require.False(t, ok, "binding an old RPC transaction after unwind must not refill the discarded fork") + + got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v1, got) +} + +func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) { + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key, v1, v2, diffs := twoStepRows(t, db, stateCache) + + oldTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer oldTx.Rollback() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) + require.NoError(t, err) + unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.Unwind(10, &diffs) + require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) + unwindDomains.Close() + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheForTest(stateCache) + + got, _, err := freshDomains.GetLatest(kv.AccountsDomain, oldTx, key) + require.NoError(t, err) + require.Equal(t, v2, got, "the old transaction still sees the discarded fork") + + _, ok := currentStateCacheView(t, stateCache).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() @@ -111,66 +253,48 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { require.NoError(t, err) require.Equal(t, code, got) - cached, ok := stateCache.View(nil).Get(kv.CodeDomain, contractAddr) + cached, ok := currentStateCacheView(t, stateCache).Get(kv.CodeDomain, contractAddr) require.True(t, ok, "an account-only deletion must not block unrelated code fills") require.Equal(t, code, cached) } -func TestSharedDomainsNegativeCacheEntryUsesLastVisibleTxNum(t *testing.T) { +func TestCanonicalUnwindClearsNegativeCacheEntry(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + _, _, _, diffs := twoStepRows(t, db, stateCache) - presentKey := make([]byte, 20) - presentKey[0] = 0xaa missingKey := make([]byte, 20) missingKey[0] = 0xbb - account := accounts.SerialiseV3(&accounts.Account{ - Nonce: 1, - Balance: *uint256.NewInt(1), - }) - - 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.SetTxNum(10) - require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, presentKey, account, 10, nil)) - require.NoError(t, seedDomains.Commit(ctx, seedTx)) - seedDomains.Close() - - budget := 1 * datasize.MB - stateCache := cache.NewStateCache(budget, budget, budget, budget) - t.Cleanup(stateCache.Close) readTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer readTx.Rollback() - visibleEnd, ok := readTx.Debug().DomainVisibleEnd(kv.AccountsDomain) - require.True(t, ok) - require.NotZero(t, visibleEnd) - readDomains, err := execctx.NewSharedDomains(ctx, readTx, log.New()) require.NoError(t, err) - defer readDomains.Close() readDomains.SetStateCacheForTest(stateCache) got, _, err := readDomains.GetLatest(kv.AccountsDomain, readTx, missingKey) require.NoError(t, err) require.Empty(t, got) + readDomains.Close() - cached, ok := stateCache.View(nil).Get(kv.AccountsDomain, missingKey) + _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, missingKey) require.True(t, ok) - require.Empty(t, cached) - stateCache.Applier().Unwind(visibleEnd) - _, ok = stateCache.View(nil).Get(kv.AccountsDomain, missingKey) - require.True(t, ok, "a negative observed before the unwind floor must remain cached") + 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)) - stateCache.Applier().Unwind(visibleEnd - 1) - _, ok = stateCache.View(nil).Get(kv.AccountsDomain, missingKey) - require.False(t, ok, "a negative observed at the unwind floor must be invalidated") + _, ok = currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, missingKey) + require.False(t, ok, "canonical unwind must clear entries without an unwind callback") } func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain kv.Domain) { @@ -268,11 +392,8 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k } // The account-deletion mirror of TestEmbeddedRPCCacheViewDoesNotResurrectDeletedCode. -// DomainDel(AccountsDomain) cascades a code-domain delete at the SD layer, so the -// commit applies it and the code frontier advances past every pre-deletion view — -// and the cache-level code-fill admission also checks the accounts frontier. This -// pins both layers: losing either must not let a pre-deletion RPC view refill the -// deleted account's code. +// DomainDel(AccountsDomain) cascades a code-domain delete. One publication must +// revoke the old RPC view before applying both deletions. func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() @@ -330,13 +451,13 @@ func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { // The view outlives the overlay teardown on purpose, as above. deleteDomains.Close() - _, ok := stateCache.View(nil).Get(kv.CodeDomain, addr) + _, ok := currentStateCacheView(t, stateCache).Get(kv.CodeDomain, addr) require.False(t, ok, "the account deletion must drop the cached code entry") got, err := rpcView.GetCode(addr) require.NoError(t, err) require.Equal(t, code, got, "the pre-deletion view still reads the code from its own tx") - _, ok = stateCache.View(nil).Get(kv.CodeDomain, addr) + _, ok = currentStateCacheView(t, stateCache).Get(kv.CodeDomain, addr) require.False(t, ok, "a pre-deletion RPC view must not refill the deleted account's code") } diff --git a/execution/cache/cache.go b/execution/cache/cache.go index d30c41a0f4d..ee97999a074 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -14,60 +14,25 @@ // You should have received a copy of the GNU Lesser General Public License // along with Erigon. If not, see . -// Package cache provides the process-global caches of latest committed state. +// Package cache provides the process-global cache of latest committed state. // -// StateCache holds the newest applied value per key for the accounts, storage -// and code domains, so repeated GetLatest reads skip the file-accessor/MDBX -// stack. It is not a snapshot and gives readers no isolation: a hit can be -// 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. -// -// 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. +// StateCache represents one durable PlainStateVersion at a time. Read views +// are bound to that version, and a publication revokes them before changing +// cache contents. Snapshot-isolated caching is handled separately by kvcache. package cache +import "github.com/erigontech/erigon/db/kv" + // Cache is the interface for domain caches. -// Implementations: GenericCache (for Account/Storage), CodeCache (for Code). +// Implementations: DomainCache (for Account/Storage), CodeCache (for Code). type Cache interface { - // Get retrieves data for the given key. - Get(key []byte) ([]byte, bool) - - // GetWithTxNum is Get plus the txNum the cached value reflects, so the - // read path can apply a step bound against an in-flight unwind's maxStep. - GetWithTxNum(key []byte) ([]byte, uint64, bool) - - // Put stores data for the given key, stamped with the txNum the value - // reflects (used for txNum/epoch unwind invalidation). - Put(key []byte, value []byte, txNum uint64) - - // PutIfAbsent is Put except that a live entry for key is left untouched - // (a stale one is replaced) — for fill writers, whose read view may - // already be superseded by an authoritative Put. - PutIfAbsent(key []byte, value []byte, txNum uint64) + Get(key []byte) (value []byte, ok bool) + GetWithStep(key []byte) (value []byte, step kv.Step, ok bool) + Put(key, value []byte, step kv.Step) + PutIfAbsent(key, value []byte, step kv.Step) - // Delete removes the data for the given key. Delete(key []byte) - - // Clear removes all mutable entries from the cache. Clear() - - // Unwind invalidates entries that reflect state above unwindToTxNum on a - // now-dead fork. Diffset-free and lazy: both GenericCache and CodeCache - // bump an epoch and lower a floor, so stale entries (including code and - // size) are evicted on the next read rather than walked eagerly. - Unwind(unwindToTxNum uint64) - - // Close drops the cache's slot in the shared memory envelope. Idempotent. Close() - - // Len returns the number of entries in the cache. Len() int } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 37952ccc450..ef2dce3769d 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -58,6 +58,16 @@ func makeValue(i int) []byte { return []byte{byte(i), byte(i + 1), byte(i + 2)} } +func readyStateCache(t *testing.T, stateVersion uint64) (*StateCache, Publisher) { + t.Helper() + b := 1 * datasize.MB + stateCache := NewStateCache(b, b, b, b) + t.Cleanup(stateCache.Close) + publisher := stateCache.Publisher() + publisher.Initialize(stateVersion) + return stateCache, publisher +} + // ============================================================================= // DomainCache Tests // ============================================================================= @@ -140,7 +150,7 @@ func TestDomainCache_PutEvictsWhenFull_EvictMode(t *testing.T) { // eviction event. Capacity-bytes is unused for the eviction // decision and is only carried for telemetry. c := &DomainCache{ - GenericCache: newGenericCacheEntries[[]byte](1<<20, 2, func(v []byte) int { return len(v) }, ModeEvictLRU), + GenericCache: newGenericCacheEntries[domainEntry](1<<20, 2, func(v domainEntry) int { return len(v.value) }, ModeEvictLRU), } for i := 1; i <= 64; i++ { @@ -443,67 +453,72 @@ func TestStateCache_NewDefaultStateCache(t *testing.T) { } func TestStateCache_GetPut_Account(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, _ := readyStateCache(t, 1) + view := c.View(1) addr := makeAddr(1) value := makeValue(1) // Get non-existent - v, ok := c.get(kv.AccountsDomain, addr) + v, ok := view.Get(kv.AccountsDomain, addr) assert.False(t, ok) assert.Nil(t, v) // Put and Get - c.put(kv.AccountsDomain, addr, value, 0) - v, ok = c.get(kv.AccountsDomain, addr) + view.Fill(kv.AccountsDomain, addr, value, 0) + v, ok = view.Get(kv.AccountsDomain, addr) assert.True(t, ok) assert.Equal(t, value, v) } func TestStateCache_GetPut_Storage(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, _ := readyStateCache(t, 1) + view := c.View(1) key := make([]byte, 52) // addr(20) + slot(32) copy(key, makeAddr(1)) key[51] = 1 value := makeValue(1) - c.put(kv.StorageDomain, key, value, 0) - v, ok := c.get(kv.StorageDomain, key) + view.Fill(kv.StorageDomain, key, value, 0) + v, ok := view.Get(kv.StorageDomain, key) assert.True(t, ok) assert.Equal(t, value, v) } func TestStateCache_GetPut_Code(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)) + c, _ := readyStateCache(t, 1) + view := c.View(1) addr := makeAddr(1) code := makeCode(1) - c.put(kv.CodeDomain, addr, code, 0) - v, ok := c.get(kv.CodeDomain, addr) + view.Fill(kv.CodeDomain, addr, code, 0) + v, ok := view.Get(kv.CodeDomain, addr) assert.True(t, ok) assert.Equal(t, code, v) } func TestStateCache_GetPut_UnsupportedDomain(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, _ := readyStateCache(t, 1) + view := c.View(1) // ReceiptDomain is not supported - c.put(kv.ReceiptDomain, makeAddr(1), makeValue(1), 0) - v, ok := c.get(kv.ReceiptDomain, makeAddr(1)) + view.Fill(kv.ReceiptDomain, makeAddr(1), makeValue(1), 0) + v, ok := view.Get(kv.ReceiptDomain, makeAddr(1)) assert.False(t, ok) assert.Nil(t, v) } func TestStateCache_Delete(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, publisher := readyStateCache(t, 1) addr := makeAddr(1) - c.put(kv.AccountsDomain, addr, makeValue(1), 0) - c.deleteKey(kv.AccountsDomain, addr) + c.View(1).Fill(kv.AccountsDomain, addr, makeValue(1), 0) + publication := publisher.Begin() + publication.Publish(2, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) - _, ok := c.get(kv.AccountsDomain, addr) + _, ok := c.View(2).Get(kv.AccountsDomain, addr) assert.False(t, ok) } @@ -511,53 +526,59 @@ func TestStateCache_Delete(t *testing.T) { // caches deleted keys via Put(key, nil); if Get treats that as "not found", // the caller unnecessarily falls through to the DB on every read. func TestStateCache_PutEmpty_ThenGet_IsCacheHit(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, _ := readyStateCache(t, 1) + view := c.View(1) key := make([]byte, 52) // addr(20) + slot(32) key[0] = 0x1d key[51] = 0xa2 - c.put(kv.StorageDomain, key, nil, 0) + view.Fill(kv.StorageDomain, key, nil, 0) - v, ok := c.get(kv.StorageDomain, key) + v, ok := view.Get(kv.StorageDomain, key) assert.True(t, ok, "Get after Put(nil) must be a cache hit, not a miss") assert.Empty(t, v, "cached value for a deleted key must be empty") } // Same test for []byte{} (zero-length but non-nil). func TestStateCache_PutEmptySlice_ThenGet_IsCacheHit(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, _ := readyStateCache(t, 1) + view := c.View(1) key := make([]byte, 52) key[0] = 0x1d key[51] = 0xa2 - c.put(kv.StorageDomain, key, []byte{}, 0) + view.Fill(kv.StorageDomain, key, []byte{}, 0) - v, ok := c.get(kv.StorageDomain, key) + v, ok := view.Get(kv.StorageDomain, key) assert.True(t, ok, "Get after Put([]byte{}) must be a cache hit") assert.Empty(t, v) } func TestStateCache_Delete_UnsupportedDomain(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + _, publisher := readyStateCache(t, 1) - // Should not panic - c.deleteKey(kv.ReceiptDomain, makeAddr(1)) + require.NotPanics(t, func() { + publication := publisher.Begin() + publication.Publish(2, []Update{{Domain: kv.ReceiptDomain, Key: makeAddr(1)}}, false) + }) } func TestStateCache_Clear(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(100, 100, 100, 100)) + c, publisher := readyStateCache(t, 1) + view := c.View(1) - c.put(kv.AccountsDomain, makeAddr(1), makeValue(1), 0) - c.put(kv.StorageDomain, makeAddr(2), makeValue(2), 0) - c.put(kv.CodeDomain, makeAddr(3), makeCode(3), 0) + view.Fill(kv.AccountsDomain, makeAddr(1), makeValue(1), 0) + view.Fill(kv.StorageDomain, makeAddr(2), makeValue(2), 0) + view.Fill(kv.CodeDomain, makeAddr(3), makeCode(3), 0) - c.clear() + publisher.Clear(2) + view = c.View(2) - _, ok1 := c.get(kv.AccountsDomain, makeAddr(1)) - _, ok2 := c.get(kv.StorageDomain, makeAddr(2)) - _, ok3 := c.get(kv.CodeDomain, makeAddr(3)) + _, ok1 := view.Get(kv.AccountsDomain, makeAddr(1)) + _, ok2 := view.Get(kv.StorageDomain, makeAddr(2)) + _, ok3 := view.Get(kv.CodeDomain, makeAddr(3)) assert.False(t, ok1) assert.False(t, ok2) @@ -634,20 +655,21 @@ func TestCodeCache_ConcurrentAccess(t *testing.T) { // ============================================================================= func TestStateCache_DomainIsolation(t *testing.T) { - c := closeOnCleanup(t, NewStateCache(1*datasize.MB, 1*datasize.MB, 1*datasize.MB, 1*datasize.MB)) + c, _ := readyStateCache(t, 1) + view := c.View(1) addr := makeAddr(1) accountData := []byte("account") storageData := []byte("storage") codeData := []byte{0x60, 0x00, 0x60, 0x00} // valid code - c.put(kv.AccountsDomain, addr, accountData, 0) - c.put(kv.StorageDomain, addr, storageData, 0) - c.put(kv.CodeDomain, addr, codeData, 0) + view.Fill(kv.AccountsDomain, addr, accountData, 0) + view.Fill(kv.StorageDomain, addr, storageData, 0) + view.Fill(kv.CodeDomain, addr, codeData, 0) - v1, ok1 := c.get(kv.AccountsDomain, addr) - v2, ok2 := c.get(kv.StorageDomain, addr) - v3, ok3 := c.get(kv.CodeDomain, addr) + v1, ok1 := view.Get(kv.AccountsDomain, addr) + v2, ok2 := view.Get(kv.StorageDomain, addr) + v3, ok3 := view.Get(kv.CodeDomain, addr) assert.True(t, ok1) assert.True(t, ok2) @@ -658,131 +680,6 @@ func TestStateCache_DomainIsolation(t *testing.T) { assert.True(t, bytes.Equal(v3, codeData)) } -// ============================================================================= -// Block Continuity Tests -// ============================================================================= - -// Fork-validation (engine_newPayload) of a block building on the canonical tip -// must NOT purge the hot cache when that block is subsequently applied -// canonically. Regression for the tip purge_rate bug: fork-validation advancing -// blockHash to the speculative block made the canonical apply mismatch & purge. -// Fork-validation of a block on a DIFFERENT parent (reorg proposal) must still -// purge, since cache-as-of-canonical-tip would serve incoherent reads, but it -// must not advance blockHash (canonical continues cleanly afterward). -// ============================================================================= -// RevertWithDiffset Tests -// ============================================================================= - -// makeDiffKey creates a domain entry key with an 8-byte step suffix, matching -// the format used by DomainEntryDiff (full key = base key + inverted step). -func makeDiffKey(baseKey []byte, step uint64) string { - k := make([]byte, len(baseKey)+8) - copy(k, baseKey) - // Store inverted step in the suffix (same encoding as domain tables). - k[len(k)-8] = byte(^step >> 56) - k[len(k)-7] = byte(^step >> 48) - k[len(k)-6] = byte(^step >> 40) - k[len(k)-5] = byte(^step >> 32) - k[len(k)-4] = byte(^step >> 24) - k[len(k)-3] = byte(^step >> 16) - k[len(k)-2] = byte(^step >> 8) - k[len(k)-1] = byte(^step) - return string(k) -} - -// --- txNum/epoch unwind invalidation (replaces the blockHash/diffset model) --- - -// Entries stamped at/below the unwind point survive (warm hot set kept); entries -// above it from the now-dead epoch are dropped lazily on read. -func TestUnwind_KeepsBelowFloor_EvictsAbove(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - below := makeAddr(1) - above := makeAddr(2) - c.Put(below, makeValue(1), 50) // predates the unwind - c.Put(above, makeValue(2), 150) // written in the unwound range - - c.Unwind(100) // floor=100 (first unwound txNum): keep <100, drop >=100 - - v, ok := c.Get(below) - assert.True(t, ok, "entry below the unwind point must stay warm") - assert.Equal(t, makeValue(1), v) - - _, ok = c.Get(above) - assert.False(t, ok, "entry above the unwind point must be invalidated") - assert.Equal(t, 1, c.Len(), "the stale entry is evicted lazily on its read") -} - -// Pins the unwind floor boundary: unwindToTxNum is the FIRST rolled-back txNum, -// so an entry stamped at exactly that txNum is dead-fork state and must be -// evicted — the drop rule is txNum >= floor, not txNum > floor. -func TestUnwind_EvictsEntryAtFloor(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - atFloor := makeAddr(1) - belowFloor := makeAddr(2) - c.Put(atFloor, makeValue(1), 100) // first txNum of the first unwound block - c.Put(belowFloor, makeValue(2), 99) // last txNum of the surviving block - - c.Unwind(100) // floor=100, epoch->1 - - _, ok := c.Get(atFloor) - assert.False(t, ok, "entry at txNum==floor is on the dead fork and must be evicted") - - v, ok := c.Get(belowFloor) - assert.True(t, ok, "entry at txNum==floor-1 predates the unwind and must stay warm") - assert.Equal(t, makeValue(2), v) -} - -// The reused-txNum case: after an unwind, the live fork re-writes a key at the -// SAME txNum as the dead fork's write. The epoch — not the txNum — distinguishes -// them, so the dead entry reads stale and the re-written one reads valid. -func TestUnwind_ReusedTxNumDisambiguatedByEpoch(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - k := makeAddr(1) - c.Put(k, makeValue(1), 150) // dead fork, epoch 0 - - c.Unwind(100) // epoch -> 1, floor -> 100 - - _, ok := c.Get(k) - assert.False(t, ok, "dead-fork entry (old epoch, above floor) reads stale") - - c.Put(k, makeValue(2), 150) // live fork re-writes at the same txNum, epoch 1 - v, ok := c.Get(k) - assert.True(t, ok, "live-fork entry at the same txNum is valid (current epoch)") - assert.Equal(t, makeValue(2), v) -} - -// A straggler the live fork never re-writes must not resurrect: it stays in a -// dead epoch above the floor and reads stale no matter how far execution -// advances afterwards (there is no rising high-water mark to re-validate it). -func TestUnwind_StragglerNeverResurrects(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - straggler := makeAddr(1) - c.Put(straggler, makeValue(1), 150) // epoch 0 - - c.Unwind(100) // epoch 1 - - // Advance the live fork far past the straggler's txNum (no re-write of it). - for i := 2; i < 50; i++ { - c.Put(makeAddr(i), makeValue(i), uint64(200+i)) - } - _, ok := c.Get(straggler) - assert.False(t, ok, "straggler in a dead epoch must stay stale, never resurrect") -} - -// A second, shallower unwind must not resurrect entries a deeper earlier unwind -// invalidated (floor only moves down). -func TestUnwind_FloorOnlyMovesDown(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - k := makeAddr(1) - c.Put(k, makeValue(1), 70) // epoch 0 - - c.Unwind(50) // floor 50, epoch 1 — k(70>50, epoch0) now stale - c.Unwind(100) // shallower; floor must stay 50, not rise to 100 - - _, ok := c.Get(k) - assert.False(t, ok, "deeper unwind's floor must not be raised by a later shallower one") -} - func TestDomainCache_PutIfAbsent(t *testing.T) { c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.KB, ModeEvictLRU)) addr := makeAddr(1) @@ -802,20 +699,6 @@ func TestDomainCache_PutIfAbsent(t *testing.T) { require.True(t, ok) assert.Equal(t, fresh, v, "PutIfAbsent must not replace a live entry") - // Entry below the unwind floor survives the unwind and still blocks PutIfAbsent. - low := makeAddr(2) - c.Put(low, fresh, 3) - c.Unwind(5) - c.PutIfAbsent(low, stale, 4) - v, ok = c.Get(low) - require.True(t, ok) - assert.Equal(t, fresh, v) - - // Stale entry (at/above the floor, superseded epoch) → replaced. - c.PutIfAbsent(addr, stale, 10) // addr's entry was stamped txNum 20 >= floor 5 - v, ok = c.Get(addr) - require.True(t, ok) - assert.Equal(t, stale, v, "PutIfAbsent must replace a stale entry") } func TestCodeCache_PutIfAbsentKeepsLiveAddrBinding(t *testing.T) { @@ -835,12 +718,6 @@ func TestCodeCache_PutIfAbsentKeepsLiveAddrBinding(t *testing.T) { require.True(t, ok) assert.Equal(t, fresh, v, "PutIfAbsent must not rebind a live addr entry") - // After an unwind marks the binding stale, PutIfAbsent may rebind. - cc.Unwind(5) - cc.PutIfAbsent(addr, stale, 4) - v, ok = cc.Get(addr) - require.True(t, ok) - assert.Equal(t, stale, v) } func TestCodeCache_PutWithCodeHashIfAbsent(t *testing.T) { @@ -890,162 +767,132 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -func TestStateCache_AppliedEndLifecycle(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - require.Zero(t, sc.appliedEnd[kv.AccountsDomain]) - - sc.apply(kv.AccountsDomain, makeAddr(1), makeValue(1), 20) - sc.apply(kv.AccountsDomain, makeAddr(2), makeValue(2), 10) - require.Equal(t, uint64(21), sc.appliedEnd[kv.AccountsDomain]) - require.Zero(t, sc.appliedEnd[kv.StorageDomain]) - - sc.unwind(15) - require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain]) - - sc.clear() - require.Equal(t, uint64(15), sc.appliedEnd[kv.AccountsDomain], - "clear drops entries, not admission history") -} - -func TestStateCache_StaleViewCannotFillAfterDelete(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - - key := makeAddr(1) - stale := makeValue(1) - sc.apply(kv.AccountsDomain, key, stale, 10) - sc.apply(kv.AccountsDomain, key, nil, 20) - _, 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) - _, 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.fillIfFresh(kv.AccountsDomain, key, makeValue(2), 200, 201) - - _, ok := sc.get(kv.AccountsDomain, key) - require.True(t, ok, - "a reader ahead of appliedEnd is the normal commit-then-apply window, not a dead fork") -} - func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - + sc, publisher := readyStateCache(t, 1) key := makeAddr(1) - canonical, fork := makeValue(1), makeValue(2) - sc.apply(kv.AccountsDomain, key, canonical, 40) - sc.apply(kv.AccountsDomain, key, fork, 100) + fork := makeValue(2) + preReorg := sc.View(1) + preReorg.Fill(kv.AccountsDomain, key, fork, 10) - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) - sc.unwind(50) - _, ok := sc.get(kv.AccountsDomain, key) - require.False(t, ok, "the unwind must evict the fork's value") + publication := publisher.Begin() + publication.Publish(2, nil, true) - sc.fillIfFresh(kv.AccountsDomain, key, fork, 100, 101) - _, ok = sc.get(kv.AccountsDomain, key) + preReorg.Fill(kv.AccountsDomain, key, fork, 10) + _, ok := sc.View(2).Get(kv.AccountsDomain, key) require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") } -func TestStateCache_FileEndViewCannotFillAtAppliedTx(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) +func TestStateCache_PublicationIsOneGeneration(t *testing.T) { + sc, publisher := readyStateCache(t, 10) + oldKey, changedKey := makeAddr(1), makeAddr(2) + oldView := sc.View(10) + oldView.Fill(kv.AccountsDomain, oldKey, makeValue(1), 1) + oldView.Fill(kv.AccountsDomain, changedKey, makeValue(2), 2) + + publication := publisher.Begin() + _, ok := oldView.Get(kv.AccountsDomain, oldKey) + require.False(t, ok, "the old generation must be unavailable during publication") + + publication.Publish(11, []Update{{ + Domain: kv.AccountsDomain, + Key: changedKey, + Value: makeValue(3), + Step: 3, + }}, false) + + _, ok = oldView.Get(kv.AccountsDomain, oldKey) + require.False(t, ok, "publication must revoke old read views") + freshView := sc.View(11) + got, ok := freshView.Get(kv.AccountsDomain, oldKey) + require.True(t, ok) + require.Equal(t, makeValue(1), got, "forward publication keeps unchanged entries") + got, step, ok := freshView.GetWithStep(kv.AccountsDomain, changedKey) + require.True(t, ok) + require.Equal(t, makeValue(3), got) + require.Equal(t, kv.Step(3), step) +} +func TestStateCache_AbortRestoresGeneration(t *testing.T) { + sc, publisher := readyStateCache(t, 10) key := makeAddr(1) - stale := makeValue(1) - sc.apply(kv.AccountsDomain, key, nil, 100) + view := sc.View(10) + view.Fill(kv.AccountsDomain, key, makeValue(1), 1) - sc.fillIfFresh(kv.AccountsDomain, key, stale, 99, 100) - _, ok := sc.get(kv.AccountsDomain, key) - require.False(t, ok, "a [0,100) view does not contain the applied tx 100") + publication := publisher.Begin() + _, ok := view.Get(kv.AccountsDomain, key) + require.False(t, ok) + publication.Abort() - fresh := makeValue(2) - sc.fillIfFresh(kv.AccountsDomain, key, fresh, 100, 101) - got, ok := sc.get(kv.AccountsDomain, key) + got, ok := view.Get(kv.AccountsDomain, key) require.True(t, ok) - require.Equal(t, fresh, got) + require.Equal(t, makeValue(1), got) } -func TestStateCache_ApplyDeleteAtomicWithFill(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) +func TestStateCache_UnpublishedVersionCannotReadOrFill(t *testing.T) { + sc, _ := readyStateCache(t, 10) + key := makeAddr(1) + unpublished := sc.View(11) + unpublished.Fill(kv.AccountsDomain, key, makeValue(1), 1) + _, ok := sc.View(10).Get(kv.AccountsDomain, key) + require.False(t, ok) +} - progressKey := makeAddr(1) - key := makeAddr(2) +func TestStateCache_PublishDeleteAtomicWithOldFill(t *testing.T) { + sc, publisher := readyStateCache(t, 1) + key := makeAddr(1) value := makeValue(1) - for round := range 20000 { - appliedTxNum := uint64(round*2 + 1) - visibleEnd := appliedTxNum + 1 - sc.apply(kv.AccountsDomain, progressKey, value, appliedTxNum) - + for stateVersion := uint64(1); stateVersion < 2000; stateVersion++ { + oldView := sc.View(stateVersion) var wg sync.WaitGroup - wg.Add(2) - go func() { - defer wg.Done() - sc.apply(kv.AccountsDomain, key, nil, visibleEnd) - }() - go func() { - defer wg.Done() - sc.fillIfFresh(kv.AccountsDomain, key, value, appliedTxNum, visibleEnd) - }() + wg.Go(func() { + oldView.Fill(kv.AccountsDomain, key, value, 1) + }) + wg.Go(func() { + publication := publisher.Begin() + publication.Publish(stateVersion+1, []Update{{ + Domain: kv.AccountsDomain, + Key: key, + Step: 2, + }}, false) + }) wg.Wait() - _, ok := sc.get(kv.AccountsDomain, key) - require.False(t, ok, "round %d: stale fill survived the authoritative delete", round) + _, ok := sc.View(stateVersion+1).Get(kv.AccountsDomain, key) + require.False(t, ok, "state version %d: stale fill survived publication", stateVersion) } } func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - + sc, publisher := readyStateCache(t, 1) addr := makeAddr(1) var h [32]byte h[0] = 0xaa - sc.seedAddrCodeHash(addr, h, 10, 0) - _, ok := sc.getAddrCodeHash(addr) + sc.View(1).SeedAddrCodeHash(addr, h) + _, ok := sc.View(1).GetAddrCodeHash(addr) require.True(t, ok) - sc.apply(kv.CodeDomain, addr, nil, 20) - _, ok = sc.getAddrCodeHash(addr) + publication := publisher.Begin() + publication.Publish(2, []Update{{Domain: kv.CodeDomain, Key: addr}}, false) + _, ok = sc.View(2).GetAddrCodeHash(addr) require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - + sc, publisher := readyStateCache(t, 1) addr := makeAddr(1) code := makeCode(1) - - sc.apply(kv.CodeDomain, addr, code, 10) - _, ok := sc.get(kv.CodeDomain, addr) + publication := publisher.Begin() + publication.Publish(2, []Update{{ + Domain: kv.CodeDomain, + Key: addr, + Value: code, + }}, false) + _, ok := sc.View(2).Get(kv.CodeDomain, addr) require.True(t, ok) - sc.apply(kv.AccountsDomain, addr, nil, 20) - _, ok = sc.get(kv.CodeDomain, addr) + publication = publisher.Begin() + publication.Publish(3, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + _, ok = sc.View(3).Get(kv.CodeDomain, addr) require.False(t, ok, "an account deletion must drop the addr→code binding") } @@ -1069,27 +916,6 @@ func TestDomainCache_DeleteAtomicWithPut_NoSizeDrift(t *testing.T) { } } -// The lazy stale-drop inside GetWithTxNum removes entries; an unstriped -// Remove racing put's read-modify-write double-subtracts the displaced -// entry's size. Exactly one live entry remains after every round, so drift -// shows as a size mismatch. -func TestDomainCache_StaleDropAtomicWithPut_NoSizeDrift(t *testing.T) { - c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) - addr := makeAddr(1) - v1 := []byte("value-one") - v2 := []byte("value-two") - wantSize := int64(len(addr) + len(v1) + 24) - for round := range 20000 { - c.Put(addr, v1, 10) - c.Unwind(5) - var wg sync.WaitGroup - wg.Go(func() { c.Put(addr, v2, 20) }) - wg.Go(func() { c.Get(addr) }) - wg.Wait() - require.Equal(t, wantSize, c.SizeBytes(), "round %d: size accounting drifted", round) - } -} - // A Clear racing a put must not leave phantom bytes: unless Clear excludes // writers via the put stripes, a put that loaded the retiring generation // lands its entry where no reader sees it and adds the entry's size after @@ -1112,103 +938,73 @@ 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. +// STATE_CACHE_FILLS=false turns off reader fills. Publications keep working. func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") - b := 1 * datasize.MB - c := NewStateCache(b, b, b, b) - defer c.Close() + c, publisher := readyStateCache(t, 1) key := make([]byte, 20) key[0] = 0xaa - view := c.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 100, true })) + view := c.View(1) view.Fill(kv.AccountsDomain, key, []byte("value"), 10) - _, ok := c.View(nil).Get(kv.AccountsDomain, key) + _, ok := view.Get(kv.AccountsDomain, key) require.False(t, ok, "fills must be disabled") - view.SeedAddrCodeHash(key, [32]byte{1}, 10) - _, ok = c.View(nil).GetAddrCodeHash(key) + view.SeedAddrCodeHash(key, [32]byte{1}) + _, ok = view.GetAddrCodeHash(key) require.False(t, ok, "mapping seeds must be disabled") codeHash := crypto.Keccak256([]byte{0xaa, 1, 2, 3}) - view.FillCodeSize(codeHash, 4, 10) - _, ok = c.View(nil).GetCodeSizeByHash(codeHash) + view.FillCodeSize(codeHash, 4) + _, ok = view.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) - got, ok := c.View(nil).Get(kv.AccountsDomain, key) - require.True(t, ok, "applies must keep working") + publication := publisher.Begin() + publication.Publish(2, []Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: []byte("applied"), + }}, false) + got, ok := c.View(2).Get(kv.AccountsDomain, key) + require.True(t, ok, "publications must keep working") require.Equal(t, []byte("applied"), got) } -// Clearing entries does not rewind canonical state, so the admission frontier -// must survive Clear: a still-live older ReadView must not refill pre-apply -// data into the emptied cache. func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - + sc, publisher := readyStateCache(t, 1) key := makeAddr(1) - oldView := sc.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 11, true })) - - sc.Applier().Apply(kv.AccountsDomain, key, nil, 20) // canonical delete - sc.Applier().Clear() + oldView := sc.View(1) + publisher.Clear(1) 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(1) + _, ok := freshView.Get(kv.AccountsDomain, key) + require.False(t, ok, "a retired view must not refill after Clear") - freshView := sc.View(FrontierFunc(func(kv.Domain) (uint64, bool) { return 21, true })) 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") + got, ok := freshView.Get(kv.AccountsDomain, key) + require.True(t, ok, "the new generation must fill after Clear") require.Equal(t, []byte("current"), got) } -// An addr-keyed code entry derives from the account: an account deletion drops -// it without advancing the code frontier, so code-fill admission must check the -// accounts frontier too — otherwise a pre-deletion view refills the dead code. func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { - b := 1 * datasize.MB - c := NewStateCache(b, b, b, b) - t.Cleanup(c.Close) + c, publisher := readyStateCache(t, 1) 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) + publication := publisher.Begin() + publication.Publish(2, []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) + stale := c.View(2) - 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) + publication = publisher.Begin() + publication.Publish(3, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + stale.Fill(kv.CodeDomain, addr, code, 1) + fresh := c.View(3) + _, ok := fresh.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.Fill(kv.CodeDomain, other, otherCode, 100) - _, ok = c.View(nil).Get(kv.CodeDomain, other) + fresh.Fill(kv.CodeDomain, other, otherCode, 1) + _, ok = fresh.Get(kv.CodeDomain, other) require.True(t, ok, "unrelated code fills from a current view must stay admitted") } - -// An apply-only cache (STATE_CACHE_FILLS=false) has no fill for a lowered -// frontier to poison; wire-up code keys the aggregator forbid on this. -func TestApplyOnlyCacheReportsFillsDisabled(t *testing.T) { - t.Setenv("STATE_CACHE_FILLS", "false") - b := 1 * datasize.MB - c := NewStateCache(b, b, b, b) - t.Cleanup(c.Close) - require.False(t, c.FillsEnabled()) - - t.Setenv("STATE_CACHE_FILLS", "true") - c2 := NewStateCache(b, b, b, b) - t.Cleanup(c2.Close) - require.True(t, c2.FillsEnabled()) -} diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index 45f7297113e..12e2a905dbb 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -27,7 +27,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/maphash" - "github.com/erigontech/erigon/execution/cache/coherence" + "github.com/erigontech/erigon/db/kv" ) // hash32 copies a codeHash slice into a fixed [32]byte for storage/compare. @@ -53,32 +53,29 @@ const ( // persistent (MDBX-backed) cold tier backstops entries the tighter cap evicts. avgCodeEntryBytes = 12 * 1024 // codeSizeEntryBytes is the resident cost of one size-layer slot (freelru - // element holding size/keyHash/txNum/epoch), used to map the size-layer entry + // element holding size/keyHash), used to map the size-layer entry // ceiling to an envelope byte budget. - codeSizeEntryBytes = 64 + codeSizeEntryBytes = 48 ) -type versionedAddressID struct { +type addressCodeID struct { addrID uint64 // codeHash is the addr's keccak codeHash, used to reject a hashToCode // maphash collision (a different contract whose code collides on the // 64-bit maphash key). Zero when populated without a known codeHash. codeHash [32]byte - txNum uint64 - epoch uint32 + step kv.Step } type addrCodeHashEntry struct { - hash [32]byte - txNum uint64 - epoch uint32 + hash [32]byte } // Per-entry residency of the two addr-keyed LRUs: a 20-byte key plus the -// value struct (which carries codeHash/txNum/epoch, not just an 8-byte ID). +// value struct (which carries codeHash, not just an 8-byte ID). // Used both to size the LRUs against the byte budget and to report residency. const ( - addrToHashEntryBytes = 20 + int(unsafe.Sizeof(versionedAddressID{})) + addrToHashEntryBytes = 20 + int(unsafe.Sizeof(addressCodeID{})) addrToCodeHashEntryBytes = 20 + int(unsafe.Sizeof(addrCodeHashEntry{})) addrEntryBytes = addrToHashEntryBytes + addrToCodeHashEntryBytes ) @@ -90,16 +87,12 @@ type codeEntry struct { // compare keyHash against the requested key to reject a collision serving // a different contract's code. keyHash [32]byte - txNum uint64 - epoch uint32 } type codeSizeEntry struct { size int // keyHash — see codeEntry.keyHash. keyHash [32]byte - txNum uint64 - epoch uint32 } // CodeCache is a multi-level concurrent cache for contract code, keyed by the @@ -112,19 +105,13 @@ type codeSizeEntry struct { // Configured byte budgets are translated into LRU entry caps; full layers // evict their coldest entries. // -// Every cached layer carries (txNum, epoch) so an unwind invalidates code the -// same way as the account/storage/branch caches: a contract's code -// value never changes for a given hash, but its EXISTENCE does — code deployed -// on a fork that is later unwound must no longer be discoverable, even by -// codeHash. So the content-addressed layers are NOT treated as immutable; they -// honor the same (txNum, epoch) lazy-drop as the addr layers. This can re-fetch -// code shared across deployments when one is unwound (a multiplicity cost), but -// keeps stale code out of the cache. +// StateCache publishes these layers as one generation. A canonical unwind +// clears the complete CodeCache before the new generation becomes visible. type CodeCache struct { // addrToHash maps a 20-byte Ethereum address to the maphash-derived // codeID for the code at that address. An LRU so fresh-address workloads // evict oldest entries and warm up the working set. - addrToHash *lru.Cache[common.Address, versionedAddressID] + addrToHash *lru.Cache[common.Address, addressCodeID] hashToCode *growLRU[codeEntry] // codeID(maphash(code)) → code, jump-grow + LRU-evicting codeSize atomic.Int64 // resident bytes (stat; hard bound is the entry cap) @@ -151,13 +138,6 @@ type CodeCache struct { codeSizeEntries atomic.Int64 codeSizeCapEntries int64 - // Unwind coherence shared by every layer (content-addressed ones included): - // an entry is valid iff written in the current epoch OR its txNum is below - // the unwind floor. Serving reads snapshot coherence before loading a layer, - // while Clear purges every layer before Reset; together those orderings keep - // the lifted floor from revalidating a retired entry. - coh coherence.Gen - // addrBindMu serializes addr→code binding writers so PutIfAbsent's // check+bind is atomic w.r.t. a concurrent authoritative rebind. addrBindMu sync.Mutex @@ -166,8 +146,8 @@ type CodeCache struct { // insertion per key hash: freelru has no LoadOrStore, so without this two // concurrent Puts of the same cold code both miss the check and both add to // the byte counter while only one entry survives, drifting the stat upward. - // Every epoch-stamped writer also uses a stripe, so Clear can fence all - // publications while distinct keys still write in parallel. + // Clear uses every stripe to fence publications while distinct keys still + // write in parallel. putStripes [256]sync.Mutex // Stats counters (atomic for concurrent access) @@ -190,38 +170,27 @@ type CodeCache struct { // putContentLocked is the shared insert path for the content-addressed code layers // (hashToCode, codeHashToCode, codeSizeByCodeHash). Each is a freelru.ShardedLRU -// of per-key-immutable entries carrying a (txNum, epoch) stamp: a live entry is -// kept (its bytes/size are invariant for a given key), a stale one is removed -// (its OnEvict decrements counter) so the fresh entry can replace it, and once -// the entry-count cap is reached freelru.Add evicts the coldest entry (whose -// OnEvict decrements counter) rather than freezing. counter tracks resident -// bytes as a stat; the hard bound is the LRU's entry cap. stamp/valCost are -// non-capturing so passing them allocates nothing on the put path. The caller -// holds the key's put stripe. +// of per-key-immutable entries. Existing content is retained; once the cap is +// reached freelru.Add evicts the coldest entry. counter tracks resident bytes +// as a stat; the hard bound is the LRU entry cap. The caller holds the key +// stripe. func putContentLocked[T any]( lru *growLRU[T], h uint64, newEntry T, - stamp func(T) (uint64, uint32), valCost func(T) int64, - coh *coherence.Gen, counter *atomic.Int64, keyCost int64, ) { - if existing, ok := lru.Get(h); ok { - if txNum, epoch := stamp(existing); !coh.IsStale(txNum, epoch) { - return - } - lru.Remove(h) // stale — OnEvict decrements counter for the removed entry + if _, ok := lru.Get(h); ok { + return } counter.Add(keyCost + valCost(newEntry)) lru.Add(h, newEntry) // evicts the coldest entry when full; its OnEvict decrements counter } -func codeEntryStamp(e codeEntry) (uint64, uint32) { return e.txNum, e.epoch } -func codeEntryCodeLen(e codeEntry) int64 { return int64(len(e.code)) } -func codeSizeEntryStamp(e codeSizeEntry) (uint64, uint32) { return e.txNum, e.epoch } -func zeroCost[T any](T) int64 { return 0 } +func codeEntryCodeLen(e codeEntry) int64 { return int64(len(e.code)) } +func zeroCost[T any](T) int64 { return 0 } // NewCodeCache creates a new CodeCache with the specified byte capacities. func NewCodeCache(codeCapacityBytes, addrCapacityBytes datasize.ByteSize) *CodeCache { @@ -229,7 +198,7 @@ func NewCodeCache(codeCapacityBytes, addrCapacityBytes datasize.ByteSize) *CodeC // addrEntryBytes (both entries combined). Divide in ByteSize space so the // budget isn't truncated to int before the division. addrEntries := max(int(addrCapacityBytes/datasize.ByteSize(addrEntryBytes)), 1024) - addrLRU, err := lru.New[common.Address, versionedAddressID](addrEntries) + addrLRU, err := lru.New[common.Address, addressCodeID](addrEntries) if err != nil { panic(err) } @@ -262,29 +231,18 @@ func NewDefaultCodeCache() *CodeCache { return NewCodeCache(DefaultCodeCacheBytes, DefaultAddrCacheBytes) } -// Get retrieves contract code for the given address, implementing the Cache interface. func (c *CodeCache) Get(addr []byte) ([]byte, bool) { - v, _, ok := c.GetWithTxNum(addr) - return v, ok + value, _, ok := c.GetWithStep(addr) + return value, ok } -// GetWithTxNum is Get plus the txNum of the addr→code binding, so the read -// path can apply the same step bound the DomainCache/BranchCache reads do. -func (c *CodeCache) GetWithTxNum(addr []byte) ([]byte, uint64, bool) { +func (c *CodeCache) GetWithStep(addr []byte) ([]byte, kv.Step, bool) { k := common.BytesToAddress(addr) - // Snapshot before either layer read so an entry captured while Clear purges - // the layers retains the pre-Clear unwind floor used to judge it. - coh := c.coh.Snapshot() vID, ok := c.addrToHash.Get(k) if !ok { c.addrMisses.Add(1) return nil, 0, false } - if coh.IsStale(vID.txNum, vID.epoch) { - c.addrToHash.Remove(k) - c.addrMisses.Add(1) - return nil, 0, false - } c.addrHits.Add(1) ce, ok := c.hashToCode.Get(vID.addrID) @@ -292,11 +250,6 @@ func (c *CodeCache) GetWithTxNum(addr []byte) ([]byte, uint64, bool) { c.codeMisses.Add(1) return nil, 0, false } - if coh.IsStale(ce.txNum, ce.epoch) { - c.hashToCode.Remove(vID.addrID) // OnEvict decrements codeSize - c.codeMisses.Add(1) - return nil, 0, false - } // Reject a 64-bit maphash collision: the stored code belongs to a different // contract than addr's. Verifiable only when the addr entry carries a // codeHash (always for PutWithCodeHash-populated code; the EVM read path). @@ -305,30 +258,26 @@ func (c *CodeCache) GetWithTxNum(addr []byte) ([]byte, uint64, bool) { return nil, 0, false } c.codeHits.Add(1) - // The addr→code binding is what an unwind re-binds; vID.txNum bounds it. - return ce.code, vID.txNum, true + return ce.code, vID.step, true } -// Put stores contract code for the given address, implementing the Cache interface. -// Uses fast maphash to compute the code identifier. addrToHash is an LRU; the -// hashToCode bytes are content-addressed (immutable for a hash) but carry a -// (txNum, epoch) stamp so an unwound deployment's code stops being discoverable. -func (c *CodeCache) Put(addr []byte, code []byte, txNum uint64) { +// Put stores contract code for the given address. +func (c *CodeCache) Put(addr []byte, code []byte, step kv.Step) { // No codeHash in hand here, so the entry is left unverified against maphash // collisions. The EVM read path uses PutWithCodeHash, which records it. - c.putCode(addr, code, [32]byte{}, txNum, true) + c.putCode(addr, code, [32]byte{}, step, true) } // PutIfAbsent implements Cache.PutIfAbsent for the addr→code binding; the // content-addressed layers skip live entries regardless. -func (c *CodeCache) PutIfAbsent(addr []byte, code []byte, txNum uint64) { - c.putCode(addr, code, [32]byte{}, txNum, false) +func (c *CodeCache) PutIfAbsent(addr []byte, code []byte, step kv.Step) { + c.putCode(addr, code, [32]byte{}, step, false) } // putCode populates the addr→codeID and codeID→code layers. keyHash is the // code's keccak codeHash when known (zero otherwise), stored so Get can reject // a 64-bit maphash collision. -func (c *CodeCache) putCode(addr []byte, code []byte, keyHash [32]byte, txNum uint64, overwriteAddr bool) { +func (c *CodeCache) putCode(addr []byte, code []byte, keyHash [32]byte, step kv.Step, overwriteAddr bool) { if len(code) == 0 { return } @@ -337,26 +286,25 @@ func (c *CodeCache) putCode(addr []byte, code []byte, keyHash [32]byte, txNum ui stripe.Lock() defer stripe.Unlock() - c.putCodeLocked(addr, code, keyHash, codeID, txNum, c.coh.Epoch(), overwriteAddr) + c.putCodeLocked(addr, code, keyHash, codeID, step, overwriteAddr) } -func (c *CodeCache) putCodeLocked(addr []byte, code []byte, keyHash [32]byte, codeID, txNum uint64, ep uint32, overwriteAddr bool) { +func (c *CodeCache) putCodeLocked(addr []byte, code []byte, keyHash [32]byte, codeID uint64, step kv.Step, overwriteAddr bool) { a := common.BytesToAddress(addr) c.addrBindMu.Lock() bindAddr := overwriteAddr if !bindAddr { - e, ok := c.addrToHash.Get(a) - bindAddr = !ok || c.coh.IsStale(e.txNum, e.epoch) + _, ok := c.addrToHash.Get(a) + bindAddr = !ok } if bindAddr { - c.addrToHash.Add(a, versionedAddressID{addrID: codeID, codeHash: keyHash, txNum: txNum, epoch: ep}) + c.addrToHash.Add(a, addressCodeID{addrID: codeID, codeHash: keyHash, step: step}) } c.addrBindMu.Unlock() - entry := codeEntry{code: code, keyHash: keyHash, txNum: txNum, epoch: ep} + entry := codeEntry{code: code, keyHash: keyHash} // freelru keyed by the codeID (maphash of code) directly; 8-byte key cost. - putContentLocked(c.hashToCode, codeID, entry, codeEntryStamp, codeEntryCodeLen, - &c.coh, &c.codeSize, 8) + putContentLocked(c.hashToCode, codeID, entry, codeEntryCodeLen, &c.codeSize, 8) } // GetAddrCodeHash returns the Ethereum codeHash for addr if cached. Lets @@ -365,32 +313,24 @@ func (c *CodeCache) putCodeLocked(addr []byte, code []byte, keyHash [32]byte, co // replace coldest entries. func (c *CodeCache) GetAddrCodeHash(addr []byte) ([32]byte, bool) { k := common.BytesToAddress(addr) - coh := c.coh.Snapshot() e, ok := c.addrToCodeHash.Get(k) if !ok { return [32]byte{}, false } - if coh.IsStale(e.txNum, e.epoch) { - c.addrToCodeHash.Remove(k) - return [32]byte{}, false - } return e.hash, true } -// PutAddrCodeHash records a committed-state addr → codeHash mapping. An -// existing live mapping remains authoritative until DeleteAddrCodeHash -// invalidates it; an unwind-stale mapping can be replaced. txNum stamps the -// mapping for unwind invalidation. -func (c *CodeCache) PutAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { +// PutAddrCodeHash records an addr → codeHash mapping if none is cached. +func (c *CodeCache) PutAddrCodeHash(addr []byte, h [32]byte) { a := common.BytesToAddress(addr) stripe := &c.putStripes[a[len(a)-1]] stripe.Lock() defer stripe.Unlock() - if e, ok := c.addrToCodeHash.Get(a); ok && !c.coh.IsStale(e.txNum, e.epoch) { + if _, ok := c.addrToCodeHash.Get(a); ok { return } - c.addrToCodeHash.Add(a, addrCodeHashEntry{hash: h, txNum: txNum, epoch: c.coh.Epoch()}) + c.addrToCodeHash.Add(a, addrCodeHashEntry{hash: h}) } // DeleteAddrCodeHash removes the mapping when its account record is invalidated. @@ -407,7 +347,6 @@ func (c *CodeCache) DeleteAddrCodeHash(addr []byte) { // single codeHashToCode entry after the first population. func (c *CodeCache) GetByCodeHash(codeHash []byte) ([]byte, bool) { h := maphash.Hash(codeHash) - coh := c.coh.Snapshot() ce, ok := c.codeHashToCode.Get(h) if !ok || len(ce.code) == 0 { c.codeHashMisses.Add(1) @@ -419,11 +358,6 @@ func (c *CodeCache) GetByCodeHash(codeHash []byte) ([]byte, bool) { c.codeHashMisses.Add(1) return nil, false } - if coh.IsStale(ce.txNum, ce.epoch) { - c.codeHashToCode.Remove(h) // OnEvict decrements codeHashCodeSize - c.codeHashMisses.Add(1) - return nil, false - } c.codeHashHits.Add(1) return ce.code, true } @@ -436,14 +370,14 @@ func (c *CodeCache) GetByCodeHash(codeHash []byte) ([]byte, bool) { // // addr may be empty to populate only codeHashToCode (e.g. when populating from a // codehash-only path that hasn't seen the addr yet). -func (c *CodeCache) PutWithCodeHash(addr []byte, code []byte, codeHash []byte, txNum uint64) { - c.putWithCodeHash(addr, code, codeHash, txNum, true) +func (c *CodeCache) PutWithCodeHash(addr []byte, code []byte, codeHash []byte, step kv.Step) { + c.putWithCodeHash(addr, code, codeHash, step, true) } // PutWithCodeHashIfAbsent is PutWithCodeHash with if-absent binding semantics // (see Cache.PutIfAbsent). -func (c *CodeCache) PutWithCodeHashIfAbsent(addr []byte, code []byte, codeHash []byte, txNum uint64) { - c.putWithCodeHash(addr, code, codeHash, txNum, false) +func (c *CodeCache) PutWithCodeHashIfAbsent(addr []byte, code []byte, codeHash []byte, step kv.Step) { + c.putWithCodeHash(addr, code, codeHash, step, false) } func (c *CodeCache) lockPutStripes(a, b uint8) { @@ -478,7 +412,7 @@ func (c *CodeCache) unlockAllPutStripes() { } } -func (c *CodeCache) putWithCodeHash(addr []byte, code []byte, codeHash []byte, txNum uint64, overwriteAddr bool) { +func (c *CodeCache) putWithCodeHash(addr []byte, code []byte, codeHash []byte, step kv.Step, overwriteAddr bool) { if len(code) == 0 || len(codeHash) == 0 { return } @@ -490,21 +424,20 @@ func (c *CodeCache) putWithCodeHash(addr []byte, code []byte, codeHash []byte, t c.lockPutStripes(uint8(codeID), uint8(hcc)) defer c.unlockPutStripes(uint8(codeID), uint8(hcc)) - ep := c.coh.Epoch() kh := hash32(codeHash) if len(addr) > 0 { - c.putCodeLocked(addr, code, kh, codeID, txNum, ep, overwriteAddr) + c.putCodeLocked(addr, code, kh, codeID, step, overwriteAddr) } // Populate the size-only layer alongside the bytes layer — every time // we touch the bytes we can answer a future EXTCODESIZE for free. - c.putCodeSizeByCodeHashLocked(codeHash, len(code), hcc, txNum, ep) + c.putCodeSizeByCodeHashLocked(codeHash, len(code), hcc) - entry := codeEntry{code: code, keyHash: kh, txNum: txNum, epoch: ep} + entry := codeEntry{code: code, keyHash: kh} // freelru keyed by maphash(codeHash); 32-byte key cost. - putContentLocked(c.codeHashToCode, hcc, entry, codeEntryStamp, codeEntryCodeLen, - &c.coh, &c.codeHashCodeSize, int64(len(codeHash))) + putContentLocked(c.codeHashToCode, hcc, entry, codeEntryCodeLen, + &c.codeHashCodeSize, int64(len(codeHash))) } // GetCodeSizeByCodeHash retrieves the size (in bytes) of a contract by its @@ -515,7 +448,6 @@ func (c *CodeCache) putWithCodeHash(addr []byte, code []byte, codeHash []byte, t // the file-accessor + decompression stack for the full bytes. func (c *CodeCache) GetCodeSizeByCodeHash(codeHash []byte) (int, bool) { h := maphash.Hash(codeHash) - coh := c.coh.Snapshot() e, ok := c.codeSizeByCodeHash.Get(h) if !ok { c.codeSizeMisses.Add(1) @@ -526,18 +458,12 @@ func (c *CodeCache) GetCodeSizeByCodeHash(codeHash []byte) (int, bool) { c.codeSizeMisses.Add(1) return 0, false } - if coh.IsStale(e.txNum, e.epoch) { - c.codeSizeByCodeHash.Remove(h) // OnEvict decrements codeSizeEntries - c.codeSizeMisses.Add(1) - return 0, false - } c.codeSizeHits.Add(1) return e.size, true } -// PutCodeSizeByCodeHash stores the size of code keyed by its Ethereum -// codeHash. txNum stamps the entry for unwind invalidation. -func (c *CodeCache) PutCodeSizeByCodeHash(codeHash []byte, size int, txNum uint64) { +// PutCodeSizeByCodeHash stores the size of code keyed by its Ethereum codeHash. +func (c *CodeCache) PutCodeSizeByCodeHash(codeHash []byte, size int) { if len(codeHash) == 0 || size < 0 { return } @@ -546,15 +472,14 @@ func (c *CodeCache) PutCodeSizeByCodeHash(codeHash []byte, size int, txNum uint6 stripe.Lock() defer stripe.Unlock() - c.putCodeSizeByCodeHashLocked(codeHash, size, hcs, txNum, c.coh.Epoch()) + c.putCodeSizeByCodeHashLocked(codeHash, size, hcs) } -func (c *CodeCache) putCodeSizeByCodeHashLocked(codeHash []byte, size int, hcs, txNum uint64, ep uint32) { +func (c *CodeCache) putCodeSizeByCodeHashLocked(codeHash []byte, size int, hcs uint64) { kh := hash32(codeHash) - entry := codeSizeEntry{size: size, keyHash: kh, txNum: txNum, epoch: ep} + entry := codeSizeEntry{size: size, keyHash: kh} // Entry-counted layer: each entry costs 1 against the entry cap. - putContentLocked(c.codeSizeByCodeHash, hcs, entry, codeSizeEntryStamp, zeroCost, - &c.coh, &c.codeSizeEntries, 1) + putContentLocked(c.codeSizeByCodeHash, hcs, entry, zeroCost, &c.codeSizeEntries, 1) } // Delete removes the address → code mapping for addr. @@ -564,10 +489,7 @@ func (c *CodeCache) Delete(addr []byte) { c.addrBindMu.Unlock() } -// Clear removes every layer, resets accounting, and starts a new coherence -// generation. It holds every writer stripe through the purges and reset, so a -// publication cannot cross generations. Reset runs after all purges, so a -// reader cannot pair a retired entry with the lifted unwind floor. +// Clear removes every layer and resets accounting. func (c *CodeCache) Clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() @@ -580,7 +502,6 @@ func (c *CodeCache) Clear() { c.codeSize.Store(0) c.codeHashCodeSize.Store(0) c.codeSizeEntries.Store(0) - c.coh.Reset() } // Close returns the content layers' envelope reservations. Idempotent. @@ -592,16 +513,6 @@ func (c *CodeCache) Close() { } } -// Unwind invalidates entries reflecting dead-fork state. Code deployed on the -// rolled-back fork must stop being discoverable — even by codeHash — because -// although a hash → bytes value is invariant, the code's EXISTENCE is not. -// O(1) and scan-free; every layer's entries at/above the floor from a superseded -// epoch drop lazily on their next Get (re-fetching code shared with a still-live -// deployment, an accepted multiplicity cost). See coherence.Gen.Unwind. -func (c *CodeCache) Unwind(unwindToTxNum uint64) { - c.coh.Unwind(unwindToTxNum) -} - // Len returns the number of entries in the address cache. func (c *CodeCache) Len() int { return c.addrToHash.Len() diff --git a/execution/cache/code_cache_codehash_test.go b/execution/cache/code_cache_codehash_test.go index 6572b99ab26..af98bf0460a 100644 --- a/execution/cache/code_cache_codehash_test.go +++ b/execution/cache/code_cache_codehash_test.go @@ -39,23 +39,23 @@ func TestCodeCache_PutAddrCodeHashKeepsLiveEntry(t *testing.T) { freshHash := [32]byte{1} staleHash := [32]byte{2} - c.PutAddrCodeHash(addr, freshHash, 20) - c.PutAddrCodeHash(addr, staleHash, 10) + c.PutAddrCodeHash(addr, freshHash) + c.PutAddrCodeHash(addr, staleHash) got, ok := c.GetAddrCodeHash(addr) require.True(t, ok) require.Equal(t, freshHash, got) } -func TestCodeCache_PutAddrCodeHashReplacesStaleEntry(t *testing.T) { +func TestCodeCache_PutAddrCodeHashReplacesEntryAfterClear(t *testing.T) { c := closeOnCleanup(t, NewCodeCache(1*datasize.MB, 1*datasize.MB)) addr := makeAddr(1) oldHash := [32]byte{1} newHash := [32]byte{2} - c.PutAddrCodeHash(addr, oldHash, 100) - c.Unwind(50) - c.PutAddrCodeHash(addr, newHash, 100) + c.PutAddrCodeHash(addr, oldHash) + c.Clear() + c.PutAddrCodeHash(addr, newHash) got, ok := c.GetAddrCodeHash(addr) require.True(t, ok) @@ -162,7 +162,7 @@ func TestCodeCache_CodeSize_DirectPutAndGet(t *testing.T) { codeHash := makeCodeHash(0xff) // Direct Put without going through the bytes layer. - c.PutCodeSizeByCodeHash(codeHash, 4096, 0) + c.PutCodeSizeByCodeHash(codeHash, 4096) size, ok := c.GetCodeSizeByCodeHash(codeHash) require.True(t, ok) @@ -171,8 +171,8 @@ func TestCodeCache_CodeSize_DirectPutAndGet(t *testing.T) { func TestCodeCache_CodeSize_EmptyHashOrNegativeIsNoOp(t *testing.T) { c := closeOnCleanup(t, NewCodeCache(1*datasize.MB, 1*datasize.MB)) - c.PutCodeSizeByCodeHash(nil, 100, 0) - c.PutCodeSizeByCodeHash(makeCodeHash(1), -1, 0) + c.PutCodeSizeByCodeHash(nil, 100) + c.PutCodeSizeByCodeHash(makeCodeHash(1), -1) _, ok := c.GetCodeSizeByCodeHash(makeCodeHash(1)) assert.False(t, ok) } @@ -244,79 +244,50 @@ func BenchmarkCodeCache_GetByCodeHash_ManyAddrs_OneCode(b *testing.B) { } } -// TestCodeCache_Unwind_DropsUnwoundCodeEverywhere verifies the (txNum, epoch) -// model: code deployed on a fork that is later -// unwound must stop being discoverable on EVERY layer — addr→code, the -// content-addressed codeHash→code, and the size layer — not just the addr -// layer. The code's value is invariant for a hash, but its existence is not. -func TestCodeCache_Unwind_DropsUnwoundCodeEverywhere(t *testing.T) { +func TestCodeCache_ClearDropsCodeEverywhere(t *testing.T) { c := closeOnCleanup(t, NewCodeCache(64*datasize.MB, 16*datasize.MB)) addr := makeAddr(1) code := bytes.Repeat([]byte{0x60}, 64) codeHash := makeCodeHash(0x11) - // Deploy at txNum=100. c.PutWithCodeHash(addr, code, codeHash, 100) - // All layers hit before unwind. + // All layers hit before the clear. got, ok := c.Get(addr) require.True(t, ok) require.Equal(t, code, got) _, ok = c.GetByCodeHash(codeHash) - require.True(t, ok, "codeHash lookup must hit before unwind") + require.True(t, ok, "codeHash lookup must hit before the clear") sz, ok := c.GetCodeSizeByCodeHash(codeHash) require.True(t, ok) require.Equal(t, len(code), sz) - // Unwind to txNum=50 — the deploy at 100 is rolled back. - c.Unwind(50) + c.Clear() _, ok = c.Get(addr) require.False(t, ok, "addr→code must drop") _, ok = c.GetByCodeHash(codeHash) - require.False(t, ok, "unwound code must NOT be discoverable by codeHash") + require.False(t, ok, "cleared code must not be discoverable by codeHash") _, ok = c.GetCodeSizeByCodeHash(codeHash) - require.False(t, ok, "size of unwound code must drop too") + require.False(t, ok, "cleared code size must drop too") } -// TestCodeCache_Unwind_BelowFloorSurvives verifies code deployed below the -// unwind floor (still live after the unwind) stays warm on all layers. -func TestCodeCache_Unwind_BelowFloorSurvives(t *testing.T) { - c := closeOnCleanup(t, NewCodeCache(64*datasize.MB, 16*datasize.MB)) - - addr := makeAddr(2) - code := bytes.Repeat([]byte{0x61}, 32) - codeHash := makeCodeHash(0x22) - c.PutWithCodeHash(addr, code, codeHash, 40) - - c.Unwind(50) // floor=50; the deploy at 40 predates it - - got, ok := c.Get(addr) - require.True(t, ok, "below-floor addr→code must survive") - require.Equal(t, code, got) - _, ok = c.GetByCodeHash(codeHash) - require.True(t, ok, "below-floor codeHash→code must survive") -} - -// TestCodeCache_Unwind_RedeployRevives verifies that re-deploying the same code -// on the live fork (current epoch) after an unwind makes it discoverable again, -// even though a stale entry at the same txNum was left behind. -func TestCodeCache_Unwind_RedeployRevives(t *testing.T) { +func TestCodeCache_ClearThenRedeploy(t *testing.T) { c := closeOnCleanup(t, NewCodeCache(64*datasize.MB, 16*datasize.MB)) addr := makeAddr(3) code := bytes.Repeat([]byte{0x62}, 48) codeHash := makeCodeHash(0x33) - c.PutWithCodeHash(addr, code, codeHash, 100) // old fork - c.Unwind(50) - c.PutWithCodeHash(addr, code, codeHash, 100) // re-executed on live fork, new epoch + c.PutWithCodeHash(addr, code, codeHash, 100) + c.Clear() + c.PutWithCodeHash(addr, code, codeHash, 100) got, ok := c.Get(addr) - require.True(t, ok, "re-deployed addr→code must be live") + require.True(t, ok, "reinserted addr→code must be live") require.Equal(t, code, got) gotH, ok := c.GetByCodeHash(codeHash) - require.True(t, ok, "re-deployed codeHash→code must be live") + require.True(t, ok, "reinserted codeHash→code must be live") require.Equal(t, code, gotH) } diff --git a/execution/cache/code_cache_concurrency_test.go b/execution/cache/code_cache_concurrency_test.go index 88a9c3d3fa1..2271721303e 100644 --- a/execution/cache/code_cache_concurrency_test.go +++ b/execution/cache/code_cache_concurrency_test.go @@ -25,7 +25,6 @@ import ( "github.com/c2h5oh/datasize" "github.com/stretchr/testify/require" - "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/maphash" ) @@ -80,7 +79,7 @@ func TestCodeCache_ByteCheckRejectsForeignKeyHash(t *testing.T) { foreign := make([]byte, 32) copy(foreign, realHash) foreign[0] ^= 0xff // different 32-byte key - cc.codeHashToCode.Add(maphash.Hash(foreign), codeEntry{code: code, keyHash: hash32(realHash), txNum: 1, epoch: cc.coh.Epoch()}) + cc.codeHashToCode.Add(maphash.Hash(foreign), codeEntry{code: code, keyHash: hash32(realHash)}) // The stored entry's keyHash is realHash, not foreign — Get must reject it. _, ok = cc.GetByCodeHash(foreign) @@ -136,27 +135,6 @@ func TestCodeCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -func TestCodeCache_ClearRacingPut_EpochAlias(t *testing.T) { - cc := closeOnCleanup(t, NewCodeCache(64*datasize.MB, 16*datasize.MB)) - cc.Unwind(300) - - addr := make([]byte, 20) - addr[0] = 0xef - code := []byte("dead-fork-code") - codeID := maphash.Hash(code) - preClearEpoch := cc.coh.Epoch() - - cc.Clear() - // Model a writer that sampled the epoch before Clear and published after - // the relevant layers were purged. - cc.addrToHash.Add(common.BytesToAddress(addr), versionedAddressID{addrID: codeID, txNum: 200, epoch: preClearEpoch}) - cc.hashToCode.Add(codeID, codeEntry{code: code, txNum: 200, epoch: preClearEpoch}) - cc.Unwind(150) - - _, ok := cc.Get(addr) - require.False(t, ok, "pre-Clear epoch must not alias the live epoch after a later unwind") -} - func TestCodeCache_ClearFencesStartedPut(t *testing.T) { // Limit Go execution to one logical processor. Each runtime.Gosched call // yields to the queued goroutine, which runs until it reaches the blocked lock. @@ -164,10 +142,9 @@ func TestCodeCache_ClearFencesStartedPut(t *testing.T) { defer runtime.GOMAXPROCS(previousProcs) cc := closeOnCleanup(t, NewCodeCache(64*datasize.MB, 16*datasize.MB)) - cc.Unwind(300) addr := []byte{0xef} - code := []byte("dead-fork-code") + code := []byte("contract-code") cc.addrBindMu.Lock() var wg sync.WaitGroup diff --git a/execution/cache/generic_cache.go b/execution/cache/generic_cache.go index 926c664c30e..1d3ec3ca92d 100644 --- a/execution/cache/generic_cache.go +++ b/execution/cache/generic_cache.go @@ -30,7 +30,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/maphash" "github.com/erigontech/erigon/common/math" - "github.com/erigontech/erigon/execution/cache/coherence" + "github.com/erigontech/erigon/db/kv" ) // putStripeCount sizes the same-key write-serialization stripes; power of two @@ -51,11 +51,9 @@ const avgBytesPerEntry = 256 // the OnEvict callback can update currentSize without re-running // sizeFunc. type entry[T any] struct { - key []byte - val T - size int - txNum uint64 // commit/read txNum the cached value reflects (upper bound) - epoch uint32 // unwind generation the entry was written in + key []byte + val T + size int } // GenericCache is a sharded, LRU-evicting bounded cache for key-value @@ -100,23 +98,16 @@ type GenericCache[T any] struct { enveloped bool closed atomic.Bool - // coh is the shared (epoch, floor) unwind-coherence primitive: an entry is - // valid iff written in the current epoch OR its txNum is below the unwind - // floor. See execution/cache/coherence. - coh coherence.Gen - // putStripes serialize same-key writers so PutIfAbsent's check+insert is // atomic w.r.t. a concurrent Put (freelru offers no conditional insert). putStripes [putStripeCount]sync.Mutex - hits atomic.Uint64 - misses atomic.Uint64 - inserts atomic.Uint64 - evictions atomic.Uint64 // capacity evictions only, counted from Add's evicted return (see newShards) - dropped atomic.Uint64 - staleEvicted atomic.Uint64 // stale entries detected on read after an unwind; dropped unless a racing put revived them - - sizeFunc func(T) int + hits atomic.Uint64 + misses atomic.Uint64 + inserts atomic.Uint64 + evictions atomic.Uint64 // capacity evictions only, counted from Add's evicted return (see newShards) + dropped atomic.Uint64 + sizeFunc func(T) int } func u64identity(k uint64) uint32 { return uint32(k) } @@ -271,30 +262,42 @@ func (c *GenericCache[T]) maybeGrow() { "alloc", fenceStart.Sub(start), "fenced", time.Since(fenceStart)) } -// DomainCache wraps GenericCache[[]byte] to implement the Cache interface. +type domainEntry struct { + value []byte + step kv.Step +} + +// DomainCache stores the value and source step required by GetLatest. type DomainCache struct { - *GenericCache[[]byte] + *GenericCache[domainEntry] } // NewDomainCacheMode creates a new domain cache with the given mode. func NewDomainCacheMode(capacityBytes datasize.ByteSize, mode Mode) *DomainCache { return &DomainCache{ - GenericCache: NewGenericCache(capacityBytes, func(v []byte) int { return len(v) }, mode), + GenericCache: NewGenericCache(capacityBytes, func(v domainEntry) int { return len(v.value) }, mode), } } -// Get retrieves data for the given key, implementing the Cache interface. func (c *DomainCache) Get(key []byte) ([]byte, bool) { + value, _, ok := c.GetWithStep(key) + return value, ok +} + +func (c *DomainCache) GetWithStep(key []byte) ([]byte, kv.Step, bool) { entry, ok := c.GenericCache.Get(key) if !ok { - return nil, false + return nil, 0, false } - return entry, true + return entry.value, entry.step, true } -// Put stores data for the given key, implementing the Cache interface. -func (c *DomainCache) Put(key []byte, value []byte, txNum uint64) { - c.GenericCache.Put(key, value, txNum) +func (c *DomainCache) Put(key, value []byte, step kv.Step) { + c.GenericCache.Put(key, domainEntry{value: value, step: step}) +} + +func (c *DomainCache) PutIfAbsent(key, value []byte, step kv.Step) { + c.GenericCache.PutIfAbsent(key, domainEntry{value: value, step: step}) } // Delete removes the data for the given key, delegating to GenericCache. @@ -304,64 +307,33 @@ func (c *DomainCache) Delete(key []byte) { // Get retrieves data for the given key. func (c *GenericCache[T]) Get(key []byte) (T, bool) { - v, _, ok := c.GetWithTxNum(key) - return v, ok -} - -// GetWithTxNum is Get plus the txNum the cached value reflects, so callers can -// apply a step bound (cStep = txNum/stepSize) against an in-flight unwind's -// maxStep — the same coherence the BranchCache read applies for commitment. -func (c *GenericCache[T]) GetWithTxNum(key []byte) (T, uint64, bool) { h := maphash.Hash(key) - // Snapshot coherence before loading the generation. Clear publishes the - // replacement generation before lifting the unwind floor, so an entry - // captured from the retiring generation is always judged by coherence that - // still carries its unwind. A replacement-generation entry judged by an old - // snapshot can only cause a safe miss because dropStale rechecks the current - // generation before removing it. - coh := c.coh.Snapshot() lru := c.data.Load() e, ok := lru.Get(h) if !ok || !bytes.Equal(e.key, key) { c.misses.Add(1) var zero T - return zero, 0, false - } - // Lazy unwind invalidation: an entry from a superseded epoch whose txNum is - // at or above the unwind floor reflects dead-fork state — drop it and miss so - // the read falls through to the reverted domain and repopulates. The floor is - // the first unwound txNum (Min(UnwindPoint+1), the first txNum of the first - // rolled-back block), so an entry stamped exactly at the floor belongs to a - // dead block — e.g. an EIP-4788 beacon-root write in the block-begin system - // tx — and must be dropped; >= not > (the surviving block's last txNum is - // floor-1, so this never drops a live entry). - if coh.IsStale(e.txNum, e.epoch) { - c.dropStale(h, key) - c.staleEvicted.Add(1) - c.misses.Add(1) - var zero T - return zero, 0, false + return zero, false } c.hits.Add(1) - return e.val, e.txNum, true + return e.val, true } // Put stores data for the given key. In ModeEvictLRU the underlying // sharded LRU evicts cold entries when its entry-count cap is reached. // In ModeNoOp inserts that would overflow the byte budget are dropped // (and counted via the dropped metric). -func (c *GenericCache[T]) Put(key []byte, value T, txNum uint64) { - c.put(key, value, txNum, true) +func (c *GenericCache[T]) Put(key []byte, value T) { + c.put(key, value, true) } -// PutIfAbsent implements Cache.PutIfAbsent (live entry kept, stale one -// replaced). -func (c *GenericCache[T]) PutIfAbsent(key []byte, value T, txNum uint64) { - c.put(key, value, txNum, false) +// PutIfAbsent leaves an existing entry untouched. +func (c *GenericCache[T]) PutIfAbsent(key []byte, value T) { + c.put(key, value, false) } -func (c *GenericCache[T]) put(key []byte, value T, txNum uint64, overwrite bool) { - if c.putStriped(key, value, txNum, overwrite) { +func (c *GenericCache[T]) put(key []byte, value T, overwrite bool) { + if c.putStriped(key, value, overwrite) { // Grow outside the stripe — maybeGrow takes every stripe. c.maybeGrow() } @@ -371,7 +343,7 @@ func (c *GenericCache[T]) put(key []byte, value T, txNum uint64, overwrite bool) // insert landed in a full LRU with ceiling headroom, i.e. the caller should // grow. Detection stays on the insert path — Len locks every shard, too costly // per warm update. -func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrite bool) bool { +func (c *GenericCache[T]) putStriped(key []byte, value T, overwrite bool) bool { h := maphash.Hash(key) valBytes := c.sizeFunc(value) newSize := len(key) + valBytes + 24 @@ -380,10 +352,6 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrit mu.Lock() defer mu.Unlock() - // Sample the epoch under the stripe. Clear holds every stripe across the - // generation swap and coherence reset, so the stamp cannot belong to a - // different generation from the one where the entry lands. - ep := c.coh.Epoch() lru := c.data.Load() existing, hasExisting := lru.Get(h) @@ -391,7 +359,7 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrit // delta would be wrong). Reuse the stored key buffer to avoid an extra // allocation; the freshly-decoded value replaces the old one. if hasExisting && bytes.Equal(existing.key, key) { - if !overwrite && !c.coh.IsStale(existing.txNum, existing.epoch) { + if !overwrite { return false } // Reserve the new size before the removal: the byte counter must never @@ -400,7 +368,7 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrit // worst a new key is dropped, which is within "drop new keys when full". c.currentSize.Add(int64(newSize)) lru.Remove(h) - if lru.Add(h, entry[T]{key: existing.key, val: value, size: newSize, txNum: txNum, epoch: ep}) { + if lru.Add(h, entry[T]{key: existing.key, val: value, size: newSize}) { c.evictions.Add(1) } return false @@ -442,7 +410,7 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrit lru.Remove(h) } keyCopy := bytes.Clone(key) - if lru.Add(h, entry[T]{key: keyCopy, val: value, size: newSize, txNum: txNum, epoch: ep}) { + if lru.Add(h, entry[T]{key: keyCopy, val: value, size: newSize}) { c.evictions.Add(1) } c.inserts.Add(1) @@ -463,25 +431,7 @@ func (c *GenericCache[T]) Delete(key []byte) { } } -// dropStale removes key's entry under its put stripe: the re-check keeps an -// entry a concurrent put revived, and the stripe keeps the removal out of -// generation swaps. -func (c *GenericCache[T]) dropStale(h uint64, key []byte) { - mu := &c.putStripes[h&(putStripeCount-1)] - mu.Lock() - defer mu.Unlock() - lru := c.data.Load() - if e, ok := lru.Get(h); ok && bytes.Equal(e.key, key) && c.coh.IsStale(e.txNum, e.epoch) { - lru.Remove(h) - } -} - -// Clear removes all entries and restores the starting capacity. It starts an -// empty coherence generation by advancing the epoch and lifting the unwind -// floor, so subsequent puts are not constrained by an unwind that belongs to -// the retired data. The accounting reset, data swap, and coherence reset run -// with every put stripe held, so a racing writer cannot split those -// publications. +// Clear removes all entries and restores the starting capacity. func (c *GenericCache[T]) Clear() { // Shrink back to the start size and return the grown budget to the envelope, // keeping the cache adaptive across fork-validation/reset (it regrows on @@ -501,11 +451,6 @@ func (c *GenericCache[T]) Clear() { c.shardCount = shards c.curCap.Store(c.startCap) c.data.Store(next) - // Reset coherence only after publishing the empty generation. Paired with - // GetWithTxNum's snapshot-before-load ordering, this ensures an entry from - // the retiring generation is judged by pre-Reset coherence that still - // carries the unwind. - c.coh.Reset() for i := range c.putStripes { c.putStripes[i].Unlock() } @@ -523,14 +468,6 @@ func (c *GenericCache[T]) Close() { } } -// Unwind invalidates entries that reflect dead-fork state. unwindToTxNum is the -// first rolled-back txNum (Min(UnwindPoint+1)); every entry at or above it is on -// the dead fork. O(1) and scan-free; stale entries drop lazily on their next -// read. See coherence.Gen.Unwind. -func (c *GenericCache[T]) Unwind(unwindToTxNum uint64) { - c.coh.Unwind(unwindToTxNum) -} - // Len returns the number of entries in the cache. func (c *GenericCache[T]) Len() int { return c.data.Load().Len() @@ -553,7 +490,6 @@ func (c *GenericCache[T]) PrintStatsAndReset(name string) { inserts := c.inserts.Swap(0) evictions := c.evictions.Swap(0) dropped := c.dropped.Swap(0) - staleEvicted := c.staleEvicted.Swap(0) total := hits + misses var hitRate float64 if total > 0 { @@ -565,7 +501,6 @@ func (c *GenericCache[T]) PrintStatsAndReset(name string) { "mode", c.mode.String(), "hits", hits, "misses", misses, "hit_rate", hitRate, "inserts", inserts, "evictions", evictions, "dropped", dropped, - "stale_evicted", staleEvicted, "epoch", c.coh.Epoch(), "entries", c.data.Load().Len(), "size_mb", sizeBytes/(1024*1024), "capacity_mb", int64(c.capacityB/datasize.MB), "usage_pct", usagePct, ) diff --git a/execution/cache/generic_cache_concurrency_test.go b/execution/cache/generic_cache_concurrency_test.go index a4bdc74a1ea..4ad8bbb3597 100644 --- a/execution/cache/generic_cache_concurrency_test.go +++ b/execution/cache/generic_cache_concurrency_test.go @@ -21,7 +21,6 @@ import ( "sync" "sync/atomic" "testing" - "time" "github.com/c2h5oh/datasize" "github.com/stretchr/testify/require" @@ -48,7 +47,7 @@ func TestGenericCache_ConcurrentPutAcrossGrow(t *testing.T) { key := make([]byte, 8) for i := range perWorker { binary.BigEndian.PutUint64(key, uint64(base*perWorker+i)) - c.Put(key, []byte{byte(i)}, uint64(i)) + c.Put(key, []byte{byte(i)}) c.Get(key) } }) @@ -71,7 +70,7 @@ func TestGenericCache_PutNotLostAcrossGrow(t *testing.T) { for round := range 50 { c := NewGenericCache[[]byte](64*datasize.MB, func(v []byte) int { return len(v) }, ModeEvictLRU) hot := []byte("hot-key") - c.Put(hot, value(0), 1) + c.Put(hot, value(0)) stop := make(chan struct{}) var regressed atomic.Bool @@ -83,7 +82,7 @@ func TestGenericCache_PutNotLostAcrossGrow(t *testing.T) { return default: } - c.Put(hot, value(n), n) + c.Put(hot, value(n)) if v, ok := c.Get(hot); ok { if got := binary.BigEndian.Uint64(v); got < n { regressed.Store(true) @@ -116,7 +115,7 @@ func TestGenericCache_PutNotLostAcrossGrow(t *testing.T) { key := make([]byte, 8) for i := range 3 * genericCacheStartCapacity { binary.BigEndian.PutUint64(key, uint64(1+i)) - c.Put(key, []byte{1}, 1) + c.Put(key, []byte{1}) } close(stop) @@ -146,7 +145,7 @@ func TestGenericCache_PutIfAbsentDefersAcrossGrow(t *testing.T) { key := make([]byte, 8) for i := range 256 { binary.BigEndian.PutUint64(key, uint64(1+i)) - c.Put(key, []byte{1}, 1) + c.Put(key, []byte{1}) } before := c.data.Load() c.curCap.Store(uint32(c.Len())) @@ -164,7 +163,7 @@ func TestGenericCache_PutIfAbsentDefersAcrossGrow(t *testing.T) { k := make([]byte, 9) k[0] = 0xfe binary.BigEndian.PutUint64(k[1:], uint64(j)) - c.Put(k, fresh, 10) + c.Put(k, fresh) candidates = append(candidates, k) if c.data.Load() != before { return @@ -173,12 +172,12 @@ func TestGenericCache_PutIfAbsentDefersAcrossGrow(t *testing.T) { }) binary.BigEndian.PutUint64(key, 0) - c.Put(key, []byte{1}, 1) // insert at the lowered cap → triggers the grow + c.Put(key, []byte{1}) // insert at the lowered cap → triggers the grow close(stop) wg.Wait() for _, k := range candidates { - c.PutIfAbsent(k, stale, 5) + c.PutIfAbsent(k, stale) } for i, k := range candidates { v, ok := c.Get(k) @@ -209,11 +208,11 @@ func TestGenericCache_ModeNoOpAdmissionAtomicWithUpdate(t *testing.T) { } v := []byte("valuevalu") // entry size 20+9+24 = 53: the budget fits exactly one entry c := newGenericCacheEntries(datasize.ByteSize(53), 8, func(v []byte) int { return len(v) }, ModeNoOp) - c.Put(a, v, 1) + c.Put(a, v) for round := range 200000 { var wg sync.WaitGroup - wg.Go(func() { c.Put(a, v, 2) }) - wg.Go(func() { c.Put(b, v, 1) }) + wg.Go(func() { c.Put(a, v) }) + wg.Go(func() { c.Put(b, v) }) wg.Wait() if _, ok := c.Get(b); ok { t.Fatalf("round %d: ModeNoOp admitted a key past a full budget (SizeBytes=%d, capacityB=%d)", @@ -247,20 +246,20 @@ func TestGenericCache_GrowMigrationLossless(t *testing.T) { pad := make([]byte, 9) for j := 0; c.Len() < genericCacheStartCapacity-len(clustered); j++ { binary.BigEndian.PutUint64(pad[1:], uint64(j)) - c.Put(pad, []byte{1}, 1) + c.Put(pad, []byte{1}) } for _, k := range clustered { - c.Put(k, []byte("fresh"), 10) + c.Put(k, []byte("fresh")) } for j := 1 << 20; c.Len() < genericCacheStartCapacity; j++ { binary.BigEndian.PutUint64(pad[1:], uint64(j)) - c.Put(pad, []byte{1}, 1) + c.Put(pad, []byte{1}) if j > 1<<21 { t.Fatal("seeding could not fill the cache to the grow threshold") } } before := c.data.Load() - c.Put([]byte("grow-trigger"), []byte{1}, 1) + c.Put([]byte("grow-trigger"), []byte{1}) require.NotEqual(t, before, c.data.Load(), "grow did not happen") lost := 0 @@ -292,10 +291,10 @@ func TestGenericCache_CapacityEvictionAtomicWithPut_NoSizeDrift(t *testing.T) { } v := []byte("value-one") for range 100000 { - c.Put(b, v, 10) + c.Put(b, v) var wg sync.WaitGroup - wg.Go(func() { c.Put(a, v, 10) }) // insert → evicts b (cap 1) - wg.Go(func() { c.Put(b, v, 20) }) // same-key update path + wg.Go(func() { c.Put(a, v) }) // insert → evicts b (cap 1) + wg.Go(func() { c.Put(b, v) }) // same-key update path wg.Wait() } c.Delete(a) @@ -303,87 +302,6 @@ func TestGenericCache_CapacityEvictionAtomicWithPut_NoSizeDrift(t *testing.T) { require.Zero(t, c.SizeBytes(), "capacity eviction raced the update-path delta") } -// The failure mode is a pre-Clear epoch stamped on post-Clear storage. If Clear -// reused that epoch value, a later unwind could reach the same value and treat -// the entry as current even though its txNum is above the unwind floor. -// -// The test holds the key's stripe, then queues Clear before Put. Waiting beyond -// the mutex starvation threshold makes the unlock hand the stripe to Clear -// first. Clear must keep the stripe through the data and coherence generation -// changes, so Put stamps the post-Clear epoch and a later unwind invalidates it. -func TestGenericCache_ClearRacingPut_EpochAlias(t *testing.T) { - c := NewGenericCache[[]byte](64*datasize.MB, func(v []byte) int { return len(v) }, ModeEvictLRU) - defer c.Close() - c.Unwind(300) // epoch 0 -> 1 - - key := []byte("epoch-alias-key") - mu := &c.putStripes[maphash.Hash(key)&(putStripeCount-1)] - mu.Lock() - - var wg sync.WaitGroup - wg.Go(func() { c.Clear() }) - time.Sleep(5 * time.Millisecond) - wg.Go(func() { c.Put(key, []byte("dead-fork-value"), 200) }) - time.Sleep(5 * time.Millisecond) - mu.Unlock() - wg.Wait() - - c.Unwind(150) - - _, ok := c.Get(key) - require.False(t, ok, "entry at txNum 200 outlived an unwind to 150") -} - -// A reader that captures a dead (unwind-invalidated) entry from the retiring -// generation must not have it revalidated by Clear's coherence reset: -// judged against the post-Reset state (new epoch, lifted floor), the entry -// passes IsStale and dead-fork state is served. Coherence is snapshotted -// before the generation load, so an old-generation entry is always judged by -// pre-Reset coherence that still carries the unwind. -// -// The reader gates on the fence reaching the key's stripe — the last one the -// sweep locks — so its Get lands next to the Reset that follows. -func TestGenericCache_ClearRacingGet_DeadEntryStaysDead(t *testing.T) { - var key []byte - for i := 0; ; i++ { - k := make([]byte, 8) - binary.BigEndian.PutUint64(k, uint64(i)) - if maphash.Hash(k)&(putStripeCount-1) == putStripeCount-1 { - key = k - break - } - } - dead := []byte("dead-fork-value") - c := NewDomainCacheMode(1*datasize.MB, ModeEvictLRU) - defer c.Close() - for round := range 2000 { - c.Put(key, dead, 200) - c.Unwind(150) // the entry is dead-fork state; it must never be served again - var served atomic.Bool - var wg sync.WaitGroup - wg.Go(func() { c.Clear() }) - wg.Go(func() { - mu := &c.putStripes[putStripeCount-1] - for range 1 << 16 { - if mu.TryLock() { - mu.Unlock() - continue - } - break - } - for range 4 { - if _, ok := c.Get(key); ok { - served.Store(true) - return - } - } - }) - wg.Wait() - require.False(t, served.Load(), - "round %d: Clear revalidated an unwind-invalidated entry for a concurrent reader", round) - } -} - // The evictions counter must carry capacity evictions only. Routing // intentional removals through it — decrement-compensated or netted against a // removal counter at print time — races a concurrent stats reset: the swap @@ -404,7 +322,7 @@ func TestGenericCache_StatsResetAtomicWithDelete_NoPhantomEvictions(t *testing.T return default: } - c.Put(key, []byte{1}, 1) + c.Put(key, []byte{1}) c.Delete(key) } }) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 276871b07cc..de958fa6597 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -18,9 +18,9 @@ package cache import ( "bytes" - "math" "strings" "sync" + "sync/atomic" "github.com/c2h5oh/datasize" @@ -31,66 +31,41 @@ import ( ) const ( - // DefaultAccountCacheBytes is the byte limit for the account cache. DefaultAccountCacheBytes = 1 * datasize.GB - // DefaultStorageCacheBytes is the byte limit for storage cache. 150 MB - // holds the hot storage working set with headroom so eviction pressure - // doesn't push the hot set out. DefaultStorageCacheBytes = 150 * datasize.MB - // Per-domain avg entry size used to translate the byte budget into the - // entry-count cap the underlying sharded LRU is sized against. Account - // and storage are near-fixed: addr + record or addr+slot + value plus - // entry overhead. - avgAccountEntryBytes = 96 // 20 addr + ~50 account record + 24 overhead - avgStorageEntryBytes = 88 // 52 addr+slot + ~12 value + 24 overhead + avgAccountEntryBytes = 88 + avgStorageEntryBytes = 80 ) -// StateCache is a unified cache for domain data (Account, Storage, Code). -// Uses an array indexed by kv.Domain. Only Account, Storage, and Code domains -// are supported; other indices are nil. -// -// StateCache itself exposes no data methods: reads and admission-gated fills -// go through a ReadView bound to one tx's read view, committed updates through -// the Applier handle (see view.go). -// -// Account and Storage use GenericCache. -// Code uses CodeCache (two-level for deduplication). +type cacheGeneration struct { + stateVersion uint64 + active bool +} + +// StateCache holds account, storage, and code data for one durable state +// version. A generation is made inactive before any publication changes the +// underlying caches, so readers never observe a partially published version. 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 - // 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. + generation atomic.Pointer[cacheGeneration] + admissionMu sync.RWMutex + caches [kv.DomainLen]Cache disableFills bool } -// NewStateCache creates a new StateCache with the specified byte capacities. -// Mode for the byte-budget DomainCaches (Account/Storage) is read once from -// STATE_CACHE_MODE (evict|noop, default evict). CodeCache has its own LRU and -// is not gated by this knob. func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { mode := stateCacheModeFromEnv() 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 committed publications populate the cache") } sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) sc.caches[kv.CodeDomain] = NewCodeCache(codeBytes, addrBytes) - // CommitmentDomain deliberately gets no cache: commitment data lives in the - // BranchCache, and the nil slot short-circuits every StateCache path for it - // (including writes of commitmentdb.KeyCommitmentState). return sc } -// stateCacheModeFromEnv reads STATE_CACHE_MODE (once per NewStateCache). Unset -// or unrecognised returns ModeEvictLRU. Recognised values: "evict", "noop". The -// noop and unrecognised cases log; the default evict path is silent. func stateCacheModeFromEnv() Mode { v := strings.ToLower(strings.TrimSpace(dbg.EnvString("STATE_CACHE_MODE", ""))) switch v { @@ -105,20 +80,12 @@ func stateCacheModeFromEnv() Mode { } } -// newDomainCacheBytes constructs a DomainCache whose growth ceiling is derived -// from the byte budget using the supplied per-domain avg. It jump-grows from a -// small start into the shared envelope on demand, so a domain with a small -// working set (a test fixture) never pre-commits the full budget. func newDomainCacheBytes(capacityBytes datasize.ByteSize, avgBytes uint32, mode Mode) *DomainCache { return &DomainCache{ - GenericCache: NewGenericCacheWithAvg(capacityBytes, avgBytes, func(v []byte) int { return len(v) }, mode), + GenericCache: NewGenericCacheWithAvg(capacityBytes, avgBytes, func(v domainEntry) int { return len(v.value) }, mode), } } -// NewDefaultStateCache creates a new StateCache with the production byte budgets -// (Account 1GB, Storage 150MB, Code 512MB, Addr 16MB). Harnesses that build -// many short-lived ExecModules set a small ethconfig.Config.StateCacheBudget -// instead. func NewDefaultStateCache() *StateCache { return NewStateCache( DefaultAccountCacheBytes, @@ -128,35 +95,32 @@ func NewDefaultStateCache() *StateCache { ) } -// get retrieves data for the given domain and key. -// Returns (value, true) on cache hit — including (nil, true) for cached negatives — -// and (nil, false) on cache miss. -func (c *StateCache) get(domain kv.Domain, key []byte) ([]byte, bool) { - cache := c.caches[domain] - if cache == nil { - return nil, false +func (c *StateCache) generationFor(stateVersion uint64) *cacheGeneration { + generation := c.generation.Load() + if generation == nil || !generation.active || generation.stateVersion != stateVersion { + return nil } - return cache.Get(key) + return generation } -// getWithTxNum is get plus the txNum the cached value reflects, so the read -// path can bound a hit by step against an in-flight unwind's maxStep. -func (c *StateCache) getWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, bool) { - cache := c.caches[domain] +// CurrentStateVersion reports the durable version represented by the cache. +// It is unavailable while a publication is in progress. +func (c *StateCache) CurrentStateVersion() (uint64, bool) { + generation := c.generation.Load() + if generation == nil || !generation.active { + return 0, false + } + return generation.stateVersion, true +} + +func (c *StateCache) getWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { + cache := c.getCache(domain) if cache == nil { return nil, 0, false } - return cache.GetWithTxNum(key) + return cache.GetWithStep(key) } -// getCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), -// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss or -// when the code domain cache is not a CodeCache (defensive fallback). -// -// Use when the caller has the codeHash in hand (post-account-load) — typical -// for EXTCODESIZE / EXTCODEHASH / CALL targets. Lets many-addrs-one-code -// patterns (proxies, factory clones, ERC-20 holders) share a single codeHashToCode -// entry. func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -165,9 +129,6 @@ func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { return cc.GetByCodeHash(codeHash) } -// getCodeSizeByHash returns the size of code by its Ethereum codeHash -// without loading the bytes. Returns (0, false) when the size-only layer -// is not populated for this hash. func (c *StateCache) getCodeSizeByHash(codeHash []byte) (int, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -176,26 +137,6 @@ func (c *StateCache) getCodeSizeByHash(codeHash []byte) (int, bool) { return cc.GetCodeSizeByCodeHash(codeHash) } -// putCodeSizeByHash records the code size for a given codeHash. Useful when -// the caller has the size in hand (e.g. from an account-domain probe that -// resolved a sibling addr to the same code) but doesn't have the bytes. -func (c *StateCache) putCodeSizeByHash(codeHash []byte, size int, txNum uint64) { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok { - return - } - cc.PutCodeSizeByCodeHash(codeHash, size, txNum) -} - -// FillsEnabled reports whether reader fills are active (STATE_CACHE_FILLS). -// Wire-up code uses it to decide whether the backing aggregator must forbid -// visibility lowering: fill admission relies on view frontiers never -// decreasing, and apply-only caches have nothing for a lowered frontier to -// poison. -func (c *StateCache) FillsEnabled() bool { return !c.disableFills } - -// getAddrCodeHash returns the Ethereum codeHash for addr without an -// account-domain round-trip. The hash is zero when ok is false. func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -204,163 +145,117 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { return cc.GetAddrCodeHash(addr) } -// 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) { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok { +func (c *StateCache) fill( + generation *cacheGeneration, + domain kv.Domain, + key, value []byte, + step kv.Step, +) { + cache := c.getCache(domain) + if cache == nil { return } + value = bytes.Clone(value) + c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[kv.AccountsDomain] { + if c.generation.Load() != generation { return } - cc.PutAddrCodeHash(addr, h, txNum) + cache.PutIfAbsent(key, value, step) } -func (c *StateCache) deleteAddrCodeHash(addr []byte) { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok { +func (c *StateCache) fillCode( + generation *cacheGeneration, + key, value []byte, + step kv.Step, +) { + codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) + if !ok || len(value) == 0 { return } - cc.DeleteAddrCodeHash(addr) -} + value = bytes.Clone(value) + codeHash := crypto.Keccak256(value) -// 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 { + c.admissionMu.RLock() + defer c.admissionMu.RUnlock() + if c.generation.Load() != generation { return } - cache.Put(key, bytes.Clone(value), txNum) + codeCache.PutWithCodeHashIfAbsent(key, value, codeHash, step) } -// 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) { - cache := c.caches[domain] - if cache == nil { +func (c *StateCache) seedAddrCodeHash(generation *cacheGeneration, addr []byte, hash [32]byte) { + codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) + if !ok { return } - // Clone outside the lock: a rejected fill wastes one copy (rare), but - // Apply's write lock never waits on a fill's memcpy. - cloned := bytes.Clone(value) - if len(value) == 0 { - readTxNum = 0 - if visibleEnd > 0 { - readTxNum = visibleEnd - 1 - } - } c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[domain] { + if c.generation.Load() != generation { return } - cache.PutIfAbsent(key, cloned, readTxNum) + codeCache.PutAddrCodeHash(addr, hash) } -// fillCodeIfFresh is fillIfFresh for the code domain. An addr-keyed code entry -// derives from the account — an account deletion drops it without advancing the -// 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) fillCodeSize(generation *cacheGeneration, codeHash []byte, size int) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok || len(value) == 0 { + if !ok { return } - codeHash := crypto.Keccak256(value) - cloned := bytes.Clone(value) c.admissionMu.RLock() defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[kv.CodeDomain] || accountsVisibleEnd < c.appliedEnd[kv.AccountsDomain] { + if c.generation.Load() != generation { return } - codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) + codeCache.PutCodeSizeByCodeHash(codeHash, size) } -// deleteKey removes the data for the given domain and key. Authoritative -// deletions go through apply, which also advances the fill-admission frontier. -func (c *StateCache) deleteKey(domain kv.Domain, key []byte) { - cache := c.caches[domain] - if cache == nil { - return +func (c *StateCache) deleteAddrCodeHash(addr []byte) { + if codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache); ok { + codeCache.DeleteAddrCodeHash(addr) } - 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] +func (c *StateCache) applyLocked(update Update) { + cache := c.getCache(update.Domain) if cache == nil { return } - 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) - } - c.admissionMu.Lock() - defer c.admissionMu.Unlock() - c.noteApplied(domain, txNum) - - switch domain { + switch update.Domain { case kv.AccountsDomain: - putOrDelete(cache, key, value, txNum) - c.deleteAddrCodeHash(key) - if len(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) + putOrDelete(cache, update.Key, update.Value, update.Step) + c.deleteAddrCodeHash(update.Key) + if len(update.Value) == 0 { + if code := c.getCache(kv.CodeDomain); code != nil { + code.Delete(update.Key) + } } case kv.CodeDomain: - if len(value) == 0 { - cache.Delete(key) - c.deleteAddrCodeHash(key) - } else if codeCache, ok := cache.(*CodeCache); ok { - codeCache.PutWithCodeHash(key, value, codeHash, txNum) + if len(update.Value) == 0 { + cache.Delete(update.Key) + c.deleteAddrCodeHash(update.Key) + return + } + value := bytes.Clone(update.Value) + if codeCache, ok := cache.(*CodeCache); ok { + codeCache.PutWithCodeHash(update.Key, value, crypto.Keccak256(value), update.Step) } default: - putOrDelete(cache, key, value, txNum) + putOrDelete(cache, update.Key, update.Value, update.Step) } } -func putOrDelete(cache Cache, key, value []byte, txNum uint64) { +func putOrDelete(cache Cache, key, value []byte, step kv.Step) { if len(value) == 0 { cache.Delete(key) return } - cache.Put(key, bytes.Clone(value), txNum) -} - -func (c *StateCache) noteApplied(domain kv.Domain, txNum uint64) { - end := txNum - if end < math.MaxUint64 { - end++ - } - if end > c.appliedEnd[domain] { - c.appliedEnd[domain] = end - } + cache.Put(key, bytes.Clone(value), step) } -// clear removes all mutable entries from all caches. The admission frontier -// 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.admissionMu.Lock() - defer c.admissionMu.Unlock() +func (c *StateCache) clearLocked() { for _, cache := range c.caches { if cache != nil { cache.Clear() @@ -368,37 +263,19 @@ func (c *StateCache) clear() { } } -// Close releases every sub-cache's slot in the shared memory envelope so later -// caches size against real concurrency. Idempotent. func (c *StateCache) Close() { - for _, cache := range c.caches { - if cache != nil { - cache.Close() - } - } -} - -// unwind invalidates, across all caches, entries reflecting state above -// unwindToTxNum on a now-dead fork. Diffset-free and O(1): every cache (the -// GenericCaches and the CodeCache, all layers) bumps an epoch + lowers a floor -// and drops stale entries lazily on read. This is the sole cache-invalidation -// path on unwind — the executor never touches the cache during forward execution. -func (c *StateCache) unwind(unwindToTxNum uint64) { c.admissionMu.Lock() - defer c.admissionMu.Unlock() + c.generation.Store(&cacheGeneration{}) + c.admissionMu.Unlock() for _, cache := range c.caches { if cache != nil { - cache.Unwind(unwindToTxNum) + cache.Close() } } - for i := range c.appliedEnd { - c.appliedEnd[i] = min(c.appliedEnd[i], unwindToTxNum) - } } -// 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 + return c.getCache(domain) != nil } func (c *StateCache) getCache(domain kv.Domain) Cache { @@ -408,7 +285,6 @@ func (c *StateCache) getCache(domain kv.Domain) Cache { return c.caches[domain] } -// PrintStatsAndReset prints cache statistics for all domains and resets counters. func (c *StateCache) PrintStatsAndReset() { if c == nil { return @@ -423,3 +299,112 @@ func (c *StateCache) PrintStatsAndReset() { code.PrintStatsAndReset() } } + +// Update is one committed cache value. Step is returned on a later GetLatest +// hit; it is not used for cache coherence. +type Update struct { + Domain kv.Domain + Key []byte + Value []byte + Step kv.Step +} + +// Publisher is the canonical mutation handle for StateCache. +type Publisher struct { + c *StateCache +} + +func (c *StateCache) Publisher() Publisher { return Publisher{c: c} } + +func (p Publisher) Enabled() bool { return p.c != nil } + +// Initialize makes the cache represent stateVersion. A version mismatch drops +// all entries because their source version is unknown. +func (p Publisher) Initialize(stateVersion uint64) { + if p.c == nil { + return + } + c := p.c + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + + current := c.generation.Load() + if current != nil && current.active { + if current.stateVersion == stateVersion { + return + } + } else if current != nil { + panic("state cache publication already in progress") + } + + c.generation.Store(&cacheGeneration{}) + c.clearLocked() + c.generation.Store(&cacheGeneration{stateVersion: stateVersion, active: true}) +} + +// Publication keeps the previous generation available for rollback until the +// database commit succeeds. +type Publication struct { + c *StateCache + previous *cacheGeneration + transition *cacheGeneration +} + +// Begin revokes every existing ReadView before the database commit starts. +func (p Publisher) Begin() *Publication { + if p.c == nil { + return nil + } + c := p.c + c.admissionMu.Lock() + defer c.admissionMu.Unlock() + + previous := c.generation.Load() + if previous != nil && !previous.active { + panic("state cache publication already in progress") + } + transition := &cacheGeneration{} + c.generation.Store(transition) + return &Publication{c: c, previous: previous, transition: transition} +} + +// Abort restores the unchanged cache when the database transaction rolls back. +func (p *Publication) Abort() { + if p == nil || p.c == nil { + return + } + p.c.admissionMu.Lock() + defer p.c.admissionMu.Unlock() + if p.c.generation.Load() != p.transition { + panic("state cache publication changed before abort") + } + p.c.generation.Store(p.previous) + p.c = nil +} + +// Publish applies the committed batch and makes its state version visible. +// clear is used for canonical unwind because entries absent from the unwind +// callbacks may still belong to the discarded fork. +func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) { + if p == nil || p.c == nil { + return + } + p.c.admissionMu.Lock() + defer p.c.admissionMu.Unlock() + if p.c.generation.Load() != p.transition { + panic("state cache publication changed before publish") + } + if clear { + p.c.clearLocked() + } + for i := range updates { + p.c.applyLocked(updates[i]) + } + p.c.generation.Store(&cacheGeneration{stateVersion: stateVersion, active: true}) + p.c = nil +} + +func (p Publisher) Clear(stateVersion uint64) { + publication := p.Begin() + publication.Publish(stateVersion, nil, true) +} diff --git a/execution/cache/view.go b/execution/cache/view.go index 0de781920d6..7e3b7b776a9 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -16,182 +16,106 @@ package cache -import ( - "github.com/erigontech/erigon/db/kv" -) +import "github.com/erigontech/erigon/db/kv" -// Frontier reports the exclusive txNum bound of one transaction's read view -// per domain. ok=false means the view has no exact frontier for the domain -// (remote or history-disabled backends, dependency-clamped values views); -// fills sourced from such a view are skipped. -// -// An implementation may report a stale-low bound only for a coherent, -// monotonically extended view — then it merely over-rejects fills. A view -// serving mixed-age reads has no exact frontier and must answer ok=false. -// Overstating what the tx can currently read is never safe: admission rests -// on that. -type Frontier interface { - DomainVisibleEnd(domain kv.Domain) (visibleEnd uint64, ok bool) +// ReadView is a cache handle bound to one durable state version. Its zero value +// is inert. A publication invalidates the view before changing cache contents. +type ReadView struct { + c *StateCache + generation *cacheGeneration } -// 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) } - -// 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. -// -// 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. -// Snapshot-isolated caching is kvcache's job (node/shards). -type ReadView struct { - c *StateCache - frontier Frontier +// View returns an inert handle unless stateVersion is the current durable +// version represented by the cache. +func (c *StateCache) View(stateVersion uint64) ReadView { + if c == nil { + return ReadView{} + } + generation := c.generationFor(stateVersion) + if generation == nil { + return ReadView{} + } + return ReadView{c: c, generation: generation} } -// View creates a ReadView vouched for by f. A nil f disables admission-gated fills. -func (c *StateCache) View(f Frontier) ReadView { return ReadView{c: c, frontier: f} } +func (v ReadView) current() bool { + return v.c != nil && v.generation != nil && v.c.generation.Load() == v.generation +} -// Get retrieves data for the given domain and key. -// Returns (value, true) on cache hit — including (nil, true) for cached negatives — -// and (nil, false) on cache miss. func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { - if v.c == nil { - return nil, false - } - return v.c.get(domain, key) + value, _, ok := v.GetWithStep(domain, key) + return value, ok } -// GetWithTxNum is Get plus the txNum the cached value reflects, so the read -// path can bound a hit by step against an in-flight unwind's maxStep. -func (v ReadView) GetWithTxNum(domain kv.Domain, key []byte) ([]byte, uint64, bool) { - if v.c == nil { +func (v ReadView) GetWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { + if !v.current() { + return nil, 0, false + } + value, step, ok := v.c.getWithStep(domain, key) + if !v.current() { return nil, 0, false } - return v.c.getWithTxNum(domain, key) + return value, step, ok } -// GetCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), -// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss. func (v ReadView) GetCodeByHash(codeHash []byte) ([]byte, bool) { - if v.c == nil { + if !v.current() { + return nil, false + } + value, ok := v.c.getCodeByHash(codeHash) + if !v.current() { return nil, false } - return v.c.getCodeByHash(codeHash) + return value, ok } -// GetCodeSizeByHash returns the cached code length for codeHash. func (v ReadView) GetCodeSizeByHash(codeHash []byte) (int, bool) { - if v.c == nil { + if !v.current() { return 0, false } - return v.c.getCodeSizeByHash(codeHash) + size, ok := v.c.getCodeSizeByHash(codeHash) + if !v.current() { + return 0, false + } + return size, ok } -// GetAddrCodeHash returns the Ethereum codeHash for addr without an -// account-domain round-trip. The hash is zero when ok is false. func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { - if v.c == nil { + if !v.current() { return [32]byte{}, false } - return v.c.getAddrCodeHash(addr) -} - -// CanFill reports whether this view carries a frontier, i.e. Fill and -// SeedAddrCodeHash can admit values through it. -func (v ReadView) CanFill() 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; -// views without an exact frontier skip the fill. A code fill also checks the -// accounts frontier: an addr-keyed code entry derives from the account — an -// account deletion drops it without advancing the code frontier — so a view -// that predates the deletion must not refill it (mirrors SeedAddrCodeHash). -func (v ReadView) Fill(domain kv.Domain, key []byte, value []byte, readTxNum uint64) { - if v.c == nil || v.c.disableFills || v.frontier == nil { - return - } - visibleEnd, ok := v.frontier.DomainVisibleEnd(domain) - if !ok { - return - } - if domain == kv.CodeDomain { - accountsEnd, ok := v.frontier.DomainVisibleEnd(kv.AccountsDomain) - if !ok { - return - } - v.c.fillCodeIfFresh(key, value, readTxNum, visibleEnd, accountsEnd) - return + hash, ok := v.c.getAddrCodeHash(addr) + if !v.current() { + return [32]byte{}, false } - v.c.fillIfFresh(domain, key, value, readTxNum, visibleEnd) + return hash, ok } -// SeedAddrCodeHash offers an addr → codeHash mapping derived from an account -// record read from this view, so admission checks the accounts frontier even -// though the mapping lives in the code cache. -func (v ReadView) SeedAddrCodeHash(addr []byte, h [32]byte, txNum uint64) { - if v.c == nil || v.c.disableFills || v.frontier == nil { - return - } - visibleEnd, ok := v.frontier.DomainVisibleEnd(kv.AccountsDomain) - if !ok { - return - } - v.c.seedAddrCodeHash(addr, h, txNum, visibleEnd) +func (v ReadView) canFill() bool { + return v.current() && !v.c.disableFills } -// FillCodeSize records the code length for codeHash. Content-addressed and -// immutable for a given hash, so it needs no admission and no frontier — but -// it is still a reader write, so the fills switch covers it. -func (v ReadView) FillCodeSize(codeHash []byte, size int, txNum uint64) { - if v.c == nil || v.c.disableFills { +func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { + if !v.canFill() { return } - v.c.putCodeSizeByHash(codeHash, size, txNum) -} - -// Applier is the authoritative writer handle of a StateCache: post-commit -// applies, 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 -} - -// 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) { - if a.c == nil { + if domain == kv.CodeDomain { + v.c.fillCode(v.generation, key, value, step) return } - a.c.apply(domain, key, value, txNum) + v.c.fill(v.generation, domain, key, value, step) } -// Unwind invalidates, across all caches, entries reflecting state above -// unwindToTxNum on a now-dead fork, and lowers the applied frontiers. -func (a Applier) Unwind(unwindToTxNum uint64) { - if a.c == nil { +func (v ReadView) SeedAddrCodeHash(addr []byte, hash [32]byte) { + if !v.canFill() { return } - a.c.unwind(unwindToTxNum) + v.c.seedAddrCodeHash(v.generation, addr, hash) } -// Clear removes all mutable entries from all caches. The applied frontiers -// survive — clearing is not a canonical-state rewind (that is Unwind). -func (a Applier) Clear() { - if a.c == nil { +func (v ReadView) FillCodeSize(codeHash []byte, size int) { + if !v.canFill() { return } - a.c.clear() + v.c.fillCodeSize(v.generation, codeHash, size) } diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index d1b2de04e5e..d8f62755c02 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -17,6 +17,7 @@ import ( "github.com/erigontech/erigon/db/dbservices" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/dbutils" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" @@ -81,23 +82,29 @@ func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { // Code reads also populate the content-addressed and size-cache layers. type cachePopulatingGetter struct { kv.TemporalGetter - view cache.ReadView - stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) + view cache.ReadView } func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx } - debug := ttx.Debug() - return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(debug), stepSize: debug.StepSize()} + stateVersion, err := rawdb.GetStateVersion(ttx) + if err != nil { + return ttx + } + for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + if _, ok := ttx.Debug().DomainVisibleEnd(domain); !ok { + return ttx + } + } + return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(stateVersion)} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { v, step, err := cpg.TemporalGetter.GetLatest(name, k) if err == nil { - readTxNum := (uint64(step)+1)*cpg.stepSize - 1 - cpg.view.Fill(name, k, v, readTxNum) + cpg.view.Fill(name, k, v, step) } return v, step, err } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index b5d153feef3..6990821f13b 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -44,15 +44,25 @@ func (s stubTemporalGetter) HasPrefix(kv.Domain, []byte) ([]byte, []byte, bool, func (s stubTemporalGetter) StepsInFiles(...kv.Domain) kv.Step { return 0 } -func newTestStateCache() *cache.StateCache { +func newTestStateCache(t *testing.T) *cache.StateCache { + t.Helper() b := 1 * datasize.MB - return cache.NewStateCache(b, b, b, b) + sc := cache.NewStateCache(b, b, b, b) + t.Cleanup(sc.Close) + sc.Publisher().Initialize(1) + return sc } -// 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) { - sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return txNum + 1, true })).Fill(domain, k, v, txNum) +func currentCacheView(t *testing.T, sc *cache.StateCache) cache.ReadView { + t.Helper() + stateVersion, ok := sc.CurrentStateVersion() + require.True(t, ok) + return sc.View(stateVersion) +} + +func seedFill(t *testing.T, sc *cache.StateCache, domain kv.Domain, k, v []byte, step kv.Step) { + t.Helper() + currentCacheView(t, sc).Fill(domain, k, v, step) } // A warmup read-through must never replace a fresher entry an authoritative @@ -64,15 +74,15 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { fresh := []byte("account-record-nonce-5") stale := []byte("account-record-nonce-4") for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { - sc := newTestStateCache() - seedFill(sc, domain, key, fresh, 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} + sc := newTestStateCache(t) + seedFill(t, sc, domain, key, fresh, 54) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, view: currentCacheView(t, sc)} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) require.Equal(t, stale, v, "read-through must still return the view's value") - got, ok := sc.View(nil).Get(domain, key) + got, ok := currentCacheView(t, sc).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, fresh, got, "domain %s: warmup must not clobber the fresher entry", domain) } @@ -84,14 +94,14 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { addr := []byte("\x11\x22\x33\x44\x55\x66\x77\x88\x99\xaa\xbb\xcc\xdd\xee\xff\x00\x11\x22\x33\x44") freshCode := []byte{0xaa, 0x01, 0x02, 0x03} staleCode := []byte{0xbb, 0x04, 0x05, 0x06} - sc := newTestStateCache() - seedFill(sc, kv.CodeDomain, addr, freshCode, 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} + sc := newTestStateCache(t) + seedFill(t, sc, kv.CodeDomain, addr, freshCode, 54) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, view: currentCacheView(t, sc)} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) - got, ok := sc.View(nil).Get(kv.CodeDomain, addr) + got, ok := currentCacheView(t, sc).Get(kv.CodeDomain, addr) require.True(t, ok) require.Equal(t, freshCode, got, "warmup must not rebind addr to older code") } @@ -103,85 +113,78 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { code := []byte{0xaa, 0x01, 0x02, 0x03} for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { - sc := newTestStateCache() - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} + sc := newTestStateCache(t) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, view: currentCacheView(t, sc)} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) - got, ok := sc.View(nil).Get(domain, key) + got, ok := currentCacheView(t, sc).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, val, got, "domain %s", domain) } - sc := newTestStateCache() - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} + sc := newTestStateCache(t) + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, view: currentCacheView(t, sc)} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) - got, ok := sc.View(nil).Get(kv.CodeDomain, key) + got, ok := currentCacheView(t, sc).Get(kv.CodeDomain, key) require.True(t, ok) require.Equal(t, code, got) - got, ok = sc.View(nil).GetCodeByHash(crypto.Keccak256(code)) + got, ok = currentCacheView(t, sc).GetCodeByHash(crypto.Keccak256(code)) require.True(t, ok) require.Equal(t, code, got) // Negative results (missing account, empty slot) are cached as nil hits. - sc = newTestStateCache() - cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, view: sc.View(cache.FrontierFunc(emptyVisibleEnd)), stepSize: 1_562_500} + sc = newTestStateCache(t) + cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, view: currentCacheView(t, sc)} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - got, ok = sc.View(nil).Get(kv.AccountsDomain, key) + got, ok = currentCacheView(t, sc).Get(kv.AccountsDomain, key) require.True(t, ok) require.Empty(t, got) } -func TestCachePopulatingGetterNegativeUsesLastVisibleTxNum(t *testing.T) { - const visibleEnd = uint64(10_000_001) +func TestCachePopulatingGetterNegativeClearedByPublication(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 := newTestStateCache(t) cpg := &cachePopulatingGetter{ - TemporalGetter: stubTemporalGetter{v: nil}, stepSize: 1_562_500, - view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return visibleEnd, true })), + TemporalGetter: stubTemporalGetter{v: nil}, + view: currentCacheView(t, sc), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Applier().Unwind(visibleEnd) - _, ok = sc.View(nil).Get(kv.AccountsDomain, key) - require.True(t, ok, "a negative observed before the unwind floor must remain cached") - - sc.Applier().Unwind(visibleEnd - 1) - _, ok = sc.View(nil).Get(kv.AccountsDomain, key) - require.False(t, ok, "a negative observed at the unwind floor must be invalidated") + sc.Publisher().Clear(2) + _, ok = currentCacheView(t, sc).Get(kv.AccountsDomain, key) + require.False(t, ok) } -func TestCachePopulatingGetterUnavailableVisibleEndNeverFills(t *testing.T) { +func TestCachePopulatingGetterInertViewNeverFills(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 := newTestStateCache(t) cpg := &cachePopulatingGetter{ - TemporalGetter: stubTemporalGetter{v: nil}, stepSize: 1_562_500, - view: sc.View(cache.FrontierFunc(func(kv.Domain) (uint64, bool) { return 0, false })), + TemporalGetter: stubTemporalGetter{v: nil}, + view: cache.ReadView{}, } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.View(nil).Get(kv.AccountsDomain, key) - require.False(t, ok, "no exact frontier — nothing may be cached") + _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) + require.False(t, ok) } 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 := newTestStateCache(t) 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: currentCacheView(t, sc), } + publication := sc.Publisher().Begin() + publication.Publish(2, nil, false) _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) require.False(t, ok) } - -func emptyVisibleEnd(kv.Domain) (uint64, bool) { return 0, true } diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 9a5b3204633..35b8a98cc62 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -396,24 +396,6 @@ 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() { - if e.readAheader == nil { - return - } - ctx := e.bacgroundCtx - if ctx == nil { - ctx = context.Background() - } - e.readAheader.WaitForWarmup(ctx) -} - func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.TemporalRwTx, header *types.Header) 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()) { @@ -442,7 +424,6 @@ func (e *ExecModule) unwindToCommonCanonical(sd *execctx.SharedDomains, tx kv.Te return err } - e.drainReadAhead() if err := e.pipelineExecutor.UnwindTo(unwindPoint, stagedsync.ExecUnwind, tx); err != nil { return err } @@ -591,7 +572,7 @@ 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.SetStateCacheReader(e.stateCache) doms.SetCodeStore(e.codeStore) if err = e.unwindToCommonCanonical(doms, tx, header); err != nil { doms.Close() diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 93b61132df9..a11869894dc 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -360,11 +360,6 @@ 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() - var validationError string // Open a RO tx as the base for all reads. Writes accumulate in the block @@ -405,7 +400,7 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa // the per-execution Account/Storage/Code cache. Previously only // ValidateChain (fork validation, exec_module.go) set this, leaving // the canonical execution path running uncached against the aggTx. - currentContext.SetStateCache(e.stateCache) + currentContext.SetCanonicalStateCache(e.stateCache) currentContext.SetCodeStore(e.codeStore) } @@ -591,7 +586,7 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa return nil, nil, fmt.Errorf("updateForkChoice: new sd after hasMore: %w", err) } freshSD.SetInMemHistoryReads(inMemHistoryReads) - freshSD.SetStateCache(e.stateCache) + freshSD.SetCanonicalStateCache(e.stateCache) freshSD.SetCodeStore(e.codeStore) if err := freshSD.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil { roTx.Rollback() diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 9a9c11c2004..4afdb6c09ff 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -103,19 +103,10 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { } defer sd.Close() - // Wire the shared state cache so unwindExec3 invalidates it (epoch bump + - // floor lower). Without this, the SD has no cache attached during the unwind, - // so sd.Unwind's invalidation is a no-op, the cache keeps pre-unwind values, - // and the next FCU re-execution reads them and computes a stale state root - // (BadBlock). Mirrors ValidateChain/forkchoice. - sd.SetStateCache(e.stateCache) + // This path owns the canonical cache publication performed by Commit. + sd.SetCanonicalStateCache(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/vm/contract.go b/execution/vm/contract.go index c699e00eb6e..cb9e669acb9 100644 --- a/execution/vm/contract.go +++ b/execution/vm/contract.go @@ -114,7 +114,7 @@ func (c *Contract) isCode(udest uint64) bool { if !isCodeHashZero { // content-addressed by codeHash and never unwound, so txNum is irrelevant - jumpDestCache.Put(codeHash[:], c.analysis, 0) + jumpDestCache.Put(codeHash[:], c.analysis) } return c.analysis.codeSegment(udest) From c51d08e0f3ebd56aa1633875fa4d0529c0560d98 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:20:04 +0200 Subject: [PATCH 03/50] execution/cache, db/state: clarify publication contract --- db/state/execctx/domain_shared.go | 69 ++++++++++++++++++++++++----- execution/cache/cache.go | 12 +++-- execution/cache/state_cache.go | 69 +++++++++++++++++++++-------- execution/cache/view.go | 17 +++++-- execution/exec/blocks_read_ahead.go | 5 +++ 5 files changed, 135 insertions(+), 37 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 10be417b7fa..5b2f7219606 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -82,6 +82,10 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } +// cacheViewFor binds a cache handle to the state version of tx. Most reads use +// the base transaction and reuse the construction-time metadata stored on +// SharedDomains. Reads through another transaction re-evaluate both its +// version and whether its domain view has an exact frontier. func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { if sd.stateCache == nil || tx == nil { return cache.ReadView{} @@ -105,6 +109,10 @@ func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { return sd.stateCache.View(stateVersion) } +// stateCacheViewEligible rejects a dependency-clamped domain view. Such a view +// mixes database values with older file values and may later expose newer +// files without changing PlainStateVersion; a fill from it could therefore +// outlive the snapshot that produced the value. func stateCacheViewEligible(tx kv.TemporalTx) bool { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { if _, ok := tx.Debug().DomainVisibleEnd(domain); !ok { @@ -133,6 +141,9 @@ type SharedDomains struct { logger log.Logger + // These fields describe the database snapshot used to construct this + // SharedDomains. The common read path reuses them instead of reading cache + // eligibility metadata for every GetLatest call. baseViewID uint64 baseStateVersion uint64 baseStateVersionKnown bool @@ -161,8 +172,10 @@ type SharedDomains struct { // to read from the FCU's published SD without writing to it. parent *SharedDomains - // Only canonical SharedDomains receive a publisher; speculative readers - // never change its generation or authoritative entries. + // stateCache provides version-bound reads and fills. cachePublisher is set + // only when this SharedDomains owns publication of durable canonical state; + // a speculative SharedDomains may read the cache but cannot move its + // generation or change its authoritative entries. stateCache *cache.StateCache cachePublisher cache.Publisher cachePublication *cache.Publication @@ -714,11 +727,19 @@ func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][ } } if sd.cachePublisher.Enabled() { + // A canonical unwind changes the durable state represented by the + // process-global cache. Revoke current views now, then clear all entries + // when Commit publishes the post-unwind PlainStateVersion. Clearing is + // required because the unwind changeset is not a complete list of cache + // entries that may have come from the discarded fork. if sd.cachePublication == nil { sd.cachePublication = sd.cachePublisher.Begin() } sd.clearStateCache = true } else { + // A speculative unwind changes only this SharedDomains and may later be + // discarded. Detach its reader so the rewound local view cannot read + // from or fill the cache's durable canonical generation. sd.stateCache = nil } } @@ -777,8 +798,13 @@ func (sd *SharedDomains) GetCommitmentCtx() *commitmentdb.SharedDomainsCommitmen func (sd *SharedDomains) Logger() log.Logger { return sd.logger } -// SetStateCacheReader attaches the process-global cache without granting -// publication authority. A speculative unwind only detaches this reader. +// SetStateCacheReader attaches the process-global cache for version-checked +// reads and read-through fills. It does not grant authority to publish, clear, +// or otherwise move the cache's durable generation. +// +// This restricted capability is safe for speculative execution: its writes +// may be discarded, and its local unwind only detaches the reader. It cannot +// change the canonical cache observed by other transactions. func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return @@ -786,8 +812,16 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { sd.stateCache = stateCache } -// SetCanonicalStateCache also grants publication authority to Commit and -// canonical unwind. +// SetCanonicalStateCache attaches the same reader and also grants publication +// authority. Use it only for a SharedDomains whose Commit makes state durable: +// Commit may revoke existing views, apply the committed cache updates, and +// publish the resulting PlainStateVersion. A canonical unwind may additionally +// clear all entries before publishing its rewound version. +// +// Initialize binds the process-global cache to this SharedDomains' base +// database version. Keeping this authority separate from SetStateCacheReader +// prevents speculative rollback or unwind from changing globally visible +// cache state. func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { sd.SetStateCacheReader(stateCache) if sd.stateCache == nil || !sd.baseStateVersionKnown { @@ -797,9 +831,16 @@ func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { sd.cachePublisher.Initialize(sd.baseStateVersion) } -// GuardAggregatorForCache keeps one PlainStateVersion from exposing older -// domain data after a cache view is bound to it. Call it whenever a StateCache -// is wired over a DB, including when reader fills are disabled. +// GuardAggregatorForCache prevents domain-file visibility from moving +// backwards while StateCache is active. PlainStateVersion tracks durable +// database state, but it does not change when the aggregator exposes an older +// set of files. If visibility could be lowered independently, a transaction +// and the cache could report the same version while representing different +// effective states. +// +// The guard is required even when reader fills are disabled because cache hits +// also rely on stable visibility. A database that cannot enforce the invariant +// is rejected instead of silently permitting unsafe cache reads. func GuardAggregatorForCache(db any, sc *cache.StateCache) { if sc == nil { return @@ -946,8 +987,14 @@ type cacheUpdate struct { txN uint64 } -// Commit flushes and commits tx before publishing the resulting cache -// generation. tx must be a flush-specific transaction. +// Commit makes the database transition durable before exposing its cache +// generation. It first flushes state while collecting cache updates, revokes +// the old ReadViews, and commits tx. Only after a successful commit does it +// apply the collected updates and publish the resulting PlainStateVersion. +// +// Any error before the database commit leaves the entries unchanged and Abort +// restores the previous generation. tx must be dedicated to this flush because +// Commit consumes it. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) defer func() { diff --git a/execution/cache/cache.go b/execution/cache/cache.go index ee97999a074..bd39668501a 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -16,9 +16,15 @@ // Package cache provides the process-global cache of latest committed state. // -// StateCache represents one durable PlainStateVersion at a time. Read views -// are bound to that version, and a publication revokes them before changing -// cache contents. Snapshot-isolated caching is handled separately by kvcache. +// StateCache represents exactly one durable PlainStateVersion at a time. It +// does not keep old generations: a transaction whose snapshot has another +// version receives an inert ReadView and reads from the database instead. +// +// Publishing canonical state revokes the current generation before changing +// entries and exposes the next generation only after the database commit. +// This keeps concurrent readers on one complete version even though the cache +// itself is process-global. Multi-version snapshot caching remains the +// responsibility of kvcache. package cache import "github.com/erigontech/erigon/db/kv" diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index de958fa6597..676613e032d 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -38,17 +38,26 @@ const ( avgStorageEntryBytes = 80 ) +// cacheGeneration is immutable after it is stored. ReadView uses pointer +// identity, rather than stateVersion alone, as its validity token: revoking +// and later republishing the same state version must not make an old view +// valid again. type cacheGeneration struct { stateVersion uint64 active bool } -// StateCache holds account, storage, and code data for one durable state -// version. A generation is made inactive before any publication changes the -// underlying caches, so readers never observe a partially published version. +// StateCache holds account, storage, and code values for exactly one durable +// PlainStateVersion. Each reader carries the generation pointer that was +// current for its database snapshot. Publication replaces that pointer before +// changing entries, so old readers turn cache accesses into misses instead of +// observing a mixture of the old and new states. type StateCache struct { - generation atomic.Pointer[cacheGeneration] - admissionMu sync.RWMutex + // Reads remain lock-free. admissionMu only serializes generation changes + // against fills, whose source read may have started before publication. + generation atomic.Pointer[cacheGeneration] + admissionMu sync.RWMutex + caches [kv.DomainLen]Cache disableFills bool } @@ -103,8 +112,9 @@ func (c *StateCache) generationFor(stateVersion uint64) *cacheGeneration { return generation } -// CurrentStateVersion reports the durable version represented by the cache. -// It is unavailable while a publication is in progress. +// CurrentStateVersion reports the durable PlainStateVersion represented by +// all cache layers. It returns false while publication is in progress because +// the old version has been revoked and the new version is not visible yet. func (c *StateCache) CurrentStateVersion() (uint64, bool) { generation := c.generation.Load() if generation == nil || !generation.active { @@ -300,8 +310,9 @@ func (c *StateCache) PrintStatsAndReset() { } } -// Update is one committed cache value. Step is returned on a later GetLatest -// hit; it is not used for cache coherence. +// Update is one value written by the database transaction being published. +// Step is retained because GetLatest must return the value's source step; cache +// coherence depends only on the published PlainStateVersion. type Update struct { Domain kv.Domain Key []byte @@ -309,17 +320,23 @@ type Update struct { Step kv.Step } -// Publisher is the canonical mutation handle for StateCache. +// Publisher is the mutation capability for canonical state. Normal readers +// receive only ReadView, while code that makes a database state durable uses a +// Publisher to move every cache layer to the same PlainStateVersion. type Publisher struct { c *StateCache } +// Publisher returns a handle that can change the cache's canonical generation. +// It must not be given to speculative execution whose writes may be discarded. func (c *StateCache) Publisher() Publisher { return Publisher{c: c} } func (p Publisher) Enabled() bool { return p.c != nil } -// Initialize makes the cache represent stateVersion. A version mismatch drops -// all entries because their source version is unknown. +// Initialize binds the cache to the durable version seen by its canonical +// owner. Existing entries are preserved when the version already matches. A +// mismatch clears them because this single-version cache cannot prove that any +// entry belongs to the owner's database snapshot. func (p Publisher) Initialize(stateVersion uint64) { if p.c == nil { return @@ -342,15 +359,19 @@ func (p Publisher) Initialize(stateVersion uint64) { c.generation.Store(&cacheGeneration{stateVersion: stateVersion, active: true}) } -// Publication keeps the previous generation available for rollback until the -// database commit succeeds. +// Publication represents one pending transition of the durable database +// state. Begin makes the cache unavailable without changing its entries, so +// Abort can restore the previous generation if the transaction rolls back. +// Publish consumes the transition after the database commit succeeds. type Publication struct { c *StateCache previous *cacheGeneration transition *cacheGeneration } -// Begin revokes every existing ReadView before the database commit starts. +// Begin revokes every existing ReadView and prevents creation of a new live +// view. It does not alter cache entries; they remain available for Abort until +// the canonical database transaction either commits or rolls back. func (p Publisher) Begin() *Publication { if p.c == nil { return nil @@ -368,7 +389,9 @@ func (p Publisher) Begin() *Publication { return &Publication{c: c, previous: previous, transition: transition} } -// Abort restores the unchanged cache when the database transaction rolls back. +// Abort restores the previous generation after a failed or abandoned database +// transaction. The entries were not changed during the transition, so the old +// ReadViews become valid again together with their database version. func (p *Publication) Abort() { if p == nil || p.c == nil { return @@ -382,9 +405,15 @@ func (p *Publication) Abort() { p.c = nil } -// Publish applies the committed batch and makes its state version visible. -// clear is used for canonical unwind because entries absent from the unwind -// callbacks may still belong to the discarded fork. +// Publish applies updates from a successful database transaction and exposes +// stateVersion as one complete cache generation. The caller must invoke it +// only after the database commit, so a visible cache generation is never ahead +// of durable state. +// +// A forward commit can retain entries that were not updated because they still +// have the same value in the new state. Canonical unwind sets clear because its +// callbacks do not enumerate every value that may belong to the discarded +// fork. func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) { if p == nil || p.c == nil { return @@ -404,6 +433,8 @@ func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) p.c = nil } +// Clear revokes current views, removes every cached value, and publishes an +// empty generation for stateVersion. func (p Publisher) Clear(stateVersion uint64) { publication := p.Begin() publication.Publish(stateVersion, nil, true) diff --git a/execution/cache/view.go b/execution/cache/view.go index 7e3b7b776a9..4fa9236af98 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -18,15 +18,24 @@ package cache import "github.com/erigontech/erigon/db/kv" -// ReadView is a cache handle bound to one durable state version. Its zero value -// is inert. A publication invalidates the view before changing cache contents. +// ReadView is a cache handle bound to one durable PlainStateVersion. It does +// not pin the cache or delay publication. Instead, each read checks the +// immutable generation token before and after accessing an underlying cache, +// so publication concurrent with the access turns the result into a miss. +// +// Fills check the same token while holding the cache admission lock. A value +// read from an old database snapshot therefore cannot enter a newer cache +// generation. The zero value is inert and safely falls back to the database. type ReadView struct { c *StateCache generation *cacheGeneration } -// View returns an inert handle unless stateVersion is the current durable -// version represented by the cache. +// View returns a live handle only when the cache currently represents +// stateVersion and no publication is in progress. Callers must pass the +// version of their own database snapshot, not a separately sampled latest +// version. A mismatch returns an inert view rather than serving newer or older +// cached state. func (c *StateCache) View(stateVersion uint64) ReadView { if c == nil { return ReadView{} diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index d8f62755c02..86462c69d0a 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -85,6 +85,11 @@ type cachePopulatingGetter struct { view cache.ReadView } +// readAheadGetter enables fills only when the transaction has an exact domain +// frontier. StateCache.View performs the second check: its PlainStateVersion +// must match the currently published generation. Failure of either check keeps +// read-ahead useful for the OS page cache without admitting unsafe values into +// StateCache. func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx From b085d39420049f38ac626bd03d1d94c420ebc51d Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:35:01 +0200 Subject: [PATCH 04/50] execution/cache, commitment, db/state: unify cache publication --- db/state/aggregator.go | 35 +- db/state/aggregator_align_test.go | 28 ++ db/state/aggregator_close_test.go | 2 +- db/state/commitment_convert_blackbox_test.go | 2 +- db/state/execctx/branch_cache_flush_test.go | 134 +++++ db/state/execctx/domain_shared.go | 492 +++++++++---------- db/state/execctx/export_test.go | 10 +- db/state/execctx/options.go | 4 +- db/state/execctx/statecache_readfill_test.go | 29 +- db/state/squeeze.go | 11 +- execution/cache/coherence/coherence.go | 123 ----- execution/cache/coherence/coherence_test.go | 110 ----- execution/cache/state_cache.go | 160 ++---- execution/cache/version_gate.go | 220 +++++++++ execution/cache/view.go | 20 +- execution/commitment/adaptive_pin.go | 277 ++++++++--- execution/commitment/adaptive_pin_test.go | 41 ++ execution/commitment/branch_cache.go | 111 ++--- execution/commitment/branch_cache_test.go | 300 +++++------ execution/commitment/branch_cache_view.go | 146 ++++++ execution/commitment/hex_patricia_hashed.go | 4 +- execution/commitment/preload.go | 12 +- execution/commitment/preload_parallel.go | 14 +- 23 files changed, 1302 insertions(+), 983 deletions(-) delete mode 100644 execution/cache/coherence/coherence.go delete mode 100644 execution/cache/coherence/coherence_test.go create mode 100644 execution/cache/version_gate.go create mode 100644 execution/commitment/branch_cache_view.go diff --git a/db/state/aggregator.go b/db/state/aggregator.go index c6bce6a926f..0a3cd3eec7f 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -90,11 +90,9 @@ type Aggregator struct { // regenerates them. Guarded by dirtyFilesLock. unalignedDomain [kv.DomainLen]bool unalignedIdx [kv.StandaloneIdxLen]bool - // visibilityLoweringForbidden: a fill-enabled StateCache is wired over - // this aggregator, and its fill admission relies on view frontiers never - // decreasing. recalcVisibleFiles refuses to lower the cached state - // domains' visible ends while set; Close clears it (shutdown is not a - // fill window). + // visibilityLoweringForbidden: a single-version cache is wired over this + // aggregator, and its fill admission relies on view frontiers never + // decreasing. Close clears it because shutdown is not a fill window. visibilityLoweringForbidden atomic.Bool snapshotBuildSema *semaphore.Weighted @@ -549,12 +547,15 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// ForbidVisibilityLowering marks this aggregator as backing a fill-enabled -// StateCache: from then on recalcVisibleFiles panics instead of lowering a -// cached state domain's visible end, whichever entry point caused it. +// ForbidVisibilityLowering marks this aggregator as backing a single-version +// cache. From then on recalcVisibleFiles rejects lowering a cached domain's +// visible end, whichever entry point caused it. // Serialized with recalcVisibleFiles via dirtyFilesLock so "from then on" // holds against a recalculation already in flight. func (a *Aggregator) ForbidVisibilityLowering() { + if a.visibilityLoweringForbidden.Load() { + return + } a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() a.visibilityLoweringForbidden.Store(true) @@ -709,9 +710,17 @@ func (a *Aggregator) ReloadFiles() error { // closeDirtyFilesNoReopen drops all dirty-file mmaps without re-scanning the // snapshots dir, so a caller can rename the underlying files (Windows forbids // renaming a mapped file); a later ReloadFiles re-opens them. +// closeDirtyFilesNoReopen is an exclusive tooling operation: it temporarily +// removes all visible files and invalidates cache guarantees tied to them. func (a *Aggregator) closeDirtyFilesNoReopen() { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() + // This path removes every visible file before replacing them, so no cache + // view may remain live across the reset. + a.visibilityLoweringForbidden.Store(false) + if cd := a.d[kv.CommitmentDomain]; cd != nil && cd.branchCache != nil { + cd.branchCache.Reset() + } a.closeDirtyFiles() a.recalcVisibleFiles(nil) } @@ -1901,14 +1910,14 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { if a.visibilityLoweringForbidden.Load() { prev := a.visible.Load() - for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain, kv.CommitmentDomain} { if prev.d[d] == nil || next.d[d] == nil { continue } prevEnd := visibleFiles(prev.d[d].files).EndTxNum() nextEnd := visibleFiles(next.d[d].files).EndTxNum() if nextEnd < prevEnd { - panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a fill-enabled StateCache is wired — fill admission relies on view frontiers never decreasing", d, prevEnd, nextEnd)) + panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a single-version cache is wired — fill admission relies on view frontiers never decreasing", d, prevEnd, nextEnd)) } if prev.dhii[d] == nil || next.dhii[d] == nil { continue @@ -1916,7 +1925,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevII := prev.dhii[d].files.EndTxNum() nextII := next.dhii[d].files.EndTxNum() if nextII < prevII { - panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a fill-enabled StateCache is wired — DomainVisibleEnd derives view frontiers from it", d, prevII, nextII)) + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a single-version cache is wired — DomainVisibleEnd derives view frontiers from it", d, prevII, nextII)) } } } @@ -2675,6 +2684,10 @@ func (at *AggregatorRoTx) MetricsCollector() *kvmetrics.Collector { return at.a.metricsCollector } +func (at *AggregatorRoTx) ForbidVisibilityLowering() { + at.a.ForbidVisibilityLowering() +} + func (at *AggregatorRoTx) Dirs() datadir.Dirs { return at.a.dirs } func (at *AggregatorRoTx) standaloneIIs() []*InvertedIndexRoTx { return at.iis[:at.iisCount] } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 2d4be50b798..6ea12af0f06 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -260,3 +260,31 @@ func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { require.Panics(t, func() { agg.recalcVisibleFiles(nil) }, "lowering a history-II end while values ends stay put must trip the forbid assert") } + +func TestVisibilityLowering_GuardsCommitmentDomain(t *testing.T) { + t.Parallel() + _, agg := testDbAndAggregatorv3(t, alignStepSize) + + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + require.NoError(t, agg.OpenFolder()) + + agg.Unalign(kv.CommitmentDomain) + agg.ForbidVisibilityLowering() + + agg.dirtyFilesLock.Lock() + defer agg.dirtyFilesLock.Unlock() + dropped := 0 + agg.d[kv.CommitmentDomain].dirtyFiles.CloseIf(func(item *FilesItem) bool { + if item.endTxNum == 2*alignStepSize { + dropped++ + return true + } + return false + }) + require.Equal(t, 1, dropped) + + require.Panics(t, func() { agg.recalcVisibleFiles(nil) }, + "BranchCache fill admission requires the commitment frontier to remain monotonic") +} diff --git a/db/state/aggregator_close_test.go b/db/state/aggregator_close_test.go index e7ccb2f6d32..0120919be96 100644 --- a/db/state/aggregator_close_test.go +++ b/db/state/aggregator_close_test.go @@ -181,7 +181,7 @@ func TestAggregatorCloseReleasesBranchCache(t *testing.T) { require.NotNil(t, cd.branchCache, "precondition: BranchCache is set when USE_STATE_CACHE is on") prefix := []byte{0x01, 0x02} - cd.branchCache.Put(prefix, []byte{0xaa, 0xbb}, 1, 1) + cd.branchCache.Put(prefix, []byte{0xaa, 0xbb}, 1) _, _, ok := cd.branchCache.Get(prefix) require.True(t, ok, "precondition: entry is cached before Close") diff --git a/db/state/commitment_convert_blackbox_test.go b/db/state/commitment_convert_blackbox_test.go index 3eb4fe614ed..1b06356a756 100644 --- a/db/state/commitment_convert_blackbox_test.go +++ b/db/state/commitment_convert_blackbox_test.go @@ -474,7 +474,7 @@ func computeCommitmentRoot(t *testing.T, db kv.TemporalRwDB) []byte { tx, err := db.BeginTemporalRw(t.Context()) require.NoError(t, err) defer tx.Rollback() - domains, err := execctx.NewSharedDomains(t.Context(), tx, log.New()) + domains, err := execctx.NewSharedDomains(t.Context(), tx, log.New(), execctx.WithoutSharedBranchCache()) require.NoError(t, err) defer domains.Close() rh, err := domains.ComputeCommitment(t.Context(), tx, false, 0, 0, "", nil) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index dd2002b5134..25a3f53ce20 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -17,15 +17,25 @@ package execctx_test import ( + "errors" "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" + "github.com/erigontech/erigon/execution/commitment" ) +type commitErrorTx struct { + kv.TemporalRwTx + err error +} + +func (tx *commitErrorTx) Commit() error { return tx.err } + // Use Commit (not Flush) so the rebuilt branch refreshes the BranchCache entry. func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { stepSize := uint64(100) @@ -67,3 +77,127 @@ func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { require.NoError(t, err) require.Equal(t, []byte("v2-branch-bytes"), v, "fresh SD must read the latest committed branch, not the stale read-through entry") } + +func TestSpeculativeUnwindDetachesWithoutChangingBranchCache(t *testing.T) { + db := newTestDb(t, 100) + ctx := t.Context() + + 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() + + provider, ok := roTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + require.NotNil(t, branchCache) + + stateVersion, err := rawdb.GetStateVersion(roTx) + require.NoError(t, err) + branchCache.Publisher().Initialize(stateVersion) + + key := []byte{0xa0, 0xb0} + published := branchCache.View(stateVersion) + published.Fill(key, []byte("canonical-cache-only"), 1) + + sd.Unwind(50, nil) + + value, _, ok := published.Get(key) + require.True(t, ok, "a speculative unwind must not mutate the process-global branch generation") + require.Equal(t, []byte("canonical-cache-only"), value) + + value, _, err = sd.GetLatest(kv.CommitmentDomain, roTx, key) + require.NoError(t, err) + require.Empty(t, value, "the rewound SharedDomains must detach from the canonical branch generation") +} + +func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { + db := newTestDb(t, 100) + ctx := t.Context() + logger := log.New() + key := []byte{0xa0, 0xb0} + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedSD, err := execctx.NewSharedDomains(ctx, seedTx, logger) + require.NoError(t, err) + require.NoError(t, seedSD.DomainPut(kv.CommitmentDomain, seedTx, key, []byte("durable"), 1, nil)) + require.NoError(t, seedSD.Commit(ctx, seedTx)) + seedSD.Close() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindSD, err := execctx.NewSharedDomains(ctx, unwindTx, logger) + require.NoError(t, err) + defer unwindSD.Close() + + provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + stateVersion, err := rawdb.GetStateVersion(unwindTx) + require.NoError(t, err) + oldView := branchCache.View(stateVersion) + cacheOnlyKey := []byte{0xa0, 0xc0} + oldView.Fill(cacheOnlyKey, []byte("discarded-fork"), 2) + + var diffs [kv.DomainLen][]kv.DomainEntryDiff + unwindSD.Unwind(0, &diffs) + value, _, ok := oldView.Get(cacheOnlyKey) + require.True(t, ok, "the cache must keep serving the still-durable version before Commit") + require.Equal(t, []byte("discarded-fork"), value) + require.NoError(t, unwindSD.Commit(ctx, unwindTx)) + + _, _, ok = oldView.Get(cacheOnlyKey) + require.False(t, ok, "committing the unwind must revoke views of the discarded version") + + readTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer readTx.Rollback() + newStateVersion, err := rawdb.GetStateVersion(readTx) + require.NoError(t, err) + _, _, ok = branchCache.View(newStateVersion).Get(cacheOnlyKey) + require.False(t, ok, "the unwound generation must not retain a cache-only discarded branch") +} + +func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { + db := newTestDb(t, 100) + ctx := t.Context() + logger := log.New() + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedSD, err := execctx.NewSharedDomains(ctx, seedTx, logger) + require.NoError(t, err) + require.NoError(t, seedSD.Commit(ctx, seedTx)) + seedSD.Close() + + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, rwTx, logger) + require.NoError(t, err) + defer sd.Close() + + provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + stateVersion, err := rawdb.GetStateVersion(rwTx) + require.NoError(t, err) + view := branchCache.View(stateVersion) + key := []byte{0xa0, 0xb0} + view.Fill(key, []byte("durable"), 1) + + sentinel := errors.New("injected commit failure") + err = sd.Commit(ctx, &commitErrorTx{TemporalRwTx: rwTx, err: sentinel}) + require.ErrorIs(t, err, sentinel) + + value, _, ok := view.Get(key) + require.True(t, ok, "a failed database commit must restore the previous branch generation") + require.Equal(t, []byte("durable"), value) +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 5b2f7219606..a99513a78a7 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -82,39 +82,53 @@ type accHolder interface { SetChangesetAccumulator(acc *changeset.StateChangeSet) } -// cacheViewFor binds a cache handle to the state version of tx. Most reads use -// the base transaction and reuse the construction-time metadata stored on -// SharedDomains. Reads through another transaction re-evaluate both its -// version and whether its domain view has an exact frontier. -func (sd *SharedDomains) cacheViewFor(tx kv.TemporalTx) cache.ReadView { - if sd.stateCache == nil || tx == nil { - return cache.ReadView{} +type cacheViews struct { + state cache.ReadView + branch commitment.BranchReadView +} + +// cacheViewsFor binds both process-global caches to the state version of tx. +// Most reads use the base transaction and reuse the construction-time +// metadata stored on SharedDomains. Reads through another transaction +// re-evaluate its version and exact domain frontiers. +func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { + if tx == nil { + return cacheViews{} } var stateVersion uint64 + var stateEligible, branchEligible bool if tx.ViewID() == sd.baseViewID { - if !sd.baseStateVersionKnown || !sd.baseCacheViewEligible { - return cache.ReadView{} + if !sd.baseStateVersionKnown { + return cacheViews{} } stateVersion = sd.baseStateVersion + stateEligible = sd.baseStateCacheEligible + branchEligible = sd.baseBranchCacheEligible } else { var err error stateVersion, err = rawdb.GetStateVersion(tx) if err != nil { - return cache.ReadView{} - } - if !stateCacheViewEligible(tx) { - return cache.ReadView{} + return cacheViews{} } + stateEligible = cacheViewEligible(tx, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) + branchEligible = cacheViewEligible(tx, kv.CommitmentDomain) + } + var views cacheViews + if sd.stateCache != nil && stateEligible { + views.state = sd.stateCache.View(stateVersion) } - return sd.stateCache.View(stateVersion) + if sd.branchCache != nil && branchEligible { + views.branch = sd.branchCache.View(stateVersion) + } + return views } -// stateCacheViewEligible rejects a dependency-clamped domain view. Such a view +// cacheViewEligible rejects a dependency-clamped domain view. Such a view // mixes database values with older file values and may later expose newer // files without changing PlainStateVersion; a fill from it could therefore // outlive the snapshot that produced the value. -func stateCacheViewEligible(tx kv.TemporalTx) bool { - for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { +func cacheViewEligible(tx kv.TemporalTx, domains ...kv.Domain) bool { + for _, domain := range domains { if _, ok := tx.Debug().DomainVisibleEnd(domain); !ok { return false } @@ -144,10 +158,11 @@ type SharedDomains struct { // These fields describe the database snapshot used to construct this // SharedDomains. The common read path reuses them instead of reading cache // eligibility metadata for every GetLatest call. - baseViewID uint64 - baseStateVersion uint64 - baseStateVersionKnown bool - baseCacheViewEligible bool + baseViewID uint64 + baseStateVersion uint64 + baseStateVersionKnown bool + baseStateCacheEligible bool + baseBranchCacheEligible bool txNum uint64 currentStep kv.Step @@ -176,10 +191,11 @@ type SharedDomains struct { // only when this SharedDomains owns publication of durable canonical state; // a speculative SharedDomains may read the cache but cannot move its // generation or change its authoritative entries. - stateCache *cache.StateCache - cachePublisher cache.Publisher - cachePublication *cache.Publication - clearStateCache bool + stateCache *cache.StateCache + cachePublisher cache.Publisher + // Unwind and Merge preserve this flag after detaching the reader so a later + // canonical Commit clears entries from the discarded state. + clearStateCache bool // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code // cache, reached via temporalGetter so an addr-keyed reader can serve a @@ -192,13 +208,13 @@ type SharedDomains struct { // swap+compute+restore window, so a later unwind reads stale prev-values. changesetMu sync.Mutex - // branchCache is the aggregator-scope commitment-branch cache. It sits - // behind sd.mem and sd.parent.mem in the read chain (consulted only after - // both miss, before the aggTx files/MDBX read), so writers' in-flight - // bytes always mask the cache and cross-SD pollution is impossible. - // May be nil for test setups whose AggTx doesn't implement - // commitment.BranchCacheProvider. - branchCache *commitment.BranchCache + // branchCache is the aggregator-scope commitment cache. Local and parent + // memory overlays take precedence; the PlainStateVersion view then prevents + // one SharedDomains from observing another transaction's branch generation. + branchCache *commitment.BranchCache + branchPublisher commitment.BranchPublisher + // Like clearStateCache, this survives reader detachment and Merge. + clearBranchCache bool // collector is the process-level KV-read metrics collector (aggregator // scope). Finished per-worker metrics are sent here (ownership transfer) @@ -217,8 +233,8 @@ type SharedDomains struct { // adaptivePinController decides which contracts get pinned based on observed // miss pressure. nil when branchCache is nil or the adaptive layer is disabled. - // Its miss callback is wired via Bind in EnableParaTrieDB; OnBlockComplete fires - // from Commit using the in-flight (pre-Commit) tx. + // Commit plans from the in-flight tx and publishes the staged pin changes + // only after that transaction is durable. adaptivePinController *commitment.AdaptivePinController } @@ -254,13 +270,14 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: tx.Debug().StepSize(), - baseViewID: tx.ViewID(), - baseStateVersion: stateVersion, - baseStateVersionKnown: stateVersionErr == nil, - baseCacheViewEligible: stateCacheViewEligible(tx), + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: tx.Debug().StepSize(), + baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, + baseStateVersionKnown: stateVersionErr == nil, + baseStateCacheEligible: cacheViewEligible(tx, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain), + baseBranchCacheEligible: cacheViewEligible(tx, kv.CommitmentDomain), } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -274,6 +291,10 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, branchCache = p.BranchCache() } sd.branchCache = branchCache + if branchCache != nil { + forbidVisibilityLowering(tx.AggTx()) + sd.branchPublisher = branchCache.Publisher() + } if p, ok := tx.AggTx().(kvmetrics.MetricsCollectorProvider); ok { sd.collector = p.MetricsCollector() } @@ -349,6 +370,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.clearStateCache { + sd.stateCache = nil + sd.clearStateCache = true + } + if other.clearBranchCache { + sd.branchCache = nil + sd.clearBranchCache = true + } // Merge block-level metadata from other's overlay into ours by flushing // other's overlay writes directly into our overlay (which implements kv.RwTx). @@ -470,9 +499,9 @@ func (sd *SharedDomains) domainPutNoLock(domain kv.Domain, roTx kv.TemporalTx, k type temporalGetter struct { sd *SharedDomains tx kv.TemporalTx - // view binds the shared state cache to tx's read view once per getter, - // keeping the per-read path allocation-free. - view cache.ReadView + // views bind both process-global caches to tx once per getter, keeping the + // per-read path allocation-free. + views cacheViews // m is an optional per-worker metrics instance to record reads into. nil // (the AsGetter default) collects nothing — there is no process-wide // accumulator, since AsGetter is used by many concurrent goroutines (RPC, @@ -482,7 +511,7 @@ type temporalGetter struct { } func (gt *temporalGetter) GetLatest(name kv.Domain, k []byte) (v []byte, step kv.Step, err error) { - return gt.sd.getLatestMetered(name, gt.tx, k, gt.m, gt.view) + return gt.sd.getLatestMetered(name, gt.tx, k, gt.m, gt.views) } // GetLatestContext is the context-aware read: it records into the per-worker, @@ -492,7 +521,7 @@ func (gt *temporalGetter) GetLatest(name kv.Domain, k []byte) (v []byte, step kv // lock. Optional method — callers type-assert for it (mirrors the existing // AggregatorRoTx.MeteredGetLatest pattern). func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, k []byte) (v []byte, step kv.Step, err error) { - return gt.sd.getLatestMetered(name, gt.tx, k, kvmetrics.MetricsFromContext(ctx), gt.view) + return gt.sd.getLatestMetered(name, gt.tx, k, kvmetrics.MetricsFromContext(ctx), gt.views) } // GetCodeSize returns the length of the code at addr without loading the @@ -503,7 +532,7 @@ func (gt *temporalGetter) GetLatestContext(ctx context.Context, name kv.Domain, // Callers (ReaderV3.ReadAccountCodeSize, etc.) type-assert on this method // so the existing kv.TemporalGetter interface is unchanged. func (gt *temporalGetter) GetCodeSize(addr []byte, _ uint64) (int, bool, error) { - return gt.sd.getCodeSize(gt.tx, gt.view, addr) + return gt.sd.getCodeSize(gt.tx, gt.views, addr) } // GetCode returns contract code via the content-addressed fast path (see @@ -512,7 +541,7 @@ func (gt *temporalGetter) GetCodeSize(addr []byte, _ uint64) (int, bool, error) // (ReaderV3.ReadAccountCode) type-assert this method; setters must not use it // (they resolve prevVal through GetLatest, which is addr-keyed). func (gt *temporalGetter) GetCode(addr []byte, _ uint64) ([]byte, bool, error) { - return gt.sd.getCode(gt.tx, gt.view, addr) + return gt.sd.getCode(gt.tx, gt.views, addr) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -524,13 +553,13 @@ func (gt *temporalGetter) StepsInFiles(entitySet ...kv.Domain) kv.Step { } func (sd *SharedDomains) AsGetter(tx kv.TemporalTx) kv.TemporalGetter { - return &temporalGetter{sd: sd, tx: tx, view: sd.cacheViewFor(tx)} + return &temporalGetter{sd: sd, tx: tx, views: sd.cacheViewsFor(tx)} } // AsGetterNoMetrics is an explicit-intent alias of AsGetter (collects no // metrics), for concurrent callers (RPC/engine) where that is deliberate. func (sd *SharedDomains) AsGetterNoMetrics(tx kv.TemporalTx) kv.TemporalGetter { - return &temporalGetter{sd: sd, tx: tx, view: sd.cacheViewFor(tx)} + return &temporalGetter{sd: sd, tx: tx, views: sd.cacheViewsFor(tx)} } // AsGetterMetered returns a getter that records reads into the caller's own @@ -538,7 +567,7 @@ func (sd *SharedDomains) AsGetterNoMetrics(tx kv.TemporalTx) kv.TemporalGetter { // caller hands it off via MergeMetrics at task end (a lock per task, not per // read) and allocates a fresh instance. Used by parallel-exec workers. func (sd *SharedDomains) AsGetterMetered(tx kv.TemporalTx, m *kvmetrics.DomainMetrics) kv.TemporalGetter { - return &temporalGetter{sd: sd, tx: tx, m: m, view: sd.cacheViewFor(tx)} + return &temporalGetter{sd: sd, tx: tx, m: m, views: sd.cacheViewsFor(tx)} } // MergeMetrics hands a boundary producer's accumulator to BOTH sinks: the @@ -709,39 +738,14 @@ func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumb // Unwind drops [txNumUnwindTo, ∞) func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][]kv.DomainEntryDiff) { sd.mem.Unwind(txNumUnwindTo, changeset) - // Tx/epoch-aware unwind of the commitment BranchCache: every cached branch - // whose bytes belong to the rolled-back window (txN at/above the unwind - // point, superseded epoch) is now stale vs the post-unwind canonical state. - // Unwind(txNum) bumps the epoch and lowers the floor (O(1), no scan); those - // entries are dropped lazily on their next Get — covering entries seeded by - // the read-pop and the trunk preload that the changeset-gated Invalidate - // below misses (which is what left stale committed branches a fork-validation - // then read as a wrong trie root). The explicit Invalidate of the unwound - // changeset keys is a redundant fast path for keys known dead right now. - if sd.branchCache != nil { - sd.branchCache.Unwind(txNumUnwindTo) - if changeset != nil { - for _, diff := range changeset[kv.CommitmentDomain] { - sd.branchCache.Invalidate([]byte(diff.Key)) - } - } - } - if sd.cachePublisher.Enabled() { - // A canonical unwind changes the durable state represented by the - // process-global cache. Revoke current views now, then clear all entries - // when Commit publishes the post-unwind PlainStateVersion. Clearing is - // required because the unwind changeset is not a complete list of cache - // entries that may have come from the discarded fork. - if sd.cachePublication == nil { - sd.cachePublication = sd.cachePublisher.Begin() - } - sd.clearStateCache = true - } else { - // A speculative unwind changes only this SharedDomains and may later be - // discarded. Detach its reader so the rewound local view cannot read - // from or fill the cache's durable canonical generation. - sd.stateCache = nil - } + // The global caches still describe the durable database until Commit. + // Detaching keeps this rewound overlay from reading or filling that version. + // If the overlay is committed, both caches are cleared because the unwind + // diff is not a complete inventory of entries from the discarded fork. + sd.stateCache = nil + sd.branchCache = nil + sd.clearStateCache = true + sd.clearBranchCache = true } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -809,7 +813,9 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } - sd.stateCache = stateCache + if !sd.clearStateCache { + sd.stateCache = stateCache + } } // SetCanonicalStateCache attaches the same reader and also grants publication @@ -823,10 +829,12 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { // prevents speculative rollback or unwind from changing globally visible // cache state. func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { - sd.SetStateCacheReader(stateCache) - if sd.stateCache == nil || !sd.baseStateVersionKnown { + if !dbg.UseStateCache || stateCache == nil || !sd.baseStateVersionKnown { return } + if !sd.clearStateCache { + sd.stateCache = stateCache + } sd.cachePublisher = stateCache.Publisher() sd.cachePublisher.Initialize(sd.baseStateVersion) } @@ -850,6 +858,10 @@ func GuardAggregatorForCache(db any, sc *cache.StateCache) { panic(fmt.Sprintf("assert: StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) } agg := h.Agg() + forbidVisibilityLowering(agg) +} + +func forbidVisibilityLowering(agg any) { f, ok := agg.(interface{ ForbidVisibilityLowering() }) if !ok { panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) @@ -938,8 +950,6 @@ func (sd *SharedDomains) Close() { return } - sd.cachePublication.Abort() - sd.cachePublication = nil sd.flushRequestMetrics() sd.SetTxNum(0) sd.ResetPendingUpdates() @@ -979,28 +989,12 @@ 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 -} - -// Commit makes the database transition durable before exposing its cache -// generation. It first flushes state while collecting cache updates, revokes -// the old ReadViews, and commits tx. Only after a successful commit does it -// apply the collected updates and publish the resulting PlainStateVersion. -// -// Any error before the database commit leaves the entries unchanged and Abort -// restores the previous generation. tx must be dedicated to this flush because -// Commit consumes it. +// Commit flushes and commits tx before publishing either process-global cache. +// Cache views are revoked only around the database commit, so they continue to +// serve the old durable version while the in-memory batch is being flushed. +// tx must be dedicated to this operation because Commit consumes it. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) - defer func() { - sd.cachePublication.Abort() - sd.cachePublication = nil - }() runValidate := func() error { for _, v := range validate { @@ -1014,7 +1008,9 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return nil } - if sd.branchCache == nil && !sd.cachePublisher.Enabled() && sd.codeStore == nil { + stateCacheEnabled := sd.cachePublisher.Enabled() + branchCacheEnabled := sd.branchPublisher.Enabled() + if !stateCacheEnabled && !branchCacheEnabled && sd.codeStore == nil { if err := sd.flushMem(ctx, tx); err != nil { return err } @@ -1024,44 +1020,49 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return tx.Commit() } - // Stash cache updates during the flush and publish them only after the - // database commit succeeds. - var pending []cacheUpdate - 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, + var stateUpdates []cache.Update + stashState := func(domain kv.Domain) kv.FlushOption { + return kv.WithFlushCallback(domain, func(key, value []byte, step kv.Step, _ uint64) { + stateUpdates = append(stateUpdates, cache.Update{ + Domain: domain, + Key: bytes.Clone(key), + Value: bytes.Clone(value), + Step: step, }) }) } + var branchUpdates []commitment.BranchUpdate + stashBranch := kv.WithFlushCallback(kv.CommitmentDomain, func(key, value []byte, step kv.Step, _ uint64) { + branchUpdates = append(branchUpdates, commitment.BranchUpdate{ + Key: bytes.Clone(key), + Value: bytes.Clone(value), + Step: uint64(step), + }) + }) + var opts []kv.FlushOption - if sd.branchCache != nil { - opts = append(opts, stash(kv.CommitmentDomain)) + if branchCacheEnabled { + opts = append(opts, stashBranch) } - if sd.cachePublisher.Enabled() { - opts = append(opts, stash(kv.AccountsDomain), stash(kv.StorageDomain)) + if stateCacheEnabled { + opts = append(opts, stashState(kv.AccountsDomain), stashState(kv.StorageDomain)) } // CodeDomain flush stashes state-cache updates and collects code for the // persistent store. The code-store MDBX write is deferred to after flushMem — // an in-callback tx.Put interleaves with the in-progress domain flush and // corrupts it (reorg/unwind wrong root). var codeStoreWrites [][2][]byte - if sd.cachePublisher.Enabled() || sd.codeStore != nil { - opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(k []byte, v []byte, step kv.Step, txNum uint64) { - if sd.codeStore != nil && len(v) > 0 { - codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(v), append([]byte(nil), v...)}) + if stateCacheEnabled || sd.codeStore != nil { + opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(key, value []byte, step kv.Step, _ uint64) { + if sd.codeStore != nil && len(value) > 0 { + codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(value), bytes.Clone(value)}) } - if sd.cachePublisher.Enabled() { - pending = append(pending, cacheUpdate{ - domain: kv.CodeDomain, - key: append([]byte(nil), k...), - val: append([]byte(nil), v...), - step: step, - txN: txNum, + if stateCacheEnabled { + stateUpdates = append(stateUpdates, cache.Update{ + Domain: kv.CodeDomain, + Key: bytes.Clone(key), + Value: bytes.Clone(value), + Step: step, }) } })) @@ -1077,100 +1078,112 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun if err := runValidate(); err != nil { return err } - // Adaptive pin promotions/demotions run on the in-flight (pre-Commit) tx so - // the preload sees the just-flushed bytes. - if sd.adaptivePinController != nil { - if ttx, ok := tx.(kv.TemporalTx); ok { - reader := func(prefix []byte) ([]byte, uint64, bool, error) { - v, step, err := ttx.GetLatest(kv.CommitmentDomain, prefix) - if err != nil { - return nil, 0, false, err - } - return v, uint64(step), len(v) > 0, nil - } - factory := func() (commitment.BatchBranchResolver, func(), error) { - return pinBranchResolver(ttx), nil, nil - } - provider := func(contractHash []byte) map[string][]byte { - m := map[string][]byte{} - c, cerr := ttx.CursorDupSort(kv.TblCommitmentVals) - if cerr != nil { - return m - } - defer c.Close() - evenFrom, evenTo, oddFrom, oddTo := commitment.ContractTrunkKeyRanges(commitment.ContractNibbles(contractHash)) - // Bound the scan by the per-contract pin ceiling — the preload can't - // pin more than that, so gathering further is pure waste on the - // Commit path. A nil `to` (all-0xff prefix) means scan to the range's - // natural end, not stop immediately. - budget := sd.adaptivePinController.PerContractBudgetBytes() - scanned := 0 - scan := func(from, to []byte) { - for k, v, err := c.Seek(from); k != nil; k, v, err = c.NextNoDup() { - if err != nil { - return // best-effort residency hint: keep what was gathered - } - if to != nil && bytes.Compare(k, to) >= 0 { - return - } - if len(v) < 8 { - continue - } - m[string(k)] = bytes.Clone(v[8:]) - if scanned += len(k) + len(v); scanned >= budget { - return - } - } - } - scan(evenFrom, evenTo) - scan(oddFrom, oddTo) - return m - } - sd.adaptivePinController.OnBlockComplete(ctx, sd.txNum, reader, factory, provider) - } - } + var stateVersion uint64 - if sd.cachePublisher.Enabled() { + if stateCacheEnabled || branchCacheEnabled { var err error stateVersion, err = rawdb.GetStateVersion(tx) if err != nil { return fmt.Errorf("read plain state version: %w", err) } - if sd.cachePublication == nil { - sd.cachePublication = sd.cachePublisher.Begin() + } + + var statePublication *cache.Publication + var branchPublication *commitment.BranchPublication + var adaptivePlan *commitment.AdaptivePinPlan + defer func() { + statePublication.Abort() + branchPublication.Abort() + adaptivePlan.Abort() + }() + + if branchCacheEnabled { + if !sd.clearBranchCache { + adaptivePlan = sd.planAdaptivePins(tx) } + branchPublication = sd.branchPublisher.Begin() + } + if stateCacheEnabled { + statePublication = sd.cachePublisher.Begin() } if err := tx.Commit(); err != nil { return err } - stateUpdates := make([]cache.Update, 0, len(pending)) - 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 - } - stateUpdates = append(stateUpdates, cache.Update{ - Domain: u.domain, - Key: u.key, - Value: u.val, - Step: u.step, - }) + + statePublication.Publish(stateVersion, stateUpdates, sd.clearStateCache) + statePublication = nil + branchPublication.Publish(stateVersion, branchUpdates, sd.clearBranchCache, adaptivePlan) + branchPublication = nil + adaptivePlan.Commit() + adaptivePlan = nil + if sd.clearBranchCache && sd.adaptivePinController != nil { + sd.adaptivePinController.Reset() } - sd.cachePublication.Publish(stateVersion, stateUpdates, sd.clearStateCache) - sd.cachePublication = nil sd.clearStateCache = false + sd.clearBranchCache = false return nil } +// planAdaptivePins reads the uncommitted transaction because it contains the +// branches just flushed by Commit. The plan does not mutate BranchCache until +// it is included in the post-commit publication. +func (sd *SharedDomains) planAdaptivePins(tx kv.RwTx) *commitment.AdaptivePinPlan { + if sd.adaptivePinController == nil { + return nil + } + ttx, ok := tx.(kv.TemporalTx) + if !ok { + return nil + } + reader := func(prefix []byte) ([]byte, uint64, bool, error) { + value, step, err := ttx.GetLatest(kv.CommitmentDomain, prefix) + if err != nil { + return nil, 0, false, err + } + return value, uint64(step), len(value) > 0, nil + } + factory := func() (commitment.BatchBranchResolver, func(), error) { + return pinBranchResolver(ttx), nil, nil + } + provider := func(contractHash []byte) map[string][]byte { + branches := map[string][]byte{} + cursor, err := ttx.CursorDupSort(kv.TblCommitmentVals) + if err != nil { + return branches + } + defer cursor.Close() + + evenFrom, evenTo, oddFrom, oddTo := commitment.ContractTrunkKeyRanges(commitment.ContractNibbles(contractHash)) + budget := sd.adaptivePinController.PerContractBudgetBytes() + scanned := 0 + scan := func(from, to []byte) { + for key, value, err := cursor.Seek(from); key != nil; key, value, err = cursor.NextNoDup() { + if err != nil { + return + } + if to != nil && bytes.Compare(key, to) >= 0 { + return + } + if len(value) < 8 { + continue + } + branches[string(key)] = bytes.Clone(value[8:]) + if scanned += len(key) + len(value); scanned >= budget { + return + } + } + } + scan(evenFrom, evenTo) + scan(oddFrom, oddTo) + return branches + } + return sd.adaptivePinController.PlanBlock(sd.txNum, reader, factory, provider) +} + // TemporalDomain satisfaction. Collects no read metrics — see // temporalGetter.GetLatest for why there is no process-wide accumulator. func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) { - return sd.getLatestMetered(domain, tx, k, nil, sd.cacheViewFor(tx)) + return sd.getLatestMetered(domain, tx, k, nil, sd.cacheViewsFor(tx)) } // GetLatestContext is the context-aware read for callers that read on behalf of @@ -1179,7 +1192,7 @@ func (sd *SharedDomains) GetLatest(domain kv.Domain, tx kv.TemporalTx, k []byte) // without any shared accumulator or lock. Mirrors temporalGetter.GetLatestContext // for readers that hold the SD directly (e.g. the committer's asOfStateReader). func (sd *SharedDomains) GetLatestContext(ctx context.Context, domain kv.Domain, tx kv.TemporalTx, k []byte) (v []byte, step kv.Step, err error) { - return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheViewFor(tx)) + return sd.getLatestMetered(domain, tx, k, kvmetrics.MetricsFromContext(ctx), sd.cacheViewsFor(tx)) } // servableUnderBound gates a cached entry against an in-flight unwind's @@ -1193,7 +1206,7 @@ func servableUnderBound(cStep, maxStep kv.Step) bool { // 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 // into the shared DomainMetrics later via Merge. -func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k []byte, wm *kvmetrics.DomainMetrics, view cache.ReadView) (v []byte, step kv.Step, err error) { +func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k []byte, wm *kvmetrics.DomainMetrics, views cacheViews) (v []byte, step kv.Step, err error) { if tx == nil { return nil, 0, errors.New("sd.GetLatest: unexpected nil tx") } @@ -1236,16 +1249,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k type MeteredGetter interface { MeteredGetLatest(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.DomainMetrics, start time.Time) (v []byte, step kv.Step, ok bool, err error) } - // MeteredGetterWithTxN exposes the txN of the read so the - // BranchCache entry can be tagged; falls back to MeteredGetter - // when only the legacy interface is implemented (test stubs). - type MeteredGetterWithTxN interface { - MeteredGetLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error) - } // stateCache holds committed values shared across domain readers. if sd.stateCache != nil { - v, cStep, ok := view.GetWithStep(domain, k) + v, cStep, ok := views.state.GetWithStep(domain, k) if ok && !servableUnderBound(cStep, maxStep) { ok = false } @@ -1291,7 +1298,7 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // CommitmentDomain only. Snapshot-isolated readers must disable it because // concurrent commits can advance the cache beyond their transaction view. if domain == kv.CommitmentDomain && sd.branchCache != nil { - if cv, cStepU64, ok := sd.branchCache.Get(k); ok { + if cv, cStepU64, ok := views.branch.Get(k); ok { // Get returns the on-disk step index directly — do NOT divide by // StepSize (that double-division collapsed cStep to ~0, defeating the // gate). @@ -1302,15 +1309,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k } } - var readTxN uint64 - var txNKnown bool - switch aggTx := tx.AggTx().(type) { - case MeteredGetterWithTxN: - v, step, readTxN, _, err = aggTx.MeteredGetLatestWithTxN(domain, k, tx, maxStep, wm, start) - txNKnown = true - case MeteredGetter: + if aggTx, ok := tx.AggTx().(MeteredGetter); ok { v, step, _, err = aggTx.MeteredGetLatest(domain, k, tx, maxStep, wm, start) - default: + } else { v, step, err = tx.GetLatest(domain, k) } if err != nil { @@ -1320,13 +1321,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // View freshness is rechecked while the fill is serialized against cache // publication. if sd.stateCache != nil && sd.stateCache.Caches(domain) { - view.Fill(domain, k, v, step) + views.state.Fill(domain, k, v, step) } - // Only cache a branch when the read's txN is known: a txN=0 entry would - // be treated as immortal by UnwindTo, so skip the Put rather than insert - // an entry that can never be unwind-evicted. - if domain == kv.CommitmentDomain && sd.branchCache != nil && len(v) > 0 && txNKnown { - sd.branchCache.Put(k, v, uint64(step), readTxN) + if domain == kv.CommitmentDomain && sd.branchCache != nil { + views.branch.Fill(k, v, uint64(step)) } return v, step, nil @@ -1354,10 +1352,10 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k // Returns (size, true, nil) on success and (0, false, nil) only when // CodeDomain itself confirms no code. func (sd *SharedDomains) GetCodeSize(tx kv.TemporalTx, addr []byte, _ uint64) (int, bool, error) { - return sd.getCodeSize(tx, sd.cacheViewFor(tx), addr) + return sd.getCodeSize(tx, sd.cacheViewsFor(tx), addr) } -func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr []byte) (int, bool, error) { +func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, views cacheViews, addr []byte) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1365,12 +1363,12 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // Fast path: when we can resolve codeHash from the account cache AND // the size is in the size cache, return without loading bytes. if sd.stateCache != nil { - if codeHash := sd.codeHashForAddr(tx, view, addr); len(codeHash) > 0 { - if size, ok := view.GetCodeSizeByHash(codeHash); ok { + if codeHash := sd.codeHashForAddr(tx, views.state, addr); len(codeHash) > 0 { + if size, ok := views.state.GetCodeSizeByHash(codeHash); ok { return size, true, nil } - if cv, ok := view.GetCodeByHash(codeHash); ok { - view.FillCodeSize(codeHash, len(cv)) + if cv, ok := views.state.GetCodeByHash(codeHash); ok { + views.state.FillCodeSize(codeHash, len(cv)) return len(cv), true, nil } } @@ -1379,7 +1377,7 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // Cold path: authoritative read via the normal SD.GetLatest chain. // Populates L1, codeHashToCode, and (via PutWithCodeHash) the size layer for // future callers. - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, views) if err != nil { return 0, false, err } @@ -1404,10 +1402,10 @@ func (sd *SharedDomains) getCodeSize(tx kv.TemporalTx, view cache.ReadView, addr // the write. Setters therefore resolve prevVal through GetLatest, which is // addr-keyed (domain-faithful); only getters use this codeHash shortcut. func (sd *SharedDomains) GetCode(tx kv.TemporalTx, addr []byte, _ uint64) ([]byte, bool, error) { - return sd.getCode(tx, sd.cacheViewFor(tx), addr) + return sd.getCode(tx, sd.cacheViewsFor(tx), addr) } -func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []byte) ([]byte, bool, error) { +func (sd *SharedDomains) getCode(tx kv.TemporalTx, views cacheViews, addr []byte) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1418,9 +1416,9 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []b // a stateObject's stale snapshot) is reorg-safe. var codeHash []byte if sd.stateCache != nil || sd.codeStore != nil { - if codeHash = sd.codeHashForAddr(tx, view, addr); len(codeHash) > 0 { + if codeHash = sd.codeHashForAddr(tx, views.state, addr); len(codeHash) > 0 { if sd.stateCache != nil { - if cv, ok := view.GetCodeByHash(codeHash); ok { + if cv, ok := views.state.GetCodeByHash(codeHash); ok { return cv, true, nil } } @@ -1433,7 +1431,7 @@ func (sd *SharedDomains) getCode(tx kv.TemporalTx, view cache.ReadView, addr []b } // Cold path: authoritative addr-keyed read (also populates the caches). - v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, view) + v, _, err := sd.getLatestMetered(kv.CodeDomain, tx, addr, nil, views) if err != nil { return nil, false, err } diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index ec64ac2583b..a439f48e2ab 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -9,7 +9,7 @@ import ( // external test package (which cannot import db/state to build a SharedDomains // internally without an import cycle). func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum uint64) []byte { - return sd.codeHashForAddr(tx, sd.cacheViewFor(tx), addr) + return sd.codeHashForAddr(tx, sd.cacheViewsFor(tx).state, addr) } // SetStateCacheForTest attaches a cache unconditionally, bypassing the @@ -17,7 +17,9 @@ 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 + if !sd.clearStateCache { + sd.stateCache = sc + } if sd.baseStateVersionKnown { sd.cachePublisher = sc.Publisher() sd.cachePublisher.Initialize(sd.baseStateVersion) @@ -25,5 +27,7 @@ func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { } func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { - sd.stateCache = sc + if !sd.clearStateCache { + sd.stateCache = sc + } } diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 853ad487c54..85ae5e31034 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -36,7 +36,9 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false } } -// WithoutSharedBranchCache keeps commitment reads within the transaction snapshot. +// WithoutSharedBranchCache keeps commitment reads within the transaction +// snapshot. Use it when tooling intentionally lowers or rebuilds the +// commitment-file frontier. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index cd66f707915..323fc081b42 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -83,8 +83,9 @@ func currentStateCacheView(t *testing.T, stateCache *cache.StateCache) cache.Rea return stateCache.View(stateVersion) } -// During an in-flight unwind the cache is inactive, so the assertion compares -// the bounded database read without observing the old cache generation. +// During an in-flight unwind this SharedDomains is detached from StateCache, +// so the assertion compares the bounded database read without observing the +// cache generation that still serves readers of the durable state. func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { // Mutates dbg.AssertStateCache — must not run in parallel with tests that // read it on the SD read path. @@ -174,7 +175,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) require.Equal(t, v1, v, "the inactive cache must fall through to the bounded database read") } -func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { +func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -182,6 +183,9 @@ func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { db := newTestDb(t, stepSize) sc := newSmallStateCache() key, _, v2, diffs := twoStepRows(t, db, sc) + stateVersion, ok := sc.CurrentStateVersion() + require.True(t, ok) + sc.Publisher().Begin().Publish(stateVersion, nil, true) roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) @@ -197,11 +201,14 @@ func TestReadFill_SkipsInFlightUnwindRow(t *testing.T) { require.NoError(t, err) require.Equal(t, v2, got) - _, ok := sc.CurrentStateVersion() - require.False(t, ok, "the cache must stay inactive until the unwind commits") + currentVersion, ok := sc.CurrentStateVersion() + require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") + require.Equal(t, stateVersion, currentVersion) + _, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + require.False(t, ok, "the detached SharedDomains must not fill from its rewound database view") } -func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { +func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -228,6 +235,9 @@ func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { sd.SetTxNum(20) require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, value, 20, nil)) require.NoError(t, sd.Commit(ctx, rwTx)) + stateVersion, ok := sc.CurrentStateVersion() + require.True(t, ok) + sc.Publisher().Begin().Publish(stateVersion, nil, true) stepBytes := make([]byte, 8) binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) @@ -246,8 +256,11 @@ func TestCodeHashFill_SkipsInFlightUnwindRow(t *testing.T) { got := sd2.CodeHashForAddr(roTx, key, 20) require.Equal(t, codeHash[:], got) - _, ok := sc.CurrentStateVersion() - require.False(t, ok, "the cache must stay inactive until the unwind commits") + currentVersion, ok := sc.CurrentStateVersion() + require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") + require.Equal(t, stateVersion, currentVersion) + _, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + require.False(t, ok, "code-hash lookup through the rewound view must not fill the durable cache") } func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { diff --git a/db/state/squeeze.go b/db/state/squeeze.go index 5fc49944dd2..ecf76858ebc 100644 --- a/db/state/squeeze.go +++ b/db/state/squeeze.go @@ -383,7 +383,7 @@ func CheckCommitmentForPrint(ctx context.Context, rwDb kv.TemporalRwDB) (string, } defer rwTx.Rollback() - domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New(), execctx.WithoutSharedBranchCache()) if err != nil { return "", err } @@ -540,7 +540,8 @@ func RebuildCommitmentFilesWithHistory(ctx context.Context, rwDb kv.TemporalRwDB rebuildCfg := commitment.DefaultTrieConfig() rebuildCfg.Variant = execctx.PickTrieVariant() - domains, err := execctx.NewSharedDomains(ctx, rwTx, logger, execctx.WithTrieConfig(rebuildCfg)) + domains, err := execctx.NewSharedDomains(ctx, rwTx, logger, + execctx.WithTrieConfig(rebuildCfg), execctx.WithoutSharedBranchCache()) if err != nil { return nil, err } @@ -641,7 +642,8 @@ func RebuildCommitmentFilesWithHistory(ctx context.Context, rwDb kv.TemporalRwDB } flushCfg := commitment.DefaultTrieConfig() flushCfg.Variant = execctx.PickTrieVariant() - domains, err = execctx.NewSharedDomains(ctx, rwTx, logger, execctx.WithTrieConfig(flushCfg)) + domains, err = execctx.NewSharedDomains(ctx, rwTx, logger, + execctx.WithTrieConfig(flushCfg), execctx.WithoutSharedBranchCache()) if err != nil { return err } @@ -1055,7 +1057,8 @@ func RebuildCommitmentFiles(ctx context.Context, rwDb kv.TemporalRwDB, txNumsRea iterTrieCfg := rebuildTrieCfg iterTrieCfg.Variant = trieVariant - domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New(), execctx.WithTrieConfig(iterTrieCfg)) + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New(), + execctx.WithTrieConfig(iterTrieCfg), execctx.WithoutSharedBranchCache()) if err != nil { return nil, err } diff --git a/execution/cache/coherence/coherence.go b/execution/cache/coherence/coherence.go deleted file mode 100644 index bf32e6d23a0..00000000000 --- a/execution/cache/coherence/coherence.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2024 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 coherence holds the (epoch, floor) unwind-coherence primitive shared -// by the state, code and commitment-branch caches. It is a leaf package (only -// sync/atomic + math) so both execution/cache and execution/commitment can -// embed it without an import cycle. -package coherence - -import ( - "math" - "sync/atomic" -) - -// gen is the immutable (epoch, floor) pair. Held behind a single atomic.Pointer -// so a reader never observes a torn epoch/floor mid-Unwind. -type gen struct { - epoch uint32 - floor uint64 -} - -// gen values are immutable, so all zero-value Gen reads can share pristine. -var pristine = &gen{floor: math.MaxUint64} - -// Gen tracks unwind coherence for a cache: every entry is stamped (txNum, epoch), -// and is stale iff it was written in a superseded epoch AND its txNum is at or -// above the unwind floor (the first rolled-back txNum). Unwind bumps the epoch -// and lowers the floor — O(1), scan-free; stale entries drop lazily on their -// next read. Within one cache generation, the floor only decreases, so a -// shallower later unwind can't resurrect entries a deeper one invalidated. -// -// The zero value is ready to use. -type Gen struct { - state atomic.Pointer[gen] -} - -// Reset starts a new generation for an empty cache without reusing an epoch. -// It advances the epoch and lifts the unwind floor in one CAS, so concurrent -// Reset and Unwind calls are ordered without losing either update. -func (g *Gen) Reset() { - for { - cur := g.state.Load() - epoch := uint32(0) - if cur != nil { - epoch = cur.epoch - } - if g.state.CompareAndSwap(cur, &gen{epoch: epoch + 1, floor: math.MaxUint64}) { - return - } - } -} - -// Epoch returns the current epoch, for stamping freshly written entries. -func (g *Gen) Epoch() uint32 { - return g.load().epoch -} - -func (g *Gen) load() *gen { - if s := g.state.Load(); s != nil { - return s - } - return pristine -} - -// IsStale reports whether an entry stamped (txNum, epoch) reflects dead-fork -// state after an unwind, judged by the live coherence state. -func (g *Gen) IsStale(txNum uint64, epoch uint32) bool { - return g.Snapshot().IsStale(txNum, epoch) -} - -// Snapshot is an immutable (epoch, floor) pair for judging entries against -// coherence captured at a chosen point. Taking it before loading cache storage -// prevents a concurrent Clear from pairing a retired entry with a reset floor. -type Snapshot struct { - epoch uint32 - floor uint64 -} - -// Snapshot returns the current (epoch, floor) pair. -func (g *Gen) Snapshot() Snapshot { - s := g.load() - return Snapshot{epoch: s.epoch, floor: s.floor} -} - -// IsStale reports whether an entry stamped (txNum, epoch) reflects dead-fork -// state under this snapshot. -func (s Snapshot) IsStale(txNum uint64, epoch uint32) bool { - return epoch != s.epoch && txNum >= s.floor -} - -// Unwind bumps the epoch and lowers the floor to unwindToTxN (the first -// rolled-back txNum, e.g. Min(UnwindPoint+1)). Atomic against concurrent readers -// and other Unwinds: the new (epoch+1, min(floor, unwindToTxN)) pair is published -// in one CAS, so IsStale/Epoch never see a half-applied update. -func (g *Gen) Unwind(unwindToTxN uint64) { - for { - cur := g.state.Load() // raw (may be nil); CAS must compare the stored pointer - epoch := uint32(0) - floor := uint64(math.MaxUint64) - if cur != nil { - epoch, floor = cur.epoch, cur.floor - } - if unwindToTxN < floor { - floor = unwindToTxN - } - if g.state.CompareAndSwap(cur, &gen{epoch: epoch + 1, floor: floor}) { - return - } - } -} diff --git a/execution/cache/coherence/coherence_test.go b/execution/cache/coherence/coherence_test.go deleted file mode 100644 index 059125ddd4e..00000000000 --- a/execution/cache/coherence/coherence_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// Copyright 2024 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 coherence - -import ( - "math" - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestGen_FloorAndEpoch(t *testing.T) { - var g Gen - require.Equal(t, uint32(0), g.Epoch()) - - // Before any unwind nothing is stale. - require.False(t, g.IsStale(100, 0)) - - // Unwind to txN=50: entries at/above 50 from the old epoch are stale. - g.Unwind(50) - require.Equal(t, uint32(1), g.Epoch()) - require.True(t, g.IsStale(50, 0), "txN>=floor, old epoch -> stale") - require.True(t, g.IsStale(80, 0)) - require.False(t, g.IsStale(49, 0), "txN survives") - require.False(t, g.IsStale(50, 1), "current epoch -> not stale") - - // A shallower later unwind must not raise the floor. - g.Unwind(70) - require.Equal(t, uint32(2), g.Epoch()) - require.True(t, g.IsStale(50, 0), "floor stays at the deeper 50") - require.False(t, g.IsStale(49, 1)) -} - -// TestGen_ConcurrentUnwindNoTear runs Unwind against concurrent readers; the -// race detector must see no torn (epoch, floor) read and the epoch must equal -// the number of Unwinds. -func TestGen_ConcurrentUnwindNoTear(t *testing.T) { - var g Gen - - const unwinds = 200 - var wg sync.WaitGroup - for range 8 { - wg.Go(func() { - for i := range 1000 { - _ = g.IsStale(uint64(i), 0) - _ = g.Epoch() - } - }) - } - wg.Go(func() { - for i := range unwinds { - g.Unwind(uint64(unwinds - i)) // descending -> floor keeps dropping - } - }) - wg.Wait() - require.Equal(t, uint32(unwinds), g.Epoch()) - require.True(t, g.IsStale(1, 0), "deepest floor reached 1") -} - -func TestGen_ResetAdvancesEpochAndLiftsFloor(t *testing.T) { - var g Gen - g.Unwind(50) - - g.Reset() - - s := g.Snapshot() - require.Equal(t, uint32(2), s.epoch) - require.Equal(t, uint64(math.MaxUint64), s.floor) -} - -func TestGen_SnapshotKeepsDeadEntryStaleAcrossReset(t *testing.T) { - var g Gen - entryEpoch := g.Epoch() - g.Unwind(50) - read := g.Snapshot() - - g.Reset() - - require.True(t, read.IsStale(60, entryEpoch)) - require.False(t, g.IsStale(60, entryEpoch)) -} - -func TestGen_ConcurrentResetAndUnwindPreserveEveryEpochBump(t *testing.T) { - var g Gen - - const operations = 200 - var wg sync.WaitGroup - for range operations { - wg.Go(g.Reset) - wg.Go(func() { g.Unwind(50) }) - } - wg.Wait() - - require.Equal(t, uint32(operations*2), g.Epoch()) -} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 676613e032d..e46e1e7a3d7 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -19,8 +19,6 @@ package cache import ( "bytes" "strings" - "sync" - "sync/atomic" "github.com/c2h5oh/datasize" @@ -38,25 +36,12 @@ const ( avgStorageEntryBytes = 80 ) -// cacheGeneration is immutable after it is stored. ReadView uses pointer -// identity, rather than stateVersion alone, as its validity token: revoking -// and later republishing the same state version must not make an old view -// valid again. -type cacheGeneration struct { - stateVersion uint64 - active bool -} - // StateCache holds account, storage, and code values for exactly one durable -// PlainStateVersion. Each reader carries the generation pointer that was -// current for its database snapshot. Publication replaces that pointer before -// changing entries, so old readers turn cache accesses into misses instead of -// observing a mixture of the old and new states. +// PlainStateVersion. Each reader carries a version token for its database +// snapshot. Publication revokes that view before changing entries, so old +// readers miss instead of observing a mixture of the old and new states. type StateCache struct { - // Reads remain lock-free. admissionMu only serializes generation changes - // against fills, whose source read may have started before publication. - generation atomic.Pointer[cacheGeneration] - admissionMu sync.RWMutex + version PlainStateVersionGate caches [kv.DomainLen]Cache disableFills bool @@ -104,23 +89,11 @@ func NewDefaultStateCache() *StateCache { ) } -func (c *StateCache) generationFor(stateVersion uint64) *cacheGeneration { - generation := c.generation.Load() - if generation == nil || !generation.active || generation.stateVersion != stateVersion { - return nil - } - return generation -} - // CurrentStateVersion reports the durable PlainStateVersion represented by // all cache layers. It returns false while publication is in progress because // the old version has been revoked and the new version is not visible yet. func (c *StateCache) CurrentStateVersion() (uint64, bool) { - generation := c.generation.Load() - if generation == nil || !generation.active { - return 0, false - } - return generation.stateVersion, true + return c.version.CurrentStateVersion() } func (c *StateCache) getWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { @@ -156,7 +129,7 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { } func (c *StateCache) fill( - generation *cacheGeneration, + version PlainStateVersionView, domain kv.Domain, key, value []byte, step kv.Step, @@ -167,16 +140,13 @@ func (c *StateCache) fill( } value = bytes.Clone(value) - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if c.generation.Load() != generation { - return - } - cache.PutIfAbsent(key, value, step) + version.Admit(func() { + cache.PutIfAbsent(key, value, step) + }) } func (c *StateCache) fillCode( - generation *cacheGeneration, + version PlainStateVersionView, key, value []byte, step kv.Step, ) { @@ -187,38 +157,29 @@ func (c *StateCache) fillCode( value = bytes.Clone(value) codeHash := crypto.Keccak256(value) - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if c.generation.Load() != generation { - return - } - codeCache.PutWithCodeHashIfAbsent(key, value, codeHash, step) + version.Admit(func() { + codeCache.PutWithCodeHashIfAbsent(key, value, codeHash, step) + }) } -func (c *StateCache) seedAddrCodeHash(generation *cacheGeneration, addr []byte, hash [32]byte) { +func (c *StateCache) seedAddrCodeHash(version PlainStateVersionView, addr []byte, hash [32]byte) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if c.generation.Load() != generation { - return - } - codeCache.PutAddrCodeHash(addr, hash) + version.Admit(func() { + codeCache.PutAddrCodeHash(addr, hash) + }) } -func (c *StateCache) fillCodeSize(generation *cacheGeneration, codeHash []byte, size int) { +func (c *StateCache) fillCodeSize(version PlainStateVersionView, codeHash []byte, size int) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if c.generation.Load() != generation { - return - } - codeCache.PutCodeSizeByCodeHash(codeHash, size) + version.Admit(func() { + codeCache.PutCodeSizeByCodeHash(codeHash, size) + }) } func (c *StateCache) deleteAddrCodeHash(addr []byte) { @@ -274,9 +235,7 @@ func (c *StateCache) clearLocked() { } func (c *StateCache) Close() { - c.admissionMu.Lock() - c.generation.Store(&cacheGeneration{}) - c.admissionMu.Unlock() + c.version.Close() for _, cache := range c.caches { if cache != nil { cache.Close() @@ -312,7 +271,7 @@ func (c *StateCache) PrintStatsAndReset() { // Update is one value written by the database transaction being published. // Step is retained because GetLatest must return the value's source step; cache -// coherence depends only on the published PlainStateVersion. +// validity depends only on the published PlainStateVersion. type Update struct { Domain kv.Domain Key []byte @@ -324,14 +283,20 @@ type Update struct { // receive only ReadView, while code that makes a database state durable uses a // Publisher to move every cache layer to the same PlainStateVersion. type Publisher struct { - c *StateCache + c *StateCache + version PlainStateVersionPublisher } // Publisher returns a handle that can change the cache's canonical generation. // It must not be given to speculative execution whose writes may be discarded. -func (c *StateCache) Publisher() Publisher { return Publisher{c: c} } +func (c *StateCache) Publisher() Publisher { + if c == nil { + return Publisher{} + } + return Publisher{c: c, version: c.version.Publisher()} +} -func (p Publisher) Enabled() bool { return p.c != nil } +func (p Publisher) Enabled() bool { return p.c != nil && p.version.Enabled() } // Initialize binds the cache to the durable version seen by its canonical // owner. Existing entries are preserved when the version already matches. A @@ -341,22 +306,7 @@ func (p Publisher) Initialize(stateVersion uint64) { if p.c == nil { return } - c := p.c - c.admissionMu.Lock() - defer c.admissionMu.Unlock() - - current := c.generation.Load() - if current != nil && current.active { - if current.stateVersion == stateVersion { - return - } - } else if current != nil { - panic("state cache publication already in progress") - } - - c.generation.Store(&cacheGeneration{}) - c.clearLocked() - c.generation.Store(&cacheGeneration{stateVersion: stateVersion, active: true}) + p.version.Initialize(stateVersion, p.c.clearLocked) } // Publication represents one pending transition of the durable database @@ -364,9 +314,8 @@ func (p Publisher) Initialize(stateVersion uint64) { // Abort can restore the previous generation if the transaction rolls back. // Publish consumes the transition after the database commit succeeds. type Publication struct { - c *StateCache - previous *cacheGeneration - transition *cacheGeneration + c *StateCache + version *PlainStateVersionPublication } // Begin revokes every existing ReadView and prevents creation of a new live @@ -376,17 +325,7 @@ func (p Publisher) Begin() *Publication { if p.c == nil { return nil } - c := p.c - c.admissionMu.Lock() - defer c.admissionMu.Unlock() - - previous := c.generation.Load() - if previous != nil && !previous.active { - panic("state cache publication already in progress") - } - transition := &cacheGeneration{} - c.generation.Store(transition) - return &Publication{c: c, previous: previous, transition: transition} + return &Publication{c: p.c, version: p.version.Begin()} } // Abort restores the previous generation after a failed or abandoned database @@ -396,12 +335,7 @@ func (p *Publication) Abort() { if p == nil || p.c == nil { return } - p.c.admissionMu.Lock() - defer p.c.admissionMu.Unlock() - if p.c.generation.Load() != p.transition { - panic("state cache publication changed before abort") - } - p.c.generation.Store(p.previous) + p.version.Abort() p.c = nil } @@ -418,18 +352,14 @@ func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) if p == nil || p.c == nil { return } - p.c.admissionMu.Lock() - defer p.c.admissionMu.Unlock() - if p.c.generation.Load() != p.transition { - panic("state cache publication changed before publish") - } - if clear { - p.c.clearLocked() - } - for i := range updates { - p.c.applyLocked(updates[i]) - } - p.c.generation.Store(&cacheGeneration{stateVersion: stateVersion, active: true}) + p.version.Publish(stateVersion, func() { + if clear { + p.c.clearLocked() + } + for i := range updates { + p.c.applyLocked(updates[i]) + } + }) p.c = nil } diff --git a/execution/cache/version_gate.go b/execution/cache/version_gate.go new file mode 100644 index 00000000000..abaa7478096 --- /dev/null +++ b/execution/cache/version_gate.go @@ -0,0 +1,220 @@ +// 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 cache + +import ( + "sync" + "sync/atomic" +) + +// versionGeneration is immutable after publication. Pointer identity prevents +// a view revoked by one publication from becoming valid when the same +// PlainStateVersion is published again. +type versionGeneration struct { + stateVersion uint64 + active bool +} + +// PlainStateVersionGate binds lock-free cache reads and serialized fills to one +// durable PlainStateVersion. It controls visibility only; each cache remains +// responsible for storing and applying its own entries. +type PlainStateVersionGate struct { + current atomic.Pointer[versionGeneration] + admissionMu sync.RWMutex +} + +// PlainStateVersionView is the immutable validity token held by one cache view. +type PlainStateVersionView struct { + gate *PlainStateVersionGate + generation *versionGeneration +} + +// View returns an inert token unless stateVersion is currently published. +func (g *PlainStateVersionGate) View(stateVersion uint64) PlainStateVersionView { + if g == nil { + return PlainStateVersionView{} + } + generation := g.current.Load() + if generation == nil || !generation.active || generation.stateVersion != stateVersion { + return PlainStateVersionView{} + } + return PlainStateVersionView{gate: g, generation: generation} +} + +// Current reports whether the generation is still published. +func (v PlainStateVersionView) Current() bool { + return v.gate != nil && v.generation != nil && v.gate.current.Load() == v.generation +} + +// Admit runs fill only if the view remains current while serialized against +// publication. The early check avoids taking the read lock for stale views. +func (v PlainStateVersionView) Admit(fill func()) bool { + if !v.Current() { + return false + } + v.gate.admissionMu.RLock() + defer v.gate.admissionMu.RUnlock() + if v.gate.current.Load() != v.generation { + return false + } + fill() + return true +} + +// CurrentStateVersion reports the active durable version. It returns false +// before initialization and while a publication is in progress. +func (g *PlainStateVersionGate) CurrentStateVersion() (uint64, bool) { + if g == nil { + return 0, false + } + generation := g.current.Load() + if generation == nil || !generation.active { + return 0, false + } + return generation.stateVersion, true +} + +// PlainStateVersionPublisher is the mutation capability for one version gate. +type PlainStateVersionPublisher struct { + gate *PlainStateVersionGate +} + +// Publisher returns a handle that can initialize and publish the gate. +func (g *PlainStateVersionGate) Publisher() PlainStateVersionPublisher { + if g == nil { + return PlainStateVersionPublisher{} + } + return PlainStateVersionPublisher{gate: g} +} + +func (p PlainStateVersionPublisher) Enabled() bool { return p.gate != nil } + +// Initialize binds the gate to stateVersion. A version mismatch runs clear +// while all fills are blocked because existing entries have an unknown origin +// relative to the requested database snapshot. +func (p PlainStateVersionPublisher) Initialize(stateVersion uint64, clear func()) { + if p.gate == nil { + return + } + gate := p.gate + gate.admissionMu.Lock() + defer gate.admissionMu.Unlock() + + current := gate.current.Load() + if current != nil && current.active { + if current.stateVersion == stateVersion { + return + } + } else if current != nil { + // The owner already revoked the old version and will publish the + // transaction's version after its database commit. A concurrent owner + // cannot initialize from this in-between state, so it stays inert. + return + } + + gate.current.Store(&versionGeneration{}) + if clear != nil { + clear() + } + gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) +} + +// PlainStateVersionPublication represents one pending durable transition. +type PlainStateVersionPublication struct { + gate *PlainStateVersionGate + previous *versionGeneration + transition *versionGeneration +} + +// Begin revokes all existing views without changing cache entries. +func (p PlainStateVersionPublisher) Begin() *PlainStateVersionPublication { + if p.gate == nil { + return nil + } + gate := p.gate + gate.admissionMu.Lock() + defer gate.admissionMu.Unlock() + + previous := gate.current.Load() + if previous != nil && !previous.active { + panic("cache version publication already in progress") + } + transition := &versionGeneration{} + gate.current.Store(transition) + return &PlainStateVersionPublication{gate: gate, previous: previous, transition: transition} +} + +// Abort restores the previous generation when no cache entries were changed. +func (p *PlainStateVersionPublication) Abort() { + if p == nil || p.gate == nil { + return + } + p.gate.admissionMu.Lock() + defer p.gate.admissionMu.Unlock() + if p.gate.current.Load() != p.transition { + panic("cache version publication changed before abort") + } + p.gate.current.Store(p.previous) + p.gate = nil +} + +// Publish applies the committed cache transition before exposing stateVersion. +func (p *PlainStateVersionPublication) Publish(stateVersion uint64, apply func()) { + if p == nil || p.gate == nil { + return + } + p.gate.admissionMu.Lock() + defer p.gate.admissionMu.Unlock() + if p.gate.current.Load() != p.transition { + panic("cache version publication changed before publish") + } + if apply != nil { + apply() + } + p.gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) + p.gate = nil +} + +// Reset revokes all views, clears the cache, and leaves it unpublished. The +// next durable publication can start from this empty state. +func (g *PlainStateVersionGate) Reset(clear func()) { + if g == nil { + return + } + g.admissionMu.Lock() + defer g.admissionMu.Unlock() + current := g.current.Load() + if current != nil && !current.active { + panic("cannot reset cache during version publication") + } + g.current.Store(&versionGeneration{}) + if clear != nil { + clear() + } + g.current.Store(nil) +} + +// Close permanently revokes current views. The owner may then close its cache +// storage without admitting new fills. +func (g *PlainStateVersionGate) Close() { + if g == nil { + return + } + g.admissionMu.Lock() + g.current.Store(&versionGeneration{}) + g.admissionMu.Unlock() +} diff --git a/execution/cache/view.go b/execution/cache/view.go index 4fa9236af98..8d7593ee0f4 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -27,8 +27,8 @@ import "github.com/erigontech/erigon/db/kv" // read from an old database snapshot therefore cannot enter a newer cache // generation. The zero value is inert and safely falls back to the database. type ReadView struct { - c *StateCache - generation *cacheGeneration + c *StateCache + version PlainStateVersionView } // View returns a live handle only when the cache currently represents @@ -40,15 +40,15 @@ func (c *StateCache) View(stateVersion uint64) ReadView { if c == nil { return ReadView{} } - generation := c.generationFor(stateVersion) - if generation == nil { + version := c.version.View(stateVersion) + if !version.Current() { return ReadView{} } - return ReadView{c: c, generation: generation} + return ReadView{c: c, version: version} } func (v ReadView) current() bool { - return v.c != nil && v.generation != nil && v.c.generation.Load() == v.generation + return v.c != nil && v.version.Current() } func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { @@ -109,22 +109,22 @@ func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { return } if domain == kv.CodeDomain { - v.c.fillCode(v.generation, key, value, step) + v.c.fillCode(v.version, key, value, step) return } - v.c.fill(v.generation, domain, key, value, step) + v.c.fill(v.version, domain, key, value, step) } func (v ReadView) SeedAddrCodeHash(addr []byte, hash [32]byte) { if !v.canFill() { return } - v.c.seedAddrCodeHash(v.generation, addr, hash) + v.c.seedAddrCodeHash(v.version, addr, hash) } func (v ReadView) FillCodeSize(codeHash []byte, size int) { if !v.canFill() { return } - v.c.fillCodeSize(v.generation, codeHash, size) + v.c.fillCodeSize(v.version, codeHash, size) } diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index 8fe3f1403c5..e512d96d51c 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -17,8 +17,9 @@ package commitment import ( - "context" + "bytes" "encoding/hex" + "maps" "sync" "sync/atomic" @@ -64,7 +65,7 @@ type AdaptivePinController struct { } // ParallelResolverFactory builds a fresh BatchBranchResolver for one -// OnBlockComplete call. release() is invoked after the controller is done +// PlanBlock call. release() is invoked after the controller is done // with the resolver. Returning (nil, nil, err) makes the controller fall // back to the serial-BFS path for this block. type ParallelResolverFactory func() (resolve BatchBranchResolver, release func(), err error) @@ -74,19 +75,115 @@ type ParallelResolverFactory func() (resolve BatchBranchResolver, release func() // Empty/nil result is valid (no overlay; resolver is authoritative). type DbBranchesProvider func(contractHash []byte) map[string][]byte +type adaptiveCacheMutation struct { + prefix []byte + value []byte + step uint64 + invalidate bool +} + +type adaptiveCacheMutations struct { + entries []adaptiveCacheMutation +} + +func (m *adaptiveCacheMutations) PinEntry(prefix, value []byte, step uint64) { + m.entries = append(m.entries, adaptiveCacheMutation{ + prefix: bytes.Clone(prefix), + value: bytes.Clone(value), + step: step, + }) +} + +func (m *adaptiveCacheMutations) Invalidate(prefix []byte) { + m.entries = append(m.entries, adaptiveCacheMutation{ + prefix: bytes.Clone(prefix), + invalidate: true, + }) +} + +func (m *adaptiveCacheMutations) apply(cache *BranchCache) { + for i := range m.entries { + entry := &m.entries[i] + if entry.invalidate { + cache.Invalidate(entry.prefix) + continue + } + cache.PinEntry(entry.prefix, entry.value, entry.step) + } +} + +// AdaptivePinPlan holds controller state and cache mutations derived from an +// uncommitted transaction. Commit or Abort must be called exactly once. +type AdaptivePinPlan struct { + controller *AdaptivePinController + mutations adaptiveCacheMutations + previousStates map[[32]byte]*adaptiveContractState + observedMisses map[[32]byte]uint64 + txNum uint64 + promoted int + extended int + demoted int +} + type adaptiveContractState struct { contractHash [32]byte - promotedAtTxNum uint64 preload *ContractTrunkPreload // serial-BFS path (nil when parallel) parallel *ContractTrunkPreloadParallel // parallel-wave-BFS path (nil when serial) coldBlocksInARow int } -func (s *adaptiveContractState) pinnedTotal() int { - if s.parallel != nil { - return s.parallel.PinnedTotal() +func cloneAdaptiveStateHeaders(states map[[32]byte]*adaptiveContractState) map[[32]byte]*adaptiveContractState { + cloned := make(map[[32]byte]*adaptiveContractState, len(states)) + for hash, state := range states { + stateCopy := *state + cloned[hash] = &stateCopy + } + return cloned +} + +func cloneByteSlices(values [][]byte) [][]byte { + cloned := make([][]byte, len(values)) + for i := range values { + cloned[i] = bytes.Clone(values[i]) } - return s.preload.PinnedTotal() + return cloned +} + +func cloneSerialPreload(preload *ContractTrunkPreload) *ContractTrunkPreload { + cloned := *preload + cloned.contractHash = bytes.Clone(preload.contractHash) + cloned.queue = make([]pathDepth, len(preload.queue)) + for i := range preload.queue { + cloned.queue[i] = pathDepth{ + path: bytes.Clone(preload.queue[i].path), + depth: preload.queue[i].depth, + } + } + cloned.pinnedPrefixes = cloneByteSlices(preload.pinnedPrefixes) + return &cloned +} + +func clonePathKeys(values []pathKey) []pathKey { + cloned := make([]pathKey, len(values)) + for i := range values { + cloned[i] = pathKey{ + path: bytes.Clone(values[i].path), + key: bytes.Clone(values[i].key), + } + } + return cloned +} + +func cloneParallelPreload(preload *ContractTrunkPreloadParallel) *ContractTrunkPreloadParallel { + cloned := *preload + cloned.contractHash = bytes.Clone(preload.contractHash) + cloned.frontier = clonePathKeys(preload.frontier) + cloned.pendingChildren = clonePathKeys(preload.pendingChildren) + cloned.pinnedPrefixes = cloneByteSlices(preload.pinnedPrefixes) + cloned.scratchDbHits = nil + cloned.scratchDbVals = nil + cloned.scratchFileMiss = nil + return &cloned } func (s *adaptiveContractState) usedBytes() int { @@ -150,6 +247,23 @@ func (c *AdaptivePinController) PerContractBudgetBytes() int { return c.cfg.PerContractMaxBudgetBytes } +// Reset forgets residency state after BranchCache is cleared. Without this, +// the controller would treat removed pins as live and wait for their normal +// demotion before promoting them again. +func (c *AdaptivePinController) Reset() { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.states = make(map[[32]byte]*adaptiveContractState) + c.misses.Range(func(key, _ any) bool { + c.misses.Delete(key) + return true + }) + mxAdaptiveActive.SetUint64(0) +} + func (c *AdaptivePinController) onCacheMiss(prefix []byte) { hash, ok := ContractHashFromPrefix(prefix) if !ok { @@ -163,19 +277,22 @@ func (c *AdaptivePinController) onCacheMiss(prefix []byte) { v.(*atomic.Uint64).Add(1) } -// OnBlockComplete consumes the per-block miss snapshot and decides -// promotions, extensions, and demotions. Synchronous — preloads run -// inline so the new pin set is available for the next block's reads. -// -// The controller is aggregator-scoped (one owner across SharedDomains) so pin -// residency ages by block-access recency, not SD binds; the tx-scoped reader/ -// factory/provider are therefore passed per call rather than stored, and c.mu -// serializes concurrent callers. -func (c *AdaptivePinController) OnBlockComplete(ctx context.Context, txNum uint64, reader CommitmentReader, factory ParallelResolverFactory, provider DbBranchesProvider) { - misses := c.snapshotMisses() - +// PlanBlock computes promotions, extensions, and demotions from the +// uncommitted transaction without changing BranchCache. The returned plan +// keeps controller updates serialized until Commit or Abort. +func (c *AdaptivePinController) PlanBlock(txNum uint64, reader CommitmentReader, factory ParallelResolverFactory, provider DbBranchesProvider) *AdaptivePinPlan { c.mu.Lock() - defer c.mu.Unlock() + previousStates := c.states + c.states = cloneAdaptiveStateHeaders(previousStates) + misses := c.snapshotMisses() + observedMisses := make(map[[32]byte]uint64, len(misses)) + maps.Copy(observedMisses, misses) + plan := &AdaptivePinPlan{ + controller: c, + previousStates: previousStates, + observedMisses: observedMisses, + txNum: txNum, + } // One factory call per block, shared across all contracts. nil falls back to serial. var parallelResolve BatchBranchResolver @@ -193,8 +310,6 @@ func (c *AdaptivePinController) OnBlockComplete(ctx context.Context, txNum uint6 defer releaseParallel() } - var promoted, extended, demoted int - for hash, state := range c.states { n, hadMisses := misses[hash] if hadMisses && n > 0 { @@ -203,56 +318,90 @@ func (c *AdaptivePinController) OnBlockComplete(ctx context.Context, txNum uint6 if state.queueRemaining() > 0 && state.usedBytes() < c.cfg.PerContractMaxBudgetBytes { remaining := c.cfg.PerContractMaxBudgetBytes - state.usedBytes() step := min(c.cfg.ExtensionBudgetBytes, remaining) - if err := c.runExtensionLocked(ctx, state, txNum, step, parallelResolve, reader, provider); err != nil { + if err := c.runExtensionLocked(state, step, parallelResolve, reader, provider, &plan.mutations); err != nil { c.warnf("[adaptive-pin] extend failed", "hash", hex.EncodeToString(hash[:]), "err", err) } else { - extended++ + plan.extended++ } } continue } state.coldBlocksInARow++ if state.coldBlocksInARow >= c.cfg.DemoteCooldownBlocks { - c.demoteLocked(hash, state) + c.demoteLocked(state, &plan.mutations) delete(c.states, hash) - demoted++ + plan.demoted++ } } if len(misses) > 0 && len(c.states) < c.cfg.MaxPromotedContracts { candidates := pickPromotionCandidates(misses, c.cfg.PromoteThresholdMisses, c.cfg.MaxPromotedContracts-len(c.states)) for _, hash := range candidates { - state, err := c.promoteLocked(ctx, hash, txNum, parallelResolve, reader, provider) + state, err := c.promoteLocked(hash, parallelResolve, reader, provider, &plan.mutations) if err != nil { c.warnf("[adaptive-pin] initial-view failed", "hash", hex.EncodeToString(hash[:]), "err", err) continue } c.states[hash] = state - promoted++ + plan.promoted++ } } - if promoted > 0 { - mxAdaptivePromoted.AddUint64(uint64(promoted)) + return plan +} + +func (p *AdaptivePinPlan) apply() { + if p != nil && p.controller != nil { + p.mutations.apply(p.controller.cache) + } +} + +// Commit accepts the planned controller state after its cache mutations have +// been published with the database transaction. +func (p *AdaptivePinPlan) Commit() { + if p == nil || p.controller == nil { + return + } + c := p.controller + if p.promoted > 0 { + mxAdaptivePromoted.AddUint64(uint64(p.promoted)) } - if extended > 0 { - mxAdaptiveExtended.AddUint64(uint64(extended)) + if p.extended > 0 { + mxAdaptiveExtended.AddUint64(uint64(p.extended)) } - if demoted > 0 { - mxAdaptiveDemoted.AddUint64(uint64(demoted)) + if p.demoted > 0 { + mxAdaptiveDemoted.AddUint64(uint64(p.demoted)) } mxAdaptiveActive.SetUint64(uint64(len(c.states))) c.cache.PublishMetrics() - if c.logger != nil && (promoted+extended+demoted > 0 || len(c.states) > 0) { + if c.logger != nil && (p.promoted+p.extended+p.demoted > 0 || len(c.states) > 0) { c.logger.Info("[adaptive-pin]", - "txNum", txNum, + "txNum", p.txNum, "promoted_total", len(c.states), - "promoted_this_block", promoted, - "extended_this_block", extended, - "demoted_this_block", demoted, + "promoted_this_block", p.promoted, + "extended_this_block", p.extended, + "demoted_this_block", p.demoted, "cache_pinned_total", c.cache.PinnedCount()) } + p.controller = nil + c.mu.Unlock() +} + +// Abort restores the controller state and miss counters from before PlanBlock. +// No BranchCache rollback is needed because planning only records mutations. +func (p *AdaptivePinPlan) Abort() { + if p == nil || p.controller == nil { + return + } + c := p.controller + c.states = p.previousStates + for hash, count := range p.observedMisses { + value, _ := c.misses.LoadOrStore(hash, new(atomic.Uint64)) + value.(*atomic.Uint64).Add(count) + } + p.controller = nil + c.mu.Unlock() } func (c *AdaptivePinController) snapshotMisses() map[[32]byte]uint64 { @@ -268,65 +417,50 @@ func (c *AdaptivePinController) snapshotMisses() map[[32]byte]uint64 { } // demoteLocked: caller must hold c.mu. -func (c *AdaptivePinController) demoteLocked(hash [32]byte, state *adaptiveContractState) { +func (c *AdaptivePinController) demoteLocked(state *adaptiveContractState, mutations *adaptiveCacheMutations) { for _, prefix := range state.pinnedPrefixes() { - c.cache.Invalidate(prefix) - } - if c.logger != nil { - c.logger.Info("[adaptive-pin] demoted", - "hash", hex.EncodeToString(hash[:]), - "pinned_was", state.pinnedTotal(), - "used_mb_was", state.usedBytes()/(1<<20), - "cold_blocks", state.coldBlocksInARow) + mutations.Invalidate(prefix) } } -// promoteLocked: caller must hold c.mu. On error the partial pin set is rolled back. +// promoteLocked: caller must hold c.mu. func (c *AdaptivePinController) promoteLocked( - ctx context.Context, hash [32]byte, - txNum uint64, parallelResolve BatchBranchResolver, reader CommitmentReader, provider DbBranchesProvider, + mutations *adaptiveCacheMutations, ) (*adaptiveContractState, error) { + checkpoint := len(mutations.entries) if parallelResolve != nil { p, err := NewContractTrunkPreloadParallel(hash[:]) if err != nil { return nil, err } - p.pinTxNum = txNum var dbBranches map[string][]byte if provider != nil { dbBranches = provider(hash[:]) } - if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, dbBranches, parallelResolve, c.cache, c.logger); err != nil { - for _, prefix := range p.PinnedPrefixes() { - c.cache.Invalidate(prefix) - } + if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, dbBranches, parallelResolve, mutations, c.logger); err != nil { + mutations.entries = mutations.entries[:checkpoint] return nil, err } return &adaptiveContractState{ - contractHash: hash, - promotedAtTxNum: txNum, - parallel: p, + contractHash: hash, + parallel: p, }, nil } p, err := NewContractTrunkPreload(hash[:]) if err != nil { return nil, err } - p.pinTxNum = txNum - if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, reader, c.cache, c.logger); err != nil { - for _, prefix := range p.PinnedPrefixes() { - c.cache.Invalidate(prefix) - } + if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, reader, mutations, c.logger); err != nil { + mutations.entries = mutations.entries[:checkpoint] return nil, err } return &adaptiveContractState{ - contractHash: hash, - promotedAtTxNum: txNum, - preload: p, + contractHash: hash, + preload: p, }, nil } @@ -334,28 +468,27 @@ func (c *AdaptivePinController) promoteLocked( // (parallel vs serial); a serial state with a parallel resolver available // keeps using serial — switching mid-contract would lose the queue position. func (c *AdaptivePinController) runExtensionLocked( - ctx context.Context, state *adaptiveContractState, - txNum uint64, stepBudget int, parallelResolve BatchBranchResolver, reader CommitmentReader, provider DbBranchesProvider, + mutations *adaptiveCacheMutations, ) error { if state.parallel != nil { if parallelResolve == nil { return nil } + state.parallel = cloneParallelPreload(state.parallel) var dbBranches map[string][]byte if provider != nil { dbBranches = provider(state.contractHash[:]) } - state.parallel.pinTxNum = txNum - _, _, err := state.parallel.Run(stepBudget, dbBranches, parallelResolve, c.cache, c.logger) + _, _, err := state.parallel.Run(stepBudget, dbBranches, parallelResolve, mutations, c.logger) return err } - state.preload.pinTxNum = txNum - _, _, err := state.preload.Run(stepBudget, reader, c.cache, c.logger) + state.preload = cloneSerialPreload(state.preload) + _, _, err := state.preload.Run(stepBudget, reader, mutations, c.logger) return err } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index fc8bed57bcd..93561f0de3a 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -17,9 +17,13 @@ package commitment import ( + "bytes" "testing" + "github.com/stretchr/testify/require" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/commitment/nibbles" ) // A zero-value field means "unset", so the constructor's fallbacks must resolve @@ -47,3 +51,40 @@ func TestNewAdaptivePinController_ExplicitConfigWins(t *testing.T) { t.Fatalf("explicit config was overwritten: got %+v, want %+v", c.cfg, cfg) } } + +func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(1) + + cfg := DefaultAdaptivePinControllerConfig() + cfg.PromoteThresholdMisses = 1 + cfg.MaxPromotedContracts = 1 + controller := NewAdaptivePinController(branchCache, cfg, log.Root()) + + var contractHash [32]byte + contractHash[0] = 1 + prefix := nibbles.HexToCompact(ContractNibbles(contractHash[:])) + controller.onCacheMiss(prefix) + reader := func(key []byte) ([]byte, uint64, bool, error) { + if !bytes.Equal(key, prefix) { + return nil, 0, false, nil + } + return []byte{0, 0, 0, 0}, 1, true, nil + } + + plan := controller.PlanBlock(1, reader, nil, nil) + _, _, ok := branchCache.Get(prefix) + require.False(t, ok, "planning from an uncommitted transaction must not change BranchCache") + plan.Abort() + require.Empty(t, controller.states, "aborting the database transaction must restore controller state") + + plan = controller.PlanBlock(2, reader, nil, nil) + publication := publisher.Begin() + publication.Publish(2, nil, false, plan) + plan.Commit() + + _, _, ok = branchCache.View(2).Get(prefix) + require.True(t, ok, "publication must apply the staged pin") +} diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 3332d633763..841d2938141 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -25,7 +25,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/common/maphash" - "github.com/erigontech/erigon/execution/cache/coherence" + "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/commitment/nibbles" ) @@ -45,12 +45,13 @@ func isCommitmentStateKey(prefix []byte) bool { return bytes.Equal(prefix, KeyCommitmentState) } -// BranchCache stores commitment-trie branch data: a bounded LRU tail plus a -// never-evicted root slot, aggregator-scope and passive (the trie drives all -// reads/writes). Concurrent Get/Put/Invalidate are mechanically safe, but the -// writer stripes only make stamped publications atomic with Clear; callers must -// still ensure one logical mutation per prefix at the orchestrator. +// BranchCache stores commitment-trie branches in an aggregator-scope resident +// trunk and bounded LRU tail. Concurrent storage operations are safe; shared +// readers use View, and durable writers use Publisher, to keep all entries +// bound to one PlainStateVersion. type BranchCache struct { + version cache.PlainStateVersionGate + // Root tier — single slot for the root branch (always hottest, always // present). Atomic-pointer access so no lock is needed for the hot // read path. @@ -66,10 +67,9 @@ type BranchCache struct { accountTrunk *trunk // Pinned tier — one storageTrunk per hot contract, keyed by the 32-byte - // account hash. Entries never LRU-evict (sized by the residency policy) - // but still honor the (txN, epoch) unwind model. Lookup checks this tier - // between the account trunk and the tail. pinnedEntries counts filled - // storage slots across all storageTrunks. + // account hash. Entries never LRU-evict (sized by the residency policy). + // Lookup checks this tier between the account trunk and the tail. + // pinnedEntries counts filled storage slots across all storageTrunks. // Allocated on the first pin (via pinnedForWrite): a cache that never pins a // contract — the common case for short-lived caches over shallow tries — // never pays for the (min 32-bucket) concurrent map. @@ -104,7 +104,6 @@ type BranchCache struct { pinnedHits, pinnedMisses atomic.Uint64 tailHits, tailMisses atomic.Uint64 bytesServed atomic.Uint64 - staleEvicted atomic.Uint64 // entries dropped lazily on read after an unwind // onMiss fires when lookup misses all tiers. The residency/adaptive layer // (added separately) registers here to attribute miss pressure per @@ -117,14 +116,9 @@ type BranchCache struct { lastPublishedPinnedHits atomic.Uint64 lastPublishedPinnedMisses atomic.Uint64 - // putStripes make epoch sampling and publication atomic with Clear while + // putStripes serialize writes to one prefix and fence Clear while // preserving parallel writes to unrelated prefixes. putStripes [256]sync.Mutex - - // coh is the (epoch, floor) unwind-coherence primitive shared with the state - // and code caches: an entry is valid iff written in the current epoch OR its - // txN is below the unwind floor. - coh coherence.Gen } type branchCacheEntry struct { @@ -138,16 +132,6 @@ type branchCacheEntry struct { // in-memory tests but real callers should always pass the step // returned by aggTx.MeteredGetLatest / tx.GetLatest. step uint64 - - // txN is the txN the cached bytes are valid as of (an upper bound: the - // value's write txN). With epoch it gates reads after an unwind. 0 means - // "frozen/untracked" — predates any unwind, always served. - txN uint64 - - // epoch is the unwind generation the entry was written in. Disambiguates a - // txN reused across forks: an entry from a superseded epoch whose txN is at - // or above the unwind floor is dropped lazily on its next Get. - epoch uint32 } // MissCallback is invoked when lookup misses ALL tiers (root, account trunk, @@ -354,6 +338,7 @@ func NewBranchCache(tailCapacity int) *BranchCache { // Close drops this cache from the active-instance count so later BranchCaches // size their trunk depth against real concurrency. Idempotent. func (c *BranchCache) Close() { + c.version.Close() if c.closed.CompareAndSwap(false, true) { if t := c.tail.Load(); t != nil { t.Close() @@ -362,6 +347,13 @@ func (c *BranchCache) Close() { } } +// Reset clears cached branches and revokes all views until the next durable +// publication. It is required when the backing commitment view changes +// without advancing PlainStateVersion. +func (c *BranchCache) Reset() { + c.version.Reset(c.Clear) +} + // tailForWrite returns the LRU tail, allocating it on first use so a cache whose // tries never spill past the resident trunk pays nothing for it. func (c *BranchCache) tailForWrite() *tailLRU { @@ -626,10 +618,10 @@ func (c *BranchCache) store(prefix []byte, entry *branchCacheEntry) { } // PinEntry inserts or replaces a pinned cache entry for prefix in its contract's -// storage trunk (allocated on demand). Pinned entries never LRU-evict but still -// honor the (txN, epoch) unwind model. Data is copied; safe to mutate the input -// after the call. Non-storage prefixes (< 64 nibbles) fall through to the tail. -func (c *BranchCache) PinEntry(prefix []byte, data []byte, step, txN uint64) { +// storage trunk (allocated on demand). Data is copied; safe to mutate the input +// after the call. The adaptive controller calls it only inside a publication. +// Non-storage prefixes (< 64 nibbles) fall through to the tail. +func (c *BranchCache) PinEntry(prefix []byte, data []byte, step uint64) { if isCommitmentStateKey(prefix) { return } @@ -640,7 +632,7 @@ func (c *BranchCache) PinEntry(prefix []byte, data []byte, step, txN uint64) { stripe.Lock() defer stripe.Unlock() - entry := &branchCacheEntry{data: dataCopy, step: step, txN: txN, epoch: c.coh.Epoch()} + entry := &branchCacheEntry{data: dataCopy, step: step} st, _, stor, ok := c.storageRoute(prefix, true) if !ok { c.tailForWrite().Add(maphash.Hash(prefix), entry) @@ -666,36 +658,24 @@ func (c *BranchCache) PinnedCount() int { // Get retrieves branch data from the cache. Returns the canonical encoded // bytes (with the leading 2-byte touch-map prefix) plus the on-disk file -// step the bytes came from (0 if not tracked). +// step the bytes came from (0 if not tracked). Shared database readers use +// BranchReadView.Get so the result is checked against PlainStateVersion. func (c *BranchCache) Get(prefix []byte) ([]byte, uint64, bool) { if isCommitmentStateKey(prefix) { return nil, 0, false } - // Snapshot before lookup so an entry captured while Clear empties the tiers - // retains the pre-Clear unwind floor used to judge it. - coh := c.coh.Snapshot() entry, ok := c.lookup(prefix) if !ok { return nil, 0, false } - // Lazy unwind invalidation: an entry from a superseded epoch whose txN is at - // or above the unwind floor reflects dead-fork state — drop it and miss so - // the read falls through to the reverted domain and repopulates. The floor - // is the first unwound txN (>= matches GenericCache: an entry stamped exactly - // at the floor belongs to a rolled-back block). - if coh.IsStale(entry.txN, entry.epoch) { - c.Invalidate(prefix) - c.staleEvicted.Add(1) - return nil, 0, false - } c.bytesServed.Add(uint64(len(entry.data))) return entry.data, entry.step, true } // Put stores branch data in the cache, replacing any existing entry. // Always copies the input data so the cache owns it independently of -// caller buffer lifetime. See entry.txN for the txN tagging semantics. -func (c *BranchCache) Put(prefix []byte, data []byte, step, txN uint64) { +// caller buffer lifetime. Durable state changes use BranchPublisher. +func (c *BranchCache) Put(prefix []byte, data []byte, step uint64) { if isCommitmentStateKey(prefix) { return } @@ -707,17 +687,12 @@ func (c *BranchCache) Put(prefix []byte, data []byte, step, txN uint64) { defer stripe.Unlock() c.store(prefix, &branchCacheEntry{ - data: dataCopy, - step: step, - txN: txN, - epoch: c.coh.Epoch(), + data: dataCopy, + step: step, }) } -// Invalidate removes the entry at prefix entirely from whichever tier -// holds it. Use when the caller knows the canonical store has changed -// and the cached entry should not be served at all (vs MarkDirty which -// keeps the entry but blocks PutIfClean overwrites). +// Invalidate removes the entry at prefix from whichever tier holds it. func (c *BranchCache) Invalidate(prefix []byte) { if isRootPrefix(prefix) { c.root.Store(nil) @@ -742,22 +717,8 @@ func (c *BranchCache) Invalidate(prefix []byte) { } } -// Unwind invalidates entries that reflect dead-fork state. unwindToTxN is the -// txN the chain is rewound to. O(1) and scan-free: bump the epoch (so entries -// written in the new, live epoch stay valid) and lower the unwind floor to -// unwindToTxN (so old-epoch entries at or above it are dropped lazily on their -// next Get). Within the current cache generation, the floor only decreases, so -// a shallow unwind cannot resurrect entries a deeper one invalidated. Mirrors -// GenericCache.Unwind so branch and state caches honor one (txN, epoch) model. -func (c *BranchCache) Unwind(unwindToTxN uint64) { - c.coh.Unwind(unwindToTxN) -} - -// Clear empties the root, trunk, pinned, and tail tiers, resets their stats, and -// starts a new coherence generation. It holds every writer stripe until all -// tiers are empty and coherence is reset, so a publication cannot cross -// generations. Reset runs after every tier is cleared, so a reader cannot pair a -// retired entry with the lifted unwind floor. +// Clear empties the root, trunk, pinned, and tail tiers and resets their stats. +// It holds every writer stripe so a write cannot cross the clear. func (c *BranchCache) Clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() @@ -782,8 +743,6 @@ func (c *BranchCache) Clear() { c.tailHits.Store(0) c.tailMisses.Store(0) c.bytesServed.Store(0) - c.staleEvicted.Store(0) - c.coh.Reset() } // Stats returns a one-line summary of the cache tiers' hit/miss counters plus @@ -803,11 +762,11 @@ func (c *BranchCache) Stats() string { return 100.0 * float64(hit) / float64(total) } return fmt.Sprintf( - "branch-cache root hit=%d miss=%d (%.1f%%) | trunk hit=%d miss=%d (%.1f%%) | pin hit=%d miss=%d (%.1f%%) entries=%d | tail hit=%d miss=%d (%.1f%%) entries=%d | served %.1f MiB | staleEvicted=%d", + "branch-cache root hit=%d miss=%d (%.1f%%) | trunk hit=%d miss=%d (%.1f%%) | pin hit=%d miss=%d (%.1f%%) entries=%d | tail hit=%d miss=%d (%.1f%%) entries=%d | served %.1f MiB", rh, rm, pct(rh, rm), kh, km, pct(kh, km), ph, pm, pct(ph, pm), int(c.pinnedEntries.Load()), th, tm, pct(th, tm), c.tailLen(), - float64(bb)/1024/1024, c.staleEvicted.Load(), + float64(bb)/1024/1024, ) } diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index 88a660cfadb..38077c62307 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -27,13 +27,12 @@ import ( // TestBranchCache_AccountTrunkRouting verifies account-trie branches at nibble // depths 1-4 land in the resident fixed-array trunk (counted as trunk hits), -// survive LRU tail-eviction pressure, and are invalidated lazily by an unwind -// (the trunk honors the same (txN, epoch) model as the tail). +// and survive LRU tail-eviction pressure. func TestBranchCache_AccountTrunkRouting(t *testing.T) { c := NewBranchCache(10) // small tail trunkKey := []byte{0xa0, 0xb0} // 2 nibbles (even flag) → accountTrunk.d2 - c.Put(trunkKey, []byte("trunk-data"), 0, 100) + c.Put(trunkKey, []byte("trunk-data"), 0) got, _, ok := c.Get(trunkKey) require.True(t, ok) @@ -44,21 +43,16 @@ func TestBranchCache_AccountTrunkRouting(t *testing.T) { // Flood the tail well past capacity with deep (5-nibble) keys; the resident // trunk entry must not be evicted. for i := range 100 { - c.Put([]byte{0x10, byte(i), byte(i)}, []byte{byte(i)}, 0, 100) // odd flag, 5 nibbles → tail + c.Put([]byte{0x10, byte(i), byte(i)}, []byte{byte(i)}, 0) // odd flag, 5 nibbles → tail } got, _, ok = c.Get(trunkKey) require.True(t, ok, "resident trunk entry must survive tail eviction pressure") require.Equal(t, []byte("trunk-data"), got) - - // An unwind below the entry's txN invalidates it lazily on next Get. - c.Unwind(60) - _, _, ok = c.Get(trunkKey) - require.False(t, ok, "trunk entry with txN=100 must drop at unwind floor 60") } // TestBranchCache_StorageTrunkPin verifies PinEntry routes a storage-trunk // prefix (>= 64 nibbles) into its per-contract storage trunk, is served from -// the pinned tier, counts toward PinnedCount, and honors the unwind model. +// the pinned tier, and counts toward PinnedCount. func TestBranchCache_StorageTrunkPin(t *testing.T) { c := NewBranchCache(100) @@ -68,17 +62,13 @@ func TestBranchCache_StorageTrunkPin(t *testing.T) { for i := 1; i < 33; i++ { prefix[i] = byte(i) } - c.PinEntry(prefix, []byte("storage-root"), 0, 100) + c.PinEntry(prefix, []byte("storage-root"), 0) require.Equal(t, 1, c.PinnedCount()) got, _, ok := c.Get(prefix) require.True(t, ok) require.Equal(t, []byte("storage-root"), got) require.Equal(t, uint64(1), c.pinnedHits.Load()) - - c.Unwind(60) - _, _, ok = c.Get(prefix) - require.False(t, ok, "pinned storage-trunk entry with txN=100 must drop at unwind floor 60") } // TestBranchCache_RootPinning verifies the root branch lands in the pinned @@ -89,8 +79,8 @@ func TestBranchCache_RootPinning(t *testing.T) { rootKey := []byte{0x00} // compact-encoded empty nibble path = root branch deepKey := []byte{0x12, 0x34, 0x56} - c.Put(rootKey, []byte("root-data"), 0, 0) - c.Put(deepKey, []byte("deep-data"), 0, 0) + c.Put(rootKey, []byte("root-data"), 0) + c.Put(deepKey, []byte("deep-data"), 0) // Root reads should increment rootHits, not tailHits got, _, ok := c.Get(rootKey) @@ -113,11 +103,11 @@ func TestBranchCache_RootPinning(t *testing.T) { func TestBranchCache_RootSurvivesEvictionPressure(t *testing.T) { c := NewBranchCache(10) // very small tail rootKey := []byte{0x00} - c.Put(rootKey, []byte("ROOT-PERSISTS"), 0, 0) + c.Put(rootKey, []byte("ROOT-PERSISTS"), 0) // Stuff the tail well past capacity for i := range 100 { - c.Put([]byte{byte(i), byte(i)}, []byte{byte(i)}, 0, 0) + c.Put([]byte{byte(i), byte(i)}, []byte{byte(i)}, 0) } // Root must still be there @@ -134,8 +124,8 @@ func TestBranchCache_Invalidate(t *testing.T) { c := NewBranchCache(100) rootKey := []byte{0x00} deepKey := []byte{0x12, 0x34} - c.Put(rootKey, []byte("r"), 0, 0) - c.Put(deepKey, []byte("d"), 0, 0) + c.Put(rootKey, []byte("r"), 0) + c.Put(deepKey, []byte("d"), 0) c.Invalidate(rootKey) _, _, ok := c.Get(rootKey) @@ -150,8 +140,8 @@ func TestBranchCache_Invalidate(t *testing.T) { func TestBranchCache_Clear(t *testing.T) { c := NewBranchCache(100) deepKey := []byte{0x12, 0x34, 0x56} // 5 nibbles → LRU tail - c.Put([]byte{0x00}, []byte("r"), 0, 0) - c.Put(deepKey, []byte("d"), 0, 0) + c.Put([]byte{0x00}, []byte("r"), 0) + c.Put(deepKey, []byte("d"), 0) _, _, _ = c.Get([]byte{0x00}) _, _, _ = c.Get(deepKey) @@ -167,23 +157,6 @@ func TestBranchCache_Clear(t *testing.T) { require.False(t, ok) } -func TestBranchCache_ClearRacingPut_EpochAlias(t *testing.T) { - c := NewBranchCache(100) - defer c.Close() - c.Unwind(300) - - key := []byte{0x00} - preClearEpoch := c.coh.Epoch() - c.Clear() - // Model a writer that sampled the epoch before Clear and published after - // its target tier was emptied. - c.store(key, &branchCacheEntry{data: []byte("dead-fork-branch"), txN: 200, epoch: preClearEpoch}) - c.Unwind(150) - - _, _, ok := c.Get(key) - require.False(t, ok, "pre-Clear epoch must not alias the live epoch after a later unwind") -} - func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func()) { // Limit Go execution to one logical processor. Each runtime.Gosched call // yields to the queued goroutine, which runs until it reaches the blocked lock. @@ -216,11 +189,10 @@ func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func TestBranchCache_ClearFencesStartedPut(t *testing.T) { c := NewBranchCache(100) defer c.Close() - c.Unwind(300) key := []byte{0x12, 0x34, 0x56} clearDuringBlockedBranchCacheWrite(c, &c.tailMu, func() { - c.Put(key, []byte("dead-fork-branch"), 0, 200) + c.Put(key, []byte("dead-fork-branch"), 0) }) _, _, ok := c.Get(key) @@ -230,12 +202,11 @@ func TestBranchCache_ClearFencesStartedPut(t *testing.T) { func TestBranchCache_ClearFencesStartedPinEntry(t *testing.T) { c := NewBranchCache(100) defer c.Close() - c.Unwind(300) key := make([]byte, 33) key[32] = 1 clearDuringBlockedBranchCacheWrite(c, &c.pinnedMu, func() { - c.PinEntry(key, []byte("dead-fork-branch"), 0, 200) + c.PinEntry(key, []byte("dead-fork-branch"), 0) }) _, _, ok := c.Get(key) @@ -250,8 +221,8 @@ func TestBranchCache_Stats(t *testing.T) { // trunk only holds depths 1-4). tailHit := []byte{0x12, 0x34, 0x56} tailMiss := []byte{0x12, 0x34, 0x57} - c.Put([]byte{0x00}, []byte("rrr"), 0, 0) - c.Put(tailHit, []byte("ddd"), 0, 0) + c.Put([]byte{0x00}, []byte("rrr"), 0) + c.Put(tailHit, []byte("ddd"), 0) _, _, _ = c.Get([]byte{0x00}) _, _, _ = c.Get(tailHit) _, _, _ = c.Get(tailMiss) // tail miss @@ -270,111 +241,6 @@ func TestBranchCache_Stats(t *testing.T) { require.True(t, strings.HasPrefix(s, "branch-cache ")) } -// twoTailKeyCapacity guarantees per-shard capacity >= 2 across the 256 tail -// shards, so two tail entries can never evict each other regardless of the -// per-process maphash seed. -const twoTailKeyCapacity = 2 * branchCacheTailShards - -// TestBranchCache_Unwind_DropsStaleAboveFloorLazily verifies Unwind drops -// (lazily, on the next Get) every superseded-epoch entry whose txN is at or -// above the unwind floor, while entries below the floor survive untouched. -func TestBranchCache_Unwind_DropsStaleAboveFloorLazily(t *testing.T) { - c := NewBranchCache(twoTailKeyCapacity) - - rootKey := []byte{0x00} - tailKeyKeep := []byte{0x1a, 0xb0, 0x00} // odd flag, 5 nibbles → LRU tail - tailKeyDrop := []byte{0x1a, 0xb0, 0x01} - - // txN=50 entries — below an unwind floor of 60, so they survive. - c.Put(rootKey, []byte("root-keep"), 0, 50) - c.Put(tailKeyKeep, []byte("tail-keep"), 0, 50) - // txN=100 entry — at/above floor 60, so it drops on its next Get. - c.Put(tailKeyDrop, []byte("tail-drop"), 0, 100) - - c.Unwind(60) - - _, _, ok := c.Get(rootKey) - require.True(t, ok, "root entry with txN=50 must survive floor=60") - _, _, ok = c.Get(tailKeyKeep) - require.True(t, ok, "tail entry with txN=50 must survive floor=60") - _, _, ok = c.Get(tailKeyDrop) - require.False(t, ok, "tail entry with txN=100 must drop at floor=60") - require.Equal(t, uint64(2), c.tailHits.Load(), "both keys must exercise the LRU tail") -} - -// TestBranchCache_Unwind_AcrossAllTiers verifies lazy invalidation reaches -// every tier: the root slot, the resident account trunk, and the LRU tail. -func TestBranchCache_Unwind_AcrossAllTiers(t *testing.T) { - c := NewBranchCache(100) - - rootKey := []byte{0x00} - trunkKey := []byte{0xa0, 0xb0} // 2 nibbles (even flag) → account trunk - tailKey := []byte{0x1a, 0xb0, 0x00} // odd flag, 5 nibbles → LRU tail - - c.Put(rootKey, []byte("root"), 0, 100) - c.Put(trunkKey, []byte("trunk"), 0, 100) - c.Put(tailKey, []byte("tail"), 0, 100) - - c.Unwind(50) - - _, _, ok := c.Get(rootKey) - require.False(t, ok, "root entry at txN>=floor must drop") - _, _, ok = c.Get(trunkKey) - require.False(t, ok, "trunk entry at txN>=floor must drop") - _, _, ok = c.Get(tailKey) - require.False(t, ok, "tail entry at txN>=floor must drop") - require.Equal(t, uint64(1), c.trunkHits.Load(), "trunk key must route to the account trunk") - require.Equal(t, uint64(1), c.tailHits.Load(), "tail key must route to the LRU tail") -} - -// TestBranchCache_Unwind_FloorBoundary verifies the >= rule at the floor: an -// entry stamped exactly at the unwind floor belongs to a rolled-back block and -// drops; an entry one txN below the floor survives. -func TestBranchCache_Unwind_FloorBoundary(t *testing.T) { - c := NewBranchCache(twoTailKeyCapacity) - - belowKey := []byte{0x1a, 0xb0, 0x00} // odd flag, 5 nibbles → LRU tail - atKey := []byte{0x1a, 0xb0, 0x01} - c.Put(belowKey, []byte("below"), 0, 99) - c.Put(atKey, []byte("at"), 0, 100) - - c.Unwind(100) - - _, _, ok := c.Get(belowKey) - require.True(t, ok, "entry at txN=floor-1 must survive") - _, _, ok = c.Get(atKey) - require.False(t, ok, "entry at txN==floor must drop (rolled-back block)") - require.Equal(t, uint64(2), c.tailHits.Load(), "both keys must exercise the LRU tail") -} - -// TestBranchCache_Unwind_CurrentEpochSurvives verifies the epoch disambiguates a -// txN reused across forks: an entry rewritten AFTER the unwind (current epoch) -// survives even when its txN is at/above the floor — only superseded-epoch -// entries are stale. -func TestBranchCache_Unwind_CurrentEpochSurvives(t *testing.T) { - c := NewBranchCache(100) - - key := []byte{0xa0, 0xb0} - c.Put(key, []byte("old-fork"), 0, 100) // pre-unwind, old epoch - c.Unwind(50) - c.Put(key, []byte("new-fork"), 0, 100) // re-executed on the live fork, new epoch, same txN - - v, _, ok := c.Get(key) - require.True(t, ok, "current-epoch entry must survive even with txN>=floor") - require.Equal(t, "new-fork", string(v), "must serve the re-executed value, not the dead-fork one") -} - -// TestBranchCache_Unwind_FrozenSurvives verifies a frozen (txN=0) entry — e.g. a -// preloaded trunk branch — is never dropped by an unwind to a positive txN. -func TestBranchCache_Unwind_FrozenSurvives(t *testing.T) { - c := NewBranchCache(100) - key := []byte{0xa0, 0xb0} - c.Put(key, []byte("frozen"), 0, 0) - c.Unwind(50) - _, _, ok := c.Get(key) - require.True(t, ok, "frozen txN=0 entry must survive any positive-txN unwind") -} - // TestBranchCache_StateKeyNeverCached pins that the commitment checkpoint key is // never served or stored (serving a stale checkpoint corrupts the trie root), // and that invalidating it doesn't evict real entries. @@ -382,48 +248,19 @@ func TestBranchCache_StateKeyNeverCached(t *testing.T) { c := NewBranchCache(100) defer c.Close() - c.Put(KeyCommitmentState, []byte("checkpoint"), 1, 1) + c.Put(KeyCommitmentState, []byte("checkpoint"), 1) _, _, ok := c.Get(KeyCommitmentState) require.False(t, ok, "state key must never be served from the cache") require.Equal(t, 0, c.tailLen(), "state key must not occupy a tail slot") deepKey := []byte{0x12, 0x34} - c.Put(deepKey, []byte("d"), 0, 0) + c.Put(deepKey, []byte("d"), 0) c.Invalidate(KeyCommitmentState) got, _, ok := c.Get(deepKey) require.True(t, ok, "invalidating the state key must not evict real entries") require.Equal(t, []byte("d"), got) } -// TestBranchCache_ShardedTailUnwindAcrossShards verifies the lazy (epoch+floor) -// unwind drops exactly the entries at/above the floor across all tail shards. -func TestBranchCache_ShardedTailUnwindAcrossShards(t *testing.T) { - c := NewBranchCache(DefaultBranchCacheTailCapacity) - defer c.Close() - - // Stay within the tail's start capacity so the entries can't LRU-evict: the - // tail only jump-grows when the shared cachebudget has room, which a full - // test run may have consumed — this test asserts the unwind floor, not growth. - const n = 64 - const watermark = 32 - for i := range n { - prefix := []byte{0x01, byte(i), byte(i >> 8)} - c.Put(prefix, []byte{byte(i)}, 0, uint64(i)) - } - - c.Unwind(watermark) - - for i := range n { - prefix := []byte{0x01, byte(i), byte(i >> 8)} - _, _, ok := c.Get(prefix) - if uint64(i) >= watermark { - require.False(t, ok, "entry txN=%d must be dropped by floor=%d", i, watermark) - } else { - require.True(t, ok, "entry txN=%d must survive floor=%d", i, watermark) - } - } -} - // TestBranchCache_ConcurrentTailGrow drives concurrent tail Puts well past the // 512-entry start capacity so maybeGrow runs under contention. It regresses the // data race where Add read tailLRU.curCap unsynchronized while maybeGrow/reset @@ -442,10 +279,107 @@ func TestBranchCache_ConcurrentTailGrow(t *testing.T) { for i := range perWorker { // odd flag (0x10) + 3 bytes → 7 nibbles → tail; unique per (w,i). key := []byte{0x10, byte(w), byte(i), byte(i >> 8)} - c.Put(key, []byte{byte(i)}, 0, 100) + c.Put(key, []byte{byte(i)}, 0) c.Get(key) } }) } wg.Wait() } + +func TestBranchCache_ViewRequiresExactStateVersion(t *testing.T) { + c := NewBranchCache(100) + t.Cleanup(c.Close) + publisher := c.Publisher() + publisher.Initialize(7) + + key := []byte{0xa0, 0xb0} + view := c.View(7) + view.Fill(key, []byte("version-7"), 3) + + value, step, ok := view.Get(key) + require.True(t, ok) + require.Equal(t, []byte("version-7"), value) + require.Equal(t, uint64(3), step) + + _, _, ok = c.View(6).Get(key) + require.False(t, ok, "an older database snapshot must not read the current branch generation") + _, _, ok = c.View(8).Get(key) + require.False(t, ok, "a newer database snapshot must wait for its branch generation to be published") +} + +func TestBranchCache_PublicationRejectsLateFill(t *testing.T) { + c := NewBranchCache(100) + t.Cleanup(c.Close) + publisher := c.Publisher() + publisher.Initialize(1) + + key := []byte{0xa0, 0xb0} + oldView := c.View(1) + oldView.Fill(key, []byte("old"), 1) + + publication := publisher.Begin() + _, _, ok := oldView.Get(key) + require.False(t, ok, "Begin must revoke existing branch views") + oldView.Fill(key, []byte("late-old-fill"), 1) + + publication.Publish(2, []BranchUpdate{{ + Key: key, + Value: []byte("new"), + Step: 2, + }}, false, nil) + + _, _, ok = oldView.Get(key) + require.False(t, ok, "a published generation must not revalidate an old view") + value, step, ok := c.View(2).Get(key) + require.True(t, ok) + require.Equal(t, []byte("new"), value) + require.Equal(t, uint64(2), step) +} + +func TestBranchCache_PublicationAbortRestoresPreviousView(t *testing.T) { + c := NewBranchCache(100) + t.Cleanup(c.Close) + publisher := c.Publisher() + publisher.Initialize(1) + + key := []byte{0xa0, 0xb0} + view := c.View(1) + view.Fill(key, []byte("unchanged"), 1) + + publication := publisher.Begin() + _, _, ok := view.Get(key) + require.False(t, ok) + + publication.Abort() + value, _, ok := view.Get(key) + require.True(t, ok, "rollback must restore the unchanged branch generation") + require.Equal(t, []byte("unchanged"), value) +} + +func TestBranchCache_ResetRevokesViewsUntilNextPublication(t *testing.T) { + c := NewBranchCache(100) + t.Cleanup(c.Close) + publisher := c.Publisher() + publisher.Initialize(1) + + key := []byte{0xa0, 0xb0} + oldView := c.View(1) + oldView.Fill(key, []byte("old-layout"), 1) + + c.Reset() + _, _, ok := oldView.Get(key) + require.False(t, ok) + _, _, ok = c.View(1).Get(key) + require.False(t, ok, "Reset must leave the cache unpublished") + + publication := publisher.Begin() + publication.Publish(2, []BranchUpdate{{ + Key: key, + Value: []byte("new-layout"), + Step: 2, + }}, false, nil) + value, _, ok := c.View(2).Get(key) + require.True(t, ok) + require.Equal(t, []byte("new-layout"), value) +} diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go new file mode 100644 index 00000000000..a91bc440da3 --- /dev/null +++ b/execution/commitment/branch_cache_view.go @@ -0,0 +1,146 @@ +// 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 commitment + +import "github.com/erigontech/erigon/execution/cache" + +// BranchReadView binds BranchCache access to one durable PlainStateVersion. +// Publication concurrent with a read turns the result into a miss, while fills +// are serialized so a value from an old database snapshot cannot enter a new +// generation. +type BranchReadView struct { + c *BranchCache + version cache.PlainStateVersionView +} + +// View returns an inert handle unless stateVersion is currently published. +func (c *BranchCache) View(stateVersion uint64) BranchReadView { + if c == nil { + return BranchReadView{} + } + version := c.version.View(stateVersion) + if !version.Current() { + return BranchReadView{} + } + return BranchReadView{c: c, version: version} +} + +func (v BranchReadView) current() bool { + return v.c != nil && v.version.Current() +} + +// Get returns a branch only while the view remains current. +func (v BranchReadView) Get(prefix []byte) ([]byte, uint64, bool) { + if !v.current() { + return nil, 0, false + } + value, step, ok := v.c.Get(prefix) + if !v.current() { + return nil, 0, false + } + return value, step, ok +} + +// Fill admits a branch read from the view's database snapshot. +func (v BranchReadView) Fill(prefix, value []byte, step uint64) { + if !v.current() || len(value) == 0 { + return + } + v.version.Admit(func() { + v.c.Put(prefix, value, step) + }) +} + +// BranchUpdate is one committed commitment-domain value. +type BranchUpdate struct { + Key []byte + Value []byte + Step uint64 +} + +// BranchPublisher is the canonical mutation handle for BranchCache. +type BranchPublisher struct { + c *BranchCache + version cache.PlainStateVersionPublisher +} + +// Publisher returns a handle that can publish durable branch generations. +func (c *BranchCache) Publisher() BranchPublisher { + if c == nil { + return BranchPublisher{} + } + return BranchPublisher{c: c, version: c.version.Publisher()} +} + +func (p BranchPublisher) Enabled() bool { + return p.c != nil && p.version.Enabled() +} + +// Initialize binds an empty or previously published cache to stateVersion. +func (p BranchPublisher) Initialize(stateVersion uint64) { + if p.c == nil { + return + } + p.version.Initialize(stateVersion, p.c.Clear) +} + +// BranchPublication represents one pending durable branch transition. +type BranchPublication struct { + c *BranchCache + version *cache.PlainStateVersionPublication +} + +// Begin revokes current BranchReadViews without changing branch entries. +func (p BranchPublisher) Begin() *BranchPublication { + if p.c == nil { + return nil + } + return &BranchPublication{c: p.c, version: p.version.Begin()} +} + +// Abort restores the previous branch generation after database rollback. +func (p *BranchPublication) Abort() { + if p == nil || p.c == nil { + return + } + p.version.Abort() + p.c = nil +} + +// Publish applies staged pin changes and committed branch updates before it +// exposes stateVersion. clear is required after canonical unwind because its +// diffset is not a complete list of branches from the discarded fork. +func (p *BranchPublication) Publish(stateVersion uint64, updates []BranchUpdate, clear bool, adaptive *AdaptivePinPlan) { + if p == nil || p.c == nil { + return + } + p.version.Publish(stateVersion, func() { + if clear { + p.c.Clear() + } + adaptive.apply() + for i := range updates { + update := &updates[i] + if len(update.Value) == 0 { + p.c.Invalidate(update.Key) + continue + } + p.c.Put(update.Key, update.Value, update.Step) + } + }) + p.c = nil +} diff --git a/execution/commitment/hex_patricia_hashed.go b/execution/commitment/hex_patricia_hashed.go index 834b212d363..3853cb0b8a8 100644 --- a/execution/commitment/hex_patricia_hashed.go +++ b/execution/commitment/hex_patricia_hashed.go @@ -2707,8 +2707,8 @@ func (hph *HexPatriciaHashed) SetLeaveDeferredForCaller(leave bool) { } // Reset allows HexPatriciaHashed instance to be reused for the new commitment calculation. -// The aggregator-scope BranchCache is intentionally not cleared here; -// SharedDomains.Unwind handles correctness via txN-tagged eviction. +// The aggregator-scope BranchCache is not trie-instance state. SharedDomains +// detaches it during unwind and clears it only if the rewound state commits. func (hph *HexPatriciaHashed) Reset() { hph.root.reset() hph.rootTouched = false diff --git a/execution/commitment/preload.go b/execution/commitment/preload.go index 6891836dc37..7998a9194f5 100644 --- a/execution/commitment/preload.go +++ b/execution/commitment/preload.go @@ -28,6 +28,10 @@ import ( // tx/aggregator types to keep this package free of db/state imports. type CommitmentReader func(prefix []byte) (v []byte, step uint64, found bool, err error) +type branchPinWriter interface { + PinEntry(prefix, data []byte, step uint64) +} + // estimatedEntryOverheadBytes is the per-entry RAM cost beyond the encoded // value itself: branchCacheEntry (~80 B), maphash slot + hash (~40 B), // prefix slice (~24 B header + content), value slice header (~24 B). @@ -47,10 +51,6 @@ type ContractTrunkPreload struct { pinned int usedBytes int maxDepthReached int - // pinTxNum stamps pinned entries with the head txNum they were read at, so a - // later unwind below that point evicts them via the BranchCache floor (a - // txN=0 pin would escape it and be served stale after a deep unwind). - pinTxNum uint64 } // NewContractTrunkPreload seeds a preload state at depth 64 (storage @@ -78,7 +78,7 @@ func NewContractTrunkPreload(contractHash []byte) (*ContractTrunkPreload, error) func (p *ContractTrunkPreload) Run( additionalBudgetBytes int, reader CommitmentReader, - cache *BranchCache, + cache branchPinWriter, logger log.Logger, ) (newlyPinned int, queueEmpty bool, err error) { if cache == nil { @@ -110,7 +110,7 @@ func (p *ContractTrunkPreload) Run( break } - cache.PinEntry(prefix, v, step, p.pinTxNum) + cache.PinEntry(prefix, v, step) // HexToCompact may alias a reused buffer; copy for a stable Invalidate handle. prefixCopy := make([]byte, len(prefix)) copy(prefixCopy, prefix) diff --git a/execution/commitment/preload_parallel.go b/execution/commitment/preload_parallel.go index 01fb27395aa..30058db0814 100644 --- a/execution/commitment/preload_parallel.go +++ b/execution/commitment/preload_parallel.go @@ -78,10 +78,6 @@ type ContractTrunkPreloadParallel struct { usedBytes int maxDepthReached int dbHitsPinned int - // pinTxNum stamps pinned entries with the head txNum they were read at, so a - // later unwind below that point evicts them via the BranchCache floor (a - // txN=0 pin would escape it and be served stale after a deep unwind). - pinTxNum uint64 // Reusable per-wave partition scratch. Contents are copied into the next // frontier before the buffers are reused, so retaining the grown backing @@ -154,7 +150,7 @@ func (p *ContractTrunkPreloadParallel) Run( stepBudgetBytes int, dbBranches map[string][]byte, resolve BatchBranchResolver, - cache *BranchCache, + cache branchPinWriter, logger log.Logger, ) (newlyPinned int, queueEmpty bool, err error) { if cache == nil { @@ -179,11 +175,9 @@ func (p *ContractTrunkPreloadParallel) Run( budgetHit = true return false } - // step=0: a storage-trunk branch resolved across merged files has no single - // source step, and the pinTxNum stamp already gives unwind coherence — the - // floor drops a preloaded pin before the cStep<=maxStep gate is consulted, - // so leaving step unset only keeps that gate trivially true for live pins. - cache.PinEntry(pk.key, v, 0, p.pinTxNum) + // A branch resolved across merged files has no single source step. The + // enclosing publication binds the completed preload to PlainStateVersion. + cache.PinEntry(pk.key, v, 0) p.pinnedPrefixes = append(p.pinnedPrefixes, bytes.Clone(pk.key)) p.usedBytes += cost p.pinned++ From b097a2ffef6cc82384589c4d684dd2118295b26f Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:06:54 +0200 Subject: [PATCH 05/50] execution/cache, commitment, db/state: reconcile file publications --- cmd/integration/commands/stages.go | 2 +- db/kv/temporal/kv_temporal.go | 2 +- db/state/aggregator.go | 80 ++++++++++++-- db/state/aggregator_align_test.go | 84 ++++++++++++++- db/state/execctx/domain_shared.go | 37 ++++--- db/state/execctx/statecache_readfill_test.go | 26 ++--- execution/cache/files_publication_test.go | 89 +++++++++++++++ execution/cache/state_cache.go | 37 ++++++- execution/cache/version_gate.go | 102 +++++++++++++----- execution/commitment/branch_cache.go | 26 ++++- .../commitment/branch_cache_absorb_test.go | 65 +++++++++++ execution/commitment/branch_cache_view.go | 7 +- execution/execmodule/exec_module.go | 2 +- 13 files changed, 489 insertions(+), 70 deletions(-) create mode 100644 execution/cache/files_publication_test.go create mode 100644 execution/commitment/branch_cache_absorb_test.go diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index b7f7610a6f0..21f547f4d62 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -846,7 +846,7 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn doms.SetInMemHistoryReads(false) doms.SetCanonicalStateCache(stateCache) doms.SetCodeStore(codeStore) - execctx.GuardAggregatorForCache(db, stateCache) + execctx.BindStateCacheToAggregator(db, stateCache) s, err := st.StageState(stages.Execution, tx, initialCycle, false) if err != nil { diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 6be7861ae8f..f134154737a 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -283,7 +283,7 @@ type domainVisibleEnds struct { // over-rejects fills: a view's frontier never decreases in a process that // fills a cache — the DB component is frozen at tx begin, and a files // reopen only extends it, an invariant the aggregator enforces once a - // fill-enabled cache is wired over it (ForbidVisibilityLowering). + // shared latest-state cache is bound (BindStateCache). ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 0a3cd3eec7f..06cc85369f7 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -55,6 +55,7 @@ import ( "github.com/erigontech/erigon/db/state/kvmetrics" "github.com/erigontech/erigon/db/state/statecfg" "github.com/erigontech/erigon/db/version" + "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/commitment" ) @@ -91,10 +92,13 @@ type Aggregator struct { unalignedDomain [kv.DomainLen]bool unalignedIdx [kv.StandaloneIdxLen]bool // visibilityLoweringForbidden: a single-version cache is wired over this - // aggregator, and its fill admission relies on view frontiers never - // decreasing. Close clears it because shutdown is not a fill window. + // aggregator, and PlainStateVersion does not encode changes to file + // visibility. Close clears it because shutdown is not a cache-read window. visibilityLoweringForbidden atomic.Bool - snapshotBuildSema *semaphore.Weighted + // boundStateCache is reconciled before a new files view becomes visible. + // Guarded by dirtyFilesLock. + boundStateCache *cache.StateCache + snapshotBuildSema *semaphore.Weighted disableHistory bool branchCacheDisabled bool @@ -549,7 +553,7 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { // ForbidVisibilityLowering marks this aggregator as backing a single-version // cache. From then on recalcVisibleFiles rejects lowering a cached domain's -// visible end, whichever entry point caused it. +// visible end because PlainStateVersion does not identify that change. // Serialized with recalcVisibleFiles via dirtyFilesLock so "from then on" // holds against a recalculation already in flight. func (a *Aggregator) ForbidVisibilityLowering() { @@ -561,6 +565,21 @@ func (a *Aggregator) ForbidVisibilityLowering() { a.visibilityLoweringForbidden.Store(true) } +// BindStateCache prevents visibility lowering and reconciles the cache with +// files that are already visible. Future file publications are reconciled by +// recalcVisibleFiles before readers can observe their new backing view. +func (a *Aggregator) BindStateCache(stateCache *cache.StateCache) { + if stateCache == nil { + return + } + a.dirtyFilesLock.Lock() + defer a.dirtyFilesLock.Unlock() + a.visibilityLoweringForbidden.Store(true) + a.boundStateCache = stateCache + change := stateCache.BeginFilesPublication(visibleStateFilesEnd(a.visible.Load())) + change.Finish() +} + func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() @@ -1885,6 +1904,47 @@ type aggregatorVisible struct { next *aggregatorVisible // oldest→newest linked-list link (set under dirtyFilesLock) } +func visibleStateFilesEnd(visible *aggregatorVisible) (ends [kv.DomainLen]uint64) { + if visible == nil { + return ends + } + for domain, domainVisible := range visible.d { + if domainVisible != nil { + ends[domain] = visibleFiles(domainVisible.files).EndTxNum() + } + } + return ends +} + +type cacheFilesPublication struct { + state *cache.PlainStateVersionBackingChange + branch *cache.PlainStateVersionBackingChange +} + +func (a *Aggregator) beginCacheFilesPublication(visible *aggregatorVisible) cacheFilesPublication { + var publication cacheFilesPublication + // SharedDomains.Commit acquires cache publication in the same order. + if domain := a.d[kv.CommitmentDomain]; domain != nil && domain.branchCache != nil { + if commitmentVisible := visible.d[kv.CommitmentDomain]; commitmentVisible != nil { + publication.branch = domain.branchCache.BeginFilesPublication(visibleFiles(commitmentVisible.files).EndTxNum()) + } + } + if a.boundStateCache != nil { + publication.state = a.boundStateCache.BeginFilesPublication(visibleStateFilesEnd(visible)) + } + return publication +} + +func (p *cacheFilesPublication) Finish() { + if p == nil { + return + } + p.state.Finish() + p.state = nil + p.branch.Finish() + p.branch = nil +} + // recalcVisibleFiles must be called with dirtyFilesLock held (writers are // serialized by it; readers take no lock and instead load a.visible). It builds // a fresh immutable aggregatorVisible bundle via the per-entity calcVisibleFiles @@ -1917,7 +1977,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevEnd := visibleFiles(prev.d[d].files).EndTxNum() nextEnd := visibleFiles(next.d[d].files).EndTxNum() if nextEnd < prevEnd { - panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a single-version cache is wired — fill admission relies on view frontiers never decreasing", d, prevEnd, nextEnd)) + panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a single-version cache is wired — PlainStateVersion does not identify file-visibility changes", d, prevEnd, nextEnd)) } if prev.dhii[d] == nil || next.dhii[d] == nil { continue @@ -1925,15 +1985,19 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevII := prev.dhii[d].files.EndTxNum() nextII := next.dhii[d].files.EndTxNum() if nextII < prevII { - panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a single-version cache is wired — DomainVisibleEnd derives view frontiers from it", d, prevII, nextII)) + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a single-version cache is wired — exact cache-view eligibility derives its frontier from history-II", d, prevII, nextII)) } } } + cachePublication := a.beginCacheFilesPublication(next) + defer cachePublication.Finish() + old := a.visible.Load() old.retired = retired old.next = next a.visible.Store(next) + cachePublication.Finish() // `recalcVisibleFiles` is rare background operation under `dirtyFilesLock` // it's good idea to delete files here, then hot reader-Close path will more likely be lock-free @@ -2688,6 +2752,10 @@ func (at *AggregatorRoTx) ForbidVisibilityLowering() { at.a.ForbidVisibilityLowering() } +func (at *AggregatorRoTx) BindStateCache(stateCache *cache.StateCache) { + at.a.BindStateCache(stateCache) +} + func (at *AggregatorRoTx) Dirs() datadir.Dirs { return at.a.dirs } func (at *AggregatorRoTx) standaloneIIs() []*InvertedIndexRoTx { return at.iis[:at.iisCount] } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 6ea12af0f06..4a37d35f5b4 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -24,6 +24,8 @@ import ( "github.com/erigontech/erigon/db/datadir" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/cache" ) // generateStandaloneIIFile writes files with a hardcoded step size of 10. @@ -55,6 +57,10 @@ func requireVisibleEnd(t *testing.T, agg *Aggregator, end uint64) { } } +type cacheAggregatorHolder struct{ agg *Aggregator } + +func (h cacheAggregatorHolder) Agg() any { return h.agg } + // state visible past commitment's files = state no commitment covers func TestVisibleFilesAligned_LaggingCommitmentClampsEveryone(t *testing.T) { t.Parallel() @@ -286,5 +292,81 @@ func TestVisibilityLowering_GuardsCommitmentDomain(t *testing.T) { require.Equal(t, 1, dropped) require.Panics(t, func() { agg.recalcVisibleFiles(nil) }, - "BranchCache fill admission requires the commitment frontier to remain monotonic") + "BranchCache validity requires the commitment frontier to remain monotonic") +} + +func TestFilePublicationRevokesCacheGenerations(t *testing.T) { + t.Parallel() + _, agg := testDbAndAggregatorv3(t, alignStepSize) + + stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(stateCache.Close) + statePublisher := stateCache.Publisher() + statePublisher.Initialize(1) + execctx.BindStateCacheToAggregator(cacheAggregatorHolder{agg}, stateCache) + + accountKey := make([]byte, 20) + accountKey[0] = 1 + stateView := stateCache.View(1) + stateView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) + _, ok := stateView.Get(kv.AccountsDomain, accountKey) + require.True(t, ok) + + branchCache := agg.d[kv.CommitmentDomain].branchCache + require.NotNil(t, branchCache) + branchPublisher := branchCache.Publisher() + branchPublisher.Initialize(1) + branchKey := []byte{0x01} + branchView := branchCache.View(1) + branchView.Fill(branchKey, []byte{0xbb}, 1) + _, _, ok = branchView.Get(branchKey) + require.True(t, ok) + + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + require.NoError(t, agg.OpenFolder()) + + _, ok = stateView.Get(kv.AccountsDomain, accountKey) + require.False(t, ok, "file publication must revoke pre-publication state views") + stateView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) + statePublisher.Initialize(1) + _, ok = stateCache.View(1).Get(kv.AccountsDomain, accountKey) + require.False(t, ok, "a revoked state view must not refill after file publication") + + _, _, ok = branchView.Get(branchKey) + require.False(t, ok, "file publication must revoke pre-publication branch views") + branchView.Fill(branchKey, []byte{0xbb}, 1) + branchPublisher.Initialize(1) + _, _, ok = branchCache.View(1).Get(branchKey) + require.False(t, ok, "a revoked branch view must not refill after file publication") +} + +func TestCacheBindingAbsorbsExistingFileVisibility(t *testing.T) { + t.Parallel() + _, agg := testDbAndAggregatorv3(t, alignStepSize) + + generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) + require.NoError(t, agg.OpenFolder()) + + stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(stateCache.Close) + statePublisher := stateCache.Publisher() + statePublisher.Initialize(1) + accountKey := make([]byte, 20) + accountKey[0] = 1 + oldView := stateCache.View(1) + oldView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) + _, ok := oldView.Get(kv.AccountsDomain, accountKey) + require.True(t, ok) + + execctx.BindStateCacheToAggregator(cacheAggregatorHolder{agg}, stateCache) + + _, ok = oldView.Get(kv.AccountsDomain, accountKey) + require.False(t, ok, "binding must revoke entries created before the visible files were absorbed") + statePublisher.Initialize(1) + _, ok = stateCache.View(1).Get(kv.AccountsDomain, accountKey) + require.False(t, ok) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index a99513a78a7..b2522558165 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -839,26 +839,28 @@ func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { sd.cachePublisher.Initialize(sd.baseStateVersion) } -// GuardAggregatorForCache prevents domain-file visibility from moving -// backwards while StateCache is active. PlainStateVersion tracks durable -// database state, but it does not change when the aggregator exposes an older -// set of files. If visibility could be lowered independently, a transaction -// and the cache could report the same version while representing different -// effective states. +// BindStateCacheToAggregator binds StateCache to the aggregator's file +// publications and prevents domain-file visibility from moving backwards. +// PlainStateVersion tracks durable database state, but it does not change when +// the aggregator changes which files are visible. // -// The guard is required even when reader fills are disabled because cache hits -// also rely on stable visibility. A database that cannot enforce the invariant -// is rejected instead of silently permitting unsafe cache reads. -func GuardAggregatorForCache(db any, sc *cache.StateCache) { +// The binding is required even when reader fills are disabled because cache +// hits also rely on the same backing view. A database that cannot enforce the +// invariant is rejected instead of silently permitting unsafe cache reads. +func BindStateCacheToAggregator(db any, sc *cache.StateCache) { if sc == nil { return } h, ok := db.(interface{ Agg() any }) if !ok { - panic(fmt.Sprintf("assert: StateCache wired over %T, which cannot produce its aggregator — the visibility-lowering guard would be silently dropped", db)) + panic(fmt.Sprintf("assert: StateCache wired over %T, which cannot produce its aggregator — file-publication cache binding would be silently dropped", db)) } agg := h.Agg() - forbidVisibilityLowering(agg) + b, ok := agg.(interface{ BindStateCache(*cache.StateCache) }) + if !ok { + panic(fmt.Sprintf("assert: aggregator %T lacks BindStateCache — file-publication cache invalidation would be silently dropped", agg)) + } + b.BindStateCache(sc) } func forbidVisibilityLowering(agg any) { @@ -1022,21 +1024,23 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun var stateUpdates []cache.Update stashState := func(domain kv.Domain) kv.FlushOption { - return kv.WithFlushCallback(domain, func(key, value []byte, step kv.Step, _ uint64) { + return kv.WithFlushCallback(domain, func(key, value []byte, step kv.Step, txNum uint64) { stateUpdates = append(stateUpdates, cache.Update{ Domain: domain, Key: bytes.Clone(key), Value: bytes.Clone(value), Step: step, + TxNum: txNum, }) }) } var branchUpdates []commitment.BranchUpdate - stashBranch := kv.WithFlushCallback(kv.CommitmentDomain, func(key, value []byte, step kv.Step, _ uint64) { + stashBranch := kv.WithFlushCallback(kv.CommitmentDomain, func(key, value []byte, step kv.Step, txNum uint64) { branchUpdates = append(branchUpdates, commitment.BranchUpdate{ Key: bytes.Clone(key), Value: bytes.Clone(value), Step: uint64(step), + TxNum: txNum, }) }) @@ -1053,7 +1057,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // corrupts it (reorg/unwind wrong root). var codeStoreWrites [][2][]byte if stateCacheEnabled || sd.codeStore != nil { - opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(key, value []byte, step kv.Step, _ uint64) { + opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(key, value []byte, step kv.Step, txNum uint64) { if sd.codeStore != nil && len(value) > 0 { codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(value), bytes.Clone(value)}) } @@ -1063,6 +1067,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun Key: bytes.Clone(key), Value: bytes.Clone(value), Step: step, + TxNum: txNum, }) } })) @@ -1097,6 +1102,8 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun adaptivePlan.Abort() }() + // Canonical commits and file-view changes both acquire BranchCache before + // StateCache. Keeping one order prevents their publications from deadlocking. if branchCacheEnabled { if !sd.clearBranchCache { adaptivePlan = sd.planAdaptivePins(tx) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 323fc081b42..e7fdc21bf9b 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -296,11 +296,11 @@ func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { require.Equal(t, v2, got) } -type fakeForbidder struct{ called bool } +type fakeCacheBinder struct{ called bool } -func (f *fakeForbidder) ForbidVisibilityLowering() { f.called = true } +func (f *fakeCacheBinder) BindStateCache(*cache.StateCache) { f.called = true } -type fakeHasAgg struct{ f *fakeForbidder } +type fakeHasAgg struct{ f *fakeCacheBinder } func (h fakeHasAgg) Agg() any { return h.f } @@ -310,28 +310,28 @@ func (fakeHasBadAgg) Agg() any { return struct{}{} } // The guard is load-bearing for every StateCache and must fail loudly when the // DB cannot enforce the visibility invariant. A nil cache needs no guard. -func TestGuardAggregatorForCache(t *testing.T) { +func TestBindStateCacheToAggregator(t *testing.T) { sc := newSmallStateCache() t.Cleanup(sc.Close) - f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + f := &fakeCacheBinder{} + execctx.BindStateCacheToAggregator(fakeHasAgg{f}, sc) require.True(t, f.called) - require.NotPanics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, nil) }, + require.NotPanics(t, func() { execctx.BindStateCacheToAggregator(struct{}{}, nil) }, "no cache, no invariant to bind — shape is irrelevant") - require.Panics(t, func() { execctx.GuardAggregatorForCache(struct{}{}, sc) }, + require.Panics(t, func() { execctx.BindStateCacheToAggregator(struct{}{}, sc) }, "a db that cannot produce its aggregator must fail loudly, not drop the guard") - require.Panics(t, func() { execctx.GuardAggregatorForCache(fakeHasBadAgg{}, sc) }, - "an aggregator without ForbidVisibilityLowering must fail loudly, not drop the guard") + require.Panics(t, func() { execctx.BindStateCacheToAggregator(fakeHasBadAgg{}, sc) }, + "an aggregator without BindStateCache must fail loudly, not drop the binding") } -func TestGuardAggregatorForCache_FillsDisabledStillGuards(t *testing.T) { +func TestBindStateCacheToAggregator_FillsDisabledStillBinds(t *testing.T) { t.Setenv("STATE_CACHE_FILLS", "false") sc := newSmallStateCache() t.Cleanup(sc.Close) - f := &fakeForbidder{} - execctx.GuardAggregatorForCache(fakeHasAgg{f}, sc) + f := &fakeCacheBinder{} + execctx.BindStateCacheToAggregator(fakeHasAgg{f}, sc) require.True(t, f.called) } diff --git a/execution/cache/files_publication_test.go b/execution/cache/files_publication_test.go new file mode 100644 index 00000000000..c6eac25c484 --- /dev/null +++ b/execution/cache/files_publication_test.go @@ -0,0 +1,89 @@ +// 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 cache + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/kv" +) + +func TestStateCacheFilesPublication(t *testing.T) { + stateCache, publisher := readyStateCache(t, 1) + key := makeAddr(1) + value := makeValue(1) + + publication := publisher.Begin() + publication.Publish(2, []Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: value, + Step: 1, + TxNum: 100, + }}, false) + view := stateCache.View(2) + got, ok := view.Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, value, got) + + var filesEnd [kv.DomainLen]uint64 + filesEnd[kv.AccountsDomain] = 101 + require.Nil(t, stateCache.BeginFilesPublication(filesEnd)) + _, ok = view.Get(kv.AccountsDomain, key) + require.True(t, ok, "files covered by committed updates must not clear the cache") + + filesEnd[kv.AccountsDomain] = 150 + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + _, ok = view.Get(kv.AccountsDomain, key) + require.False(t, ok, "foreign files must revoke the published generation") + change.Finish() + + publication = publisher.Begin() + publication.Publish(3, nil, false) + current := stateCache.View(3) + _, ok = current.Get(kv.AccountsDomain, key) + require.False(t, ok, "the next commit must not reactivate entries from the old backing view") + + current.Fill(kv.AccountsDomain, key, value, 1) + require.Nil(t, stateCache.BeginFilesPublication(filesEnd)) + _, ok = current.Get(kv.AccountsDomain, key) + require.True(t, ok, "an already absorbed files view must not clear again") +} + +func TestFilesPublicationBlocksCachePublicationUntilVisible(t *testing.T) { + stateCache, publisher := readyStateCache(t, 1) + var filesEnd [kv.DomainLen]uint64 + filesEnd[kv.AccountsDomain] = 1 + + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + require.False(t, stateCache.version.publicationMu.TryLock(), + "cache publication must stay blocked while the backing-file view changes") + + change.Finish() + locked := stateCache.version.publicationMu.TryLock() + require.True(t, locked) + if locked { + stateCache.version.publicationMu.Unlock() + } + + publication := publisher.Begin() + publication.Abort() +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index e46e1e7a3d7..f880a5f5ff9 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -43,8 +43,12 @@ const ( type StateCache struct { version PlainStateVersionGate - caches [kv.DomainLen]Cache - disableFills bool + // committedTxNumEnd is only a file-provenance watermark. Cache validity is + // still decided exclusively by version; these ends distinguish files built + // from published updates from files downloaded outside that stream. + committedTxNumEnd [kv.DomainLen]uint64 + caches [kv.DomainLen]Cache + disableFills bool } func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { @@ -96,6 +100,26 @@ func (c *StateCache) CurrentStateVersion() (uint64, bool) { return c.version.CurrentStateVersion() } +// BeginFilesPublication revokes and clears the cache when files expose state +// beyond this process's committed updates. Finish must be called after the new +// files view becomes visible. +func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *PlainStateVersionBackingChange { + if c == nil { + return nil + } + return c.version.Publisher().BeginBackingChange(func() bool { + extended := false + for domain, cache := range c.caches { + if cache == nil || filesEnd[domain] <= c.committedTxNumEnd[domain] { + continue + } + c.committedTxNumEnd[domain] = filesEnd[domain] + extended = true + } + return extended + }, c.clearLocked) +} + func (c *StateCache) getWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { cache := c.getCache(domain) if cache == nil { @@ -193,6 +217,9 @@ func (c *StateCache) applyLocked(update Update) { if cache == nil { return } + if committedEnd := update.TxNum + 1; committedEnd > c.committedTxNumEnd[update.Domain] { + c.committedTxNumEnd[update.Domain] = committedEnd + } switch update.Domain { case kv.AccountsDomain: @@ -270,13 +297,15 @@ func (c *StateCache) PrintStatsAndReset() { } // Update is one value written by the database transaction being published. -// Step is retained because GetLatest must return the value's source step; cache -// validity depends only on the published PlainStateVersion. +// Step is returned by GetLatest. TxNum records how far this process's committed +// writes cover the domain, allowing file publication to detect downloaded +// state that never passed through this publisher. type Update struct { Domain kv.Domain Key []byte Value []byte Step kv.Step + TxNum uint64 } // Publisher is the mutation capability for canonical state. Normal readers diff --git a/execution/cache/version_gate.go b/execution/cache/version_gate.go index abaa7478096..3f7e523efee 100644 --- a/execution/cache/version_gate.go +++ b/execution/cache/version_gate.go @@ -30,11 +30,14 @@ type versionGeneration struct { } // PlainStateVersionGate binds lock-free cache reads and serialized fills to one -// durable PlainStateVersion. It controls visibility only; each cache remains -// responsible for storing and applying its own entries. +// durable PlainStateVersion. It also revokes views when the backing data +// changes without advancing that version. type PlainStateVersionGate struct { current atomic.Pointer[versionGeneration] admissionMu sync.RWMutex + // publicationMu orders durable cache publication with independent changes + // to the backing-file view. Begin holds it until Publish or Abort. + publicationMu sync.Mutex } // PlainStateVersionView is the immutable validity token held by one cache view. @@ -111,22 +114,17 @@ func (p PlainStateVersionPublisher) Initialize(stateVersion uint64, clear func() return } gate := p.gate + gate.publicationMu.Lock() + defer gate.publicationMu.Unlock() gate.admissionMu.Lock() defer gate.admissionMu.Unlock() current := gate.current.Load() - if current != nil && current.active { - if current.stateVersion == stateVersion { - return - } - } else if current != nil { - // The owner already revoked the old version and will publish the - // transaction's version after its database commit. A concurrent owner - // cannot initialize from this in-between state, so it stays inert. + if current != nil && current.active && current.stateVersion == stateVersion { return } - gate.current.Store(&versionGeneration{}) + gate.current.Store(nil) if clear != nil { clear() } @@ -140,17 +138,21 @@ type PlainStateVersionPublication struct { transition *versionGeneration } -// Begin revokes all existing views without changing cache entries. +// Begin revokes all existing views without changing cache entries. It also +// blocks backing-file changes until Publish or Abort completes the durable +// transition. func (p PlainStateVersionPublisher) Begin() *PlainStateVersionPublication { if p.gate == nil { return nil } gate := p.gate + gate.publicationMu.Lock() gate.admissionMu.Lock() defer gate.admissionMu.Unlock() previous := gate.current.Load() if previous != nil && !previous.active { + gate.publicationMu.Unlock() panic("cache version publication already in progress") } transition := &versionGeneration{} @@ -163,12 +165,14 @@ func (p *PlainStateVersionPublication) Abort() { if p == nil || p.gate == nil { return } - p.gate.admissionMu.Lock() - defer p.gate.admissionMu.Unlock() - if p.gate.current.Load() != p.transition { + gate := p.gate + gate.admissionMu.Lock() + defer gate.publicationMu.Unlock() + defer gate.admissionMu.Unlock() + if gate.current.Load() != p.transition { panic("cache version publication changed before abort") } - p.gate.current.Store(p.previous) + gate.current.Store(p.previous) p.gate = nil } @@ -177,15 +181,17 @@ func (p *PlainStateVersionPublication) Publish(stateVersion uint64, apply func() if p == nil || p.gate == nil { return } - p.gate.admissionMu.Lock() - defer p.gate.admissionMu.Unlock() - if p.gate.current.Load() != p.transition { + gate := p.gate + gate.admissionMu.Lock() + defer gate.publicationMu.Unlock() + defer gate.admissionMu.Unlock() + if gate.current.Load() != p.transition { panic("cache version publication changed before publish") } if apply != nil { apply() } - p.gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) + gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) p.gate = nil } @@ -195,17 +201,59 @@ func (g *PlainStateVersionGate) Reset(clear func()) { if g == nil { return } + g.publicationMu.Lock() + defer g.publicationMu.Unlock() g.admissionMu.Lock() defer g.admissionMu.Unlock() - current := g.current.Load() - if current != nil && !current.active { - panic("cannot reset cache during version publication") + g.current.Store(nil) + if clear != nil { + clear() } - g.current.Store(&versionGeneration{}) +} + +// PlainStateVersionBackingChange keeps cache publication blocked while a new +// backing-file view becomes visible. +type PlainStateVersionBackingChange struct { + gate *PlainStateVersionGate +} + +// BeginBackingChange runs reconcile while publications and fills are blocked. +// If reconcile reports that cached entries no longer match the backing data, +// the current generation is revoked and cleared. The returned handle keeps +// publication blocked until Finish makes the new backing view observable. +func (p PlainStateVersionPublisher) BeginBackingChange(reconcile func() bool, clear func()) *PlainStateVersionBackingChange { + if p.gate == nil { + return nil + } + gate := p.gate + gate.publicationMu.Lock() + gate.admissionMu.Lock() + keepPublicationLocked := false + defer func() { + gate.admissionMu.Unlock() + if !keepPublicationLocked { + gate.publicationMu.Unlock() + } + }() + + if reconcile == nil || !reconcile() { + return nil + } + gate.current.Store(nil) if clear != nil { clear() } - g.current.Store(nil) + keepPublicationLocked = true + return &PlainStateVersionBackingChange{gate: gate} +} + +// Finish allows cache publication after the backing-file view is visible. +func (c *PlainStateVersionBackingChange) Finish() { + if c == nil || c.gate == nil { + return + } + c.gate.publicationMu.Unlock() + c.gate = nil } // Close permanently revokes current views. The owner may then close its cache @@ -214,7 +262,9 @@ func (g *PlainStateVersionGate) Close() { if g == nil { return } + g.publicationMu.Lock() + defer g.publicationMu.Unlock() g.admissionMu.Lock() - g.current.Store(&versionGeneration{}) + g.current.Store(nil) g.admissionMu.Unlock() } diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 841d2938141..d1d3e5c08de 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -52,6 +52,11 @@ func isCommitmentStateKey(prefix []byte) bool { type BranchCache struct { version cache.PlainStateVersionGate + // committedTxNumEnd is only a file-provenance watermark. Cache validity is + // still decided by PlainStateVersion; this end distinguishes locally built + // commitment files from files downloaded outside the publication stream. + committedTxNumEnd uint64 + // Root tier — single slot for the root branch (always hottest, always // present). Atomic-pointer access so no lock is needed for the hot // read path. @@ -351,7 +356,26 @@ func (c *BranchCache) Close() { // publication. It is required when the backing commitment view changes // without advancing PlainStateVersion. func (c *BranchCache) Reset() { - c.version.Reset(c.Clear) + c.version.Reset(func() { + c.committedTxNumEnd = 0 + c.Clear() + }) +} + +// BeginFilesPublication revokes and clears the cache when commitment files +// expose state beyond this process's committed branch updates. Finish must be +// called after the new files view becomes visible. +func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.PlainStateVersionBackingChange { + if c == nil { + return nil + } + return c.version.Publisher().BeginBackingChange(func() bool { + if filesEnd <= c.committedTxNumEnd { + return false + } + c.committedTxNumEnd = filesEnd + return true + }, c.Clear) } // tailForWrite returns the LRU tail, allocating it on first use so a cache whose diff --git a/execution/commitment/branch_cache_absorb_test.go b/execution/commitment/branch_cache_absorb_test.go new file mode 100644 index 00000000000..f6bbc87e800 --- /dev/null +++ b/execution/commitment/branch_cache_absorb_test.go @@ -0,0 +1,65 @@ +// 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 commitment + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBranchCacheFilesPublication(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(1) + key := []byte{0x01} + value := []byte{0xbb} + + publication := publisher.Begin() + publication.Publish(2, []BranchUpdate{{ + Key: key, + Value: value, + Step: 1, + TxNum: 100, + }}, false, nil) + view := branchCache.View(2) + got, _, ok := view.Get(key) + require.True(t, ok) + require.Equal(t, value, got) + + require.Nil(t, branchCache.BeginFilesPublication(101)) + _, _, ok = view.Get(key) + require.True(t, ok, "files covered by committed updates must not clear the cache") + + change := branchCache.BeginFilesPublication(150) + require.NotNil(t, change) + _, _, ok = view.Get(key) + require.False(t, ok, "foreign files must revoke the published generation") + change.Finish() + + publication = publisher.Begin() + publication.Publish(3, nil, false, nil) + current := branchCache.View(3) + _, _, ok = current.Get(key) + require.False(t, ok, "the next commit must not reactivate entries from the old backing view") + + current.Fill(key, value, 1) + require.Nil(t, branchCache.BeginFilesPublication(150)) + _, _, ok = current.Get(key) + require.True(t, ok, "an already absorbed files view must not clear again") +} diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index a91bc440da3..0ae87fc8b55 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -65,11 +65,13 @@ func (v BranchReadView) Fill(prefix, value []byte, step uint64) { }) } -// BranchUpdate is one committed commitment-domain value. +// BranchUpdate is one committed commitment-domain value. TxNum records process +// write coverage for detecting files downloaded outside this publication path. type BranchUpdate struct { Key []byte Value []byte Step uint64 + TxNum uint64 } // BranchPublisher is the canonical mutation handle for BranchCache. @@ -135,6 +137,9 @@ func (p *BranchPublication) Publish(stateVersion uint64, updates []BranchUpdate, adaptive.apply() for i := range updates { update := &updates[i] + if committedEnd := update.TxNum + 1; committedEnd > p.c.committedTxNumEnd { + p.c.committedTxNumEnd = committedEnd + } if len(update.Value) == 0 { p.c.Invalidate(update.Key) continue diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 35b8a98cc62..0326d7de3bf 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -255,7 +255,7 @@ func NewExecModule( stopNode func() error, ) *ExecModule { domainCache := newDomainStateCache(stateCacheBudget) - execctx.GuardAggregatorForCache(db, domainCache) + execctx.BindStateCacheToAggregator(db, domainCache) var codeStore *cache.CodeStore if dbg.UseCodeStore { codeStore = cache.NewCodeStore(cache.DefaultCodeStoreMemBytes, cache.DefaultCodeStoreTableBytes) From 28bc1af2e8ca167a9ac15a12ff86a134416c82af Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:21:07 +0200 Subject: [PATCH 06/50] execution/vm: clarify jump destination cache comment --- execution/vm/contract.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/vm/contract.go b/execution/vm/contract.go index cb9e669acb9..e10c8a9da30 100644 --- a/execution/vm/contract.go +++ b/execution/vm/contract.go @@ -113,7 +113,7 @@ func (c *Contract) isCode(udest uint64) bool { c.analysis = codeBitmap(c.Code) if !isCodeHashZero { - // content-addressed by codeHash and never unwound, so txNum is irrelevant + // Code analysis is content-addressed and remains valid across state changes. jumpDestCache.Put(codeHash[:], c.analysis) } From 6e3da3363a3740c9adc94006995ce50b6cf0efd4 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:22:28 +0200 Subject: [PATCH 07/50] execution/cache, db/state: bind cache generations to file views --- db/state/aggregator.go | 20 +- db/state/aggregator_align_test.go | 24 +- db/state/execctx/branch_cache_flush_test.go | 27 +- db/state/execctx/domain_shared.go | 108 +++--- db/state/execctx/export_test.go | 2 +- db/state/execctx/statecache_readfill_test.go | 14 +- .../statecache_rpc_integration_test.go | 80 +++++ execution/cache/cache.go | 11 +- execution/cache/cache_test.go | 94 ++--- execution/cache/files_publication_test.go | 29 +- execution/cache/generation_gate.go | 334 ++++++++++++++++++ execution/cache/state_cache.go | 92 ++--- execution/cache/version_gate.go | 270 -------------- execution/cache/view.go | 34 +- execution/commitment/adaptive_pin_test.go | 6 +- execution/commitment/branch_cache.go | 28 +- .../commitment/branch_cache_absorb_test.go | 27 +- execution/commitment/branch_cache_test.go | 40 ++- execution/commitment/branch_cache_view.go | 53 ++- execution/commitment/preload_parallel.go | 2 +- execution/exec/blocks_read_ahead.go | 19 +- execution/exec/blocks_read_ahead_test.go | 8 +- 22 files changed, 756 insertions(+), 566 deletions(-) create mode 100644 execution/cache/generation_gate.go delete mode 100644 execution/cache/version_gate.go diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 06cc85369f7..d66951841ba 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -91,9 +91,9 @@ type Aggregator struct { // regenerates them. Guarded by dirtyFilesLock. unalignedDomain [kv.DomainLen]bool unalignedIdx [kv.StandaloneIdxLen]bool - // visibilityLoweringForbidden: a single-version cache is wired over this - // aggregator, and PlainStateVersion does not encode changes to file - // visibility. Close clears it because shutdown is not a cache-read window. + // Cache reconciliation assumes that visible ends only advance. Lowering one + // could retain an entry that existed only in the newer files view. Close + // clears this guard because shutdown is not a cache-read window. visibilityLoweringForbidden atomic.Bool // boundStateCache is reconciled before a new files view becomes visible. // Guarded by dirtyFilesLock. @@ -551,9 +551,9 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// ForbidVisibilityLowering marks this aggregator as backing a single-version -// cache. From then on recalcVisibleFiles rejects lowering a cached domain's -// visible end because PlainStateVersion does not identify that change. +// ForbidVisibilityLowering marks this aggregator as backing a shared latest +// state cache. From then on recalcVisibleFiles rejects lowering a cached +// domain's visible end because cache file-provenance watermarks only advance. // Serialized with recalcVisibleFiles via dirtyFilesLock so "from then on" // holds against a recalculation already in flight. func (a *Aggregator) ForbidVisibilityLowering() { @@ -1917,8 +1917,8 @@ func visibleStateFilesEnd(visible *aggregatorVisible) (ends [kv.DomainLen]uint64 } type cacheFilesPublication struct { - state *cache.PlainStateVersionBackingChange - branch *cache.PlainStateVersionBackingChange + state *cache.BackingChange + branch *cache.BackingChange } func (a *Aggregator) beginCacheFilesPublication(visible *aggregatorVisible) cacheFilesPublication { @@ -1977,7 +1977,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevEnd := visibleFiles(prev.d[d].files).EndTxNum() nextEnd := visibleFiles(next.d[d].files).EndTxNum() if nextEnd < prevEnd { - panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a single-version cache is wired — PlainStateVersion does not identify file-visibility changes", d, prevEnd, nextEnd)) + panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a shared cache is wired — file-provenance watermarks only advance", d, prevEnd, nextEnd)) } if prev.dhii[d] == nil || next.dhii[d] == nil { continue @@ -1985,7 +1985,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevII := prev.dhii[d].files.EndTxNum() nextII := next.dhii[d].files.EndTxNum() if nextII < prevII { - panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a single-version cache is wired — exact cache-view eligibility derives its frontier from history-II", d, prevII, nextII)) + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a shared cache is wired — exact cache-view eligibility derives its frontier from history-II", d, prevII, nextII)) } } } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 4a37d35f5b4..d8a44dfd8fc 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -302,12 +302,12 @@ func TestFilePublicationRevokesCacheGenerations(t *testing.T) { stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) t.Cleanup(stateCache.Close) statePublisher := stateCache.Publisher() - statePublisher.Initialize(1) + statePublisher.Initialize(cache.StateGeneration(1, 0, 0, 0)) execctx.BindStateCacheToAggregator(cacheAggregatorHolder{agg}, stateCache) accountKey := make([]byte, 20) accountKey[0] = 1 - stateView := stateCache.View(1) + stateView := stateCache.View(cache.StateGeneration(1, 0, 0, 0)) stateView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) _, ok := stateView.Get(kv.AccountsDomain, accountKey) require.True(t, ok) @@ -315,9 +315,9 @@ func TestFilePublicationRevokesCacheGenerations(t *testing.T) { branchCache := agg.d[kv.CommitmentDomain].branchCache require.NotNil(t, branchCache) branchPublisher := branchCache.Publisher() - branchPublisher.Initialize(1) + branchPublisher.Initialize(cache.BranchGeneration(1, 0)) branchKey := []byte{0x01} - branchView := branchCache.View(1) + branchView := branchCache.View(cache.BranchGeneration(1, 0)) branchView.Fill(branchKey, []byte{0xbb}, 1) _, _, ok = branchView.Get(branchKey) require.True(t, ok) @@ -330,15 +330,15 @@ func TestFilePublicationRevokesCacheGenerations(t *testing.T) { _, ok = stateView.Get(kv.AccountsDomain, accountKey) require.False(t, ok, "file publication must revoke pre-publication state views") stateView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) - statePublisher.Initialize(1) - _, ok = stateCache.View(1).Get(kv.AccountsDomain, accountKey) + statePublisher.Initialize(cache.StateGeneration(1, 2*alignStepSize, 2*alignStepSize, 2*alignStepSize)) + _, ok = stateCache.View(cache.StateGeneration(1, 2*alignStepSize, 2*alignStepSize, 2*alignStepSize)).Get(kv.AccountsDomain, accountKey) require.False(t, ok, "a revoked state view must not refill after file publication") _, _, ok = branchView.Get(branchKey) require.False(t, ok, "file publication must revoke pre-publication branch views") branchView.Fill(branchKey, []byte{0xbb}, 1) - branchPublisher.Initialize(1) - _, _, ok = branchCache.View(1).Get(branchKey) + branchPublisher.Initialize(cache.BranchGeneration(1, 2*alignStepSize)) + _, _, ok = branchCache.View(cache.BranchGeneration(1, 2*alignStepSize)).Get(branchKey) require.False(t, ok, "a revoked branch view must not refill after file publication") } @@ -354,10 +354,10 @@ func TestCacheBindingAbsorbsExistingFileVisibility(t *testing.T) { stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) t.Cleanup(stateCache.Close) statePublisher := stateCache.Publisher() - statePublisher.Initialize(1) + statePublisher.Initialize(cache.StateGeneration(1, 0, 0, 0)) accountKey := make([]byte, 20) accountKey[0] = 1 - oldView := stateCache.View(1) + oldView := stateCache.View(cache.StateGeneration(1, 0, 0, 0)) oldView.Fill(kv.AccountsDomain, accountKey, []byte{1}, 1) _, ok := oldView.Get(kv.AccountsDomain, accountKey) require.True(t, ok) @@ -366,7 +366,7 @@ func TestCacheBindingAbsorbsExistingFileVisibility(t *testing.T) { _, ok = oldView.Get(kv.AccountsDomain, accountKey) require.False(t, ok, "binding must revoke entries created before the visible files were absorbed") - statePublisher.Initialize(1) - _, ok = stateCache.View(1).Get(kv.AccountsDomain, accountKey) + statePublisher.Initialize(cache.StateGeneration(1, 2*alignStepSize, 2*alignStepSize, 2*alignStepSize)) + _, ok = stateCache.View(cache.StateGeneration(1, 2*alignStepSize, 2*alignStepSize, 2*alignStepSize)).Get(kv.AccountsDomain, accountKey) require.False(t, ok) } diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 25a3f53ce20..c830681f0e9 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -26,6 +26,7 @@ import ( "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/commitment" ) @@ -36,6 +37,13 @@ type commitErrorTx struct { func (tx *commitErrorTx) Commit() error { return tx.err } +func branchGenerationForTx(t *testing.T, tx kv.TemporalTx) cache.Generation { + t.Helper() + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + return cache.BranchGeneration(stateVersion, tx.Debug().TxNumsInFiles(kv.CommitmentDomain)) +} + // Use Commit (not Flush) so the rebuilt branch refreshes the BranchCache entry. func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { stepSize := uint64(100) @@ -95,12 +103,11 @@ func TestSpeculativeUnwindDetachesWithoutChangingBranchCache(t *testing.T) { branchCache := provider.BranchCache() require.NotNil(t, branchCache) - stateVersion, err := rawdb.GetStateVersion(roTx) - require.NoError(t, err) - branchCache.Publisher().Initialize(stateVersion) + generation := branchGenerationForTx(t, roTx) + branchCache.Publisher().Initialize(generation) key := []byte{0xa0, 0xb0} - published := branchCache.View(stateVersion) + published := branchCache.View(generation) published.Fill(key, []byte("canonical-cache-only"), 1) sd.Unwind(50, nil) @@ -139,9 +146,7 @@ func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) require.True(t, ok) branchCache := provider.BranchCache() - stateVersion, err := rawdb.GetStateVersion(unwindTx) - require.NoError(t, err) - oldView := branchCache.View(stateVersion) + oldView := branchCache.View(branchGenerationForTx(t, unwindTx)) cacheOnlyKey := []byte{0xa0, 0xc0} oldView.Fill(cacheOnlyKey, []byte("discarded-fork"), 2) @@ -158,9 +163,7 @@ func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { readTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer readTx.Rollback() - newStateVersion, err := rawdb.GetStateVersion(readTx) - require.NoError(t, err) - _, _, ok = branchCache.View(newStateVersion).Get(cacheOnlyKey) + _, _, ok = branchCache.View(branchGenerationForTx(t, readTx)).Get(cacheOnlyKey) require.False(t, ok, "the unwound generation must not retain a cache-only discarded branch") } @@ -187,9 +190,7 @@ func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) require.True(t, ok) branchCache := provider.BranchCache() - stateVersion, err := rawdb.GetStateVersion(rwTx) - require.NoError(t, err) - view := branchCache.View(stateVersion) + view := branchCache.View(branchGenerationForTx(t, rwTx)) key := []byte{0xa0, 0xb0} view.Fill(key, []byte("durable"), 1) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b2522558165..cb8212035de 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -87,49 +87,60 @@ type cacheViews struct { branch commitment.BranchReadView } -// cacheViewsFor binds both process-global caches to the state version of tx. -// Most reads use the base transaction and reuse the construction-time -// metadata stored on SharedDomains. Reads through another transaction -// re-evaluate its version and exact domain frontiers. +// cacheViewsFor binds both process-global caches to the database and files +// generation pinned by tx. The common path reuses construction-time metadata; +// reads through another transaction derive its generation again. func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { if tx == nil { return cacheViews{} } - var stateVersion uint64 + var stateGeneration, branchGeneration cache.Generation var stateEligible, branchEligible bool if tx.ViewID() == sd.baseViewID { if !sd.baseStateVersionKnown { return cacheViews{} } - stateVersion = sd.baseStateVersion + stateGeneration = sd.baseStateCacheGeneration + branchGeneration = sd.baseBranchCacheGeneration stateEligible = sd.baseStateCacheEligible branchEligible = sd.baseBranchCacheEligible } else { - var err error - stateVersion, err = rawdb.GetStateVersion(tx) + stateVersion, err := rawdb.GetStateVersion(tx) if err != nil { return cacheViews{} } - stateEligible = cacheViewEligible(tx, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) - branchEligible = cacheViewEligible(tx, kv.CommitmentDomain) + debug := tx.Debug() + stateGeneration, branchGeneration = cacheGenerationsFor(debug, stateVersion) + stateEligible = cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) + branchEligible = cacheViewEligible(debug, kv.CommitmentDomain) } var views cacheViews if sd.stateCache != nil && stateEligible { - views.state = sd.stateCache.View(stateVersion) + views.state = sd.stateCache.View(stateGeneration) } if sd.branchCache != nil && branchEligible { - views.branch = sd.branchCache.View(stateVersion) + views.branch = sd.branchCache.View(branchGeneration) } return views } -// cacheViewEligible rejects a dependency-clamped domain view. Such a view -// mixes database values with older file values and may later expose newer -// files without changing PlainStateVersion; a fill from it could therefore -// outlive the snapshot that produced the value. -func cacheViewEligible(tx kv.TemporalTx, domains ...kv.Domain) bool { +func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) (cache.Generation, cache.Generation) { + stateGeneration := cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) + branchGeneration := cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) + return stateGeneration, branchGeneration +} + +// cacheViewEligible rejects a dependency-clamped domain view. Its reads mix +// database state with an older values frontier, so it has no exact cache +// identity. +func cacheViewEligible(debug kv.TemporalDebugTx, domains ...kv.Domain) bool { for _, domain := range domains { - if _, ok := tx.Debug().DomainVisibleEnd(domain); !ok { + if _, ok := debug.DomainVisibleEnd(domain); !ok { return false } } @@ -158,11 +169,12 @@ type SharedDomains struct { // These fields describe the database snapshot used to construct this // SharedDomains. The common read path reuses them instead of reading cache // eligibility metadata for every GetLatest call. - baseViewID uint64 - baseStateVersion uint64 - baseStateVersionKnown bool - baseStateCacheEligible bool - baseBranchCacheEligible bool + baseViewID uint64 + baseStateCacheGeneration cache.Generation + baseBranchCacheGeneration cache.Generation + baseStateVersionKnown bool + baseStateCacheEligible bool + baseBranchCacheEligible bool txNum uint64 currentStep kv.Step @@ -187,7 +199,7 @@ type SharedDomains struct { // to read from the FCU's published SD without writing to it. parent *SharedDomains - // stateCache provides version-bound reads and fills. cachePublisher is set + // stateCache provides generation-bound reads and fills. cachePublisher is set // only when this SharedDomains owns publication of durable canonical state; // a speculative SharedDomains may read the cache but cannot move its // generation or change its authoritative entries. @@ -209,8 +221,8 @@ type SharedDomains struct { changesetMu sync.Mutex // branchCache is the aggregator-scope commitment cache. Local and parent - // memory overlays take precedence; the PlainStateVersion view then prevents - // one SharedDomains from observing another transaction's branch generation. + // memory overlays take precedence; its generation view then prevents one + // SharedDomains from observing another transaction's cached branches. branchCache *commitment.BranchCache branchPublisher commitment.BranchPublisher // Like clearStateCache, this survives reader detachment and Merge. @@ -269,15 +281,18 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, trieCfg := o.trieCfg stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) + debug := tx.Debug() + stateGeneration, branchGeneration := cacheGenerationsFor(debug, stateVersion) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: tx.Debug().StepSize(), - baseViewID: tx.ViewID(), - baseStateVersion: stateVersion, - baseStateVersionKnown: stateVersionErr == nil, - baseStateCacheEligible: cacheViewEligible(tx, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain), - baseBranchCacheEligible: cacheViewEligible(tx, kv.CommitmentDomain), + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: debug.StepSize(), + baseViewID: tx.ViewID(), + baseStateCacheGeneration: stateGeneration, + baseBranchCacheGeneration: branchGeneration, + baseStateVersionKnown: stateVersionErr == nil, + baseStateCacheEligible: cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain), + baseBranchCacheEligible: cacheViewEligible(debug, kv.CommitmentDomain), } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -802,7 +817,7 @@ func (sd *SharedDomains) GetCommitmentCtx() *commitmentdb.SharedDomainsCommitmen func (sd *SharedDomains) Logger() log.Logger { return sd.logger } -// SetStateCacheReader attaches the process-global cache for version-checked +// SetStateCacheReader attaches the process-global cache for generation-checked // reads and read-through fills. It does not grant authority to publish, clear, // or otherwise move the cache's durable generation. // @@ -821,13 +836,13 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { // SetCanonicalStateCache attaches the same reader and also grants publication // authority. Use it only for a SharedDomains whose Commit makes state durable: // Commit may revoke existing views, apply the committed cache updates, and -// publish the resulting PlainStateVersion. A canonical unwind may additionally -// clear all entries before publishing its rewound version. +// publish the resulting database and files generation. A canonical unwind may +// additionally clear all entries before publishing its rewound state. // // Initialize binds the process-global cache to this SharedDomains' base -// database version. Keeping this authority separate from SetStateCacheReader -// prevents speculative rollback or unwind from changing globally visible -// cache state. +// database and files snapshot. Keeping this authority separate from +// SetStateCacheReader prevents speculative rollback or unwind from changing +// globally visible cache state. func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil || !sd.baseStateVersionKnown { return @@ -836,7 +851,7 @@ func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { sd.stateCache = stateCache } sd.cachePublisher = stateCache.Publisher() - sd.cachePublisher.Initialize(sd.baseStateVersion) + sd.cachePublisher.Initialize(sd.baseStateCacheGeneration) } // BindStateCacheToAggregator binds StateCache to the aggregator's file @@ -1084,13 +1099,14 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return err } - var stateVersion uint64 + var stateGeneration, branchGeneration cache.Generation if stateCacheEnabled || branchCacheEnabled { - var err error - stateVersion, err = rawdb.GetStateVersion(tx) + stateVersion, err := rawdb.GetStateVersion(tx) if err != nil { return fmt.Errorf("read plain state version: %w", err) } + stateGeneration = sd.baseStateCacheGeneration.WithStateVersion(stateVersion) + branchGeneration = sd.baseBranchCacheGeneration.WithStateVersion(stateVersion) } var statePublication *cache.Publication @@ -1117,9 +1133,9 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return err } - statePublication.Publish(stateVersion, stateUpdates, sd.clearStateCache) + statePublication.Publish(stateGeneration, stateUpdates, sd.clearStateCache) statePublication = nil - branchPublication.Publish(stateVersion, branchUpdates, sd.clearBranchCache, adaptivePlan) + branchPublication.Publish(branchGeneration, branchUpdates, sd.clearBranchCache, adaptivePlan) branchPublication = nil adaptivePlan.Commit() adaptivePlan = nil diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index a439f48e2ab..c27063cf44b 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -22,7 +22,7 @@ func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { } if sd.baseStateVersionKnown { sd.cachePublisher = sc.Publisher() - sd.cachePublisher.Initialize(sd.baseStateVersion) + sd.cachePublisher.Initialize(sd.baseStateCacheGeneration) } } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index e7fdc21bf9b..20f87f1595d 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -80,7 +80,7 @@ func currentStateCacheView(t *testing.T, stateCache *cache.StateCache) cache.Rea t.Helper() stateVersion, ok := stateCache.CurrentStateVersion() require.True(t, ok) - return stateCache.View(stateVersion) + return stateCache.View(cache.StateGeneration(stateVersion, 0, 0, 0)) } // During an in-flight unwind this SharedDomains is detached from StateCache, @@ -185,7 +185,7 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { key, _, v2, diffs := twoStepRows(t, db, sc) stateVersion, ok := sc.CurrentStateVersion() require.True(t, ok) - sc.Publisher().Begin().Publish(stateVersion, nil, true) + sc.Publisher().Begin().Publish(cache.StateGeneration(stateVersion, 0, 0, 0), nil, true) roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) @@ -204,7 +204,7 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { currentVersion, ok := sc.CurrentStateVersion() require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") require.Equal(t, stateVersion, currentVersion) - _, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + _, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) require.False(t, ok, "the detached SharedDomains must not fill from its rewound database view") } @@ -237,7 +237,7 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { require.NoError(t, sd.Commit(ctx, rwTx)) stateVersion, ok := sc.CurrentStateVersion() require.True(t, ok) - sc.Publisher().Begin().Publish(stateVersion, nil, true) + sc.Publisher().Begin().Publish(cache.StateGeneration(stateVersion, 0, 0, 0), nil, true) stepBytes := make([]byte, 8) binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) @@ -259,7 +259,7 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { currentVersion, ok := sc.CurrentStateVersion() require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") require.Equal(t, stateVersion, currentVersion) - _, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + _, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) require.False(t, ok, "code-hash lookup through the rewound view must not fill the durable cache") } @@ -275,7 +275,7 @@ func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { stateVersion, ok := sc.CurrentStateVersion() require.True(t, ok) - got, ok := sc.View(stateVersion).Get(kv.AccountsDomain, key) + got, ok := sc.View(cache.StateGeneration(stateVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, v2, got) @@ -291,7 +291,7 @@ func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { currentVersion, ok := sc.CurrentStateVersion() require.True(t, ok) require.Equal(t, stateVersion, currentVersion) - got, ok = sc.View(currentVersion).Get(kv.AccountsDomain, key) + got, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, v2, got) } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index a615d23edfd..2af78cc9da9 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -26,6 +26,8 @@ import ( "github.com/erigontech/erigon/common/crypto" "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" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/execmodule" @@ -187,6 +189,84 @@ func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testin require.Equal(t, v1, got) } +func TestSharedDomainsOldFilesTxBoundAfterPublicationDoesNotUseNewCacheGeneration(t *testing.T) { + const stepSize = uint64(1) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + execctx.BindStateCacheToAggregator(db, stateCache) + + key := make([]byte, 52) + key[0] = 0xaa + v1, v2, v3 := []byte{1}, []byte{2}, []byte{3} + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetTxNum(1) + require.NoError(t, seedDomains.DomainPut(kv.StorageDomain, seedTx, key, v1, 1, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + readerTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer readerTx.Rollback() + readerDomains, err := execctx.NewSharedDomains(ctx, readerTx, log.New()) + require.NoError(t, err) + defer readerDomains.Close() + readerDomains.SetStateCacheReaderForTest(stateCache) + + writeStorage := func(txNum uint64, value, prevValue []byte) { + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + domains.SetTxNum(txNum) + require.NoError(t, domains.DomainPut(kv.StorageDomain, rwTx, key, value, txNum, prevValue)) + require.NoError(t, domains.Flush(ctx, rwTx)) + require.NoError(t, rwTx.Commit()) + } + + oldTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer oldTx.Rollback() + oldStateVersion, err := rawdb.GetStateVersion(oldTx) + require.NoError(t, err) + oldFilesEnd := oldTx.Debug().TxNumsInFiles(kv.StorageDomain) + + writeStorage(2, v2, v1) + writeStorage(3, v3, v2) + agg := db.(state.HasAgg).Agg().(*state.Aggregator) + require.NoError(t, agg.BuildFiles(3)) + + // The extra writes only create the extended files. Restoring the version + // models a downloaded files publication without a database-state commit. + resetTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer resetTx.Rollback() + require.NoError(t, resetTx.ResetSequence(string(kv.PlainStateVersion), oldStateVersion)) + require.NoError(t, resetTx.Commit()) + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + require.Greater(t, freshTx.Debug().TxNumsInFiles(kv.StorageDomain), oldFilesEnd) + + got, _, err := readerDomains.GetLatest(kv.StorageDomain, freshTx, key) + require.NoError(t, err) + require.Equal(t, v3, got) + + got, _, err = readerDomains.GetLatest(kv.StorageDomain, oldTx, key) + require.NoError(t, err) + require.Equal(t, v1, got, "a transaction pinned to the old files must not use the new files cache generation") +} + func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() diff --git a/execution/cache/cache.go b/execution/cache/cache.go index bd39668501a..0a7939a467d 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -16,14 +16,15 @@ // Package cache provides the process-global cache of latest committed state. // -// StateCache represents exactly one durable PlainStateVersion at a time. It -// does not keep old generations: a transaction whose snapshot has another -// version receives an inert ReadView and reads from the database instead. +// StateCache represents exactly one Generation at a time: one durable +// PlainStateVersion over one compatible immutable-files view. It does not keep +// old generations; a transaction with another identity receives an inert +// ReadView and reads from its own database snapshot instead. // // Publishing canonical state revokes the current generation before changing // entries and exposes the next generation only after the database commit. -// This keeps concurrent readers on one complete version even though the cache -// itself is process-global. Multi-version snapshot caching remains the +// This keeps concurrent readers on one complete generation even though the +// cache is process-global. Multi-version snapshot caching remains the // responsibility of kvcache. package cache diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index ef2dce3769d..c87db939d13 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -58,13 +58,17 @@ func makeValue(i int) []byte { return []byte{byte(i), byte(i + 1), byte(i + 2)} } +func testStateGeneration(stateVersion uint64) Generation { + return StateGeneration(stateVersion, 0, 0, 0) +} + func readyStateCache(t *testing.T, stateVersion uint64) (*StateCache, Publisher) { t.Helper() b := 1 * datasize.MB stateCache := NewStateCache(b, b, b, b) t.Cleanup(stateCache.Close) publisher := stateCache.Publisher() - publisher.Initialize(stateVersion) + publisher.Initialize(testStateGeneration(stateVersion)) return stateCache, publisher } @@ -454,7 +458,7 @@ func TestStateCache_NewDefaultStateCache(t *testing.T) { func TestStateCache_GetPut_Account(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) addr := makeAddr(1) value := makeValue(1) @@ -473,7 +477,7 @@ func TestStateCache_GetPut_Account(t *testing.T) { func TestStateCache_GetPut_Storage(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) key := make([]byte, 52) // addr(20) + slot(32) copy(key, makeAddr(1)) @@ -488,7 +492,7 @@ func TestStateCache_GetPut_Storage(t *testing.T) { func TestStateCache_GetPut_Code(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) addr := makeAddr(1) code := makeCode(1) @@ -501,7 +505,7 @@ func TestStateCache_GetPut_Code(t *testing.T) { func TestStateCache_GetPut_UnsupportedDomain(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) // ReceiptDomain is not supported view.Fill(kv.ReceiptDomain, makeAddr(1), makeValue(1), 0) @@ -514,11 +518,11 @@ func TestStateCache_Delete(t *testing.T) { c, publisher := readyStateCache(t, 1) addr := makeAddr(1) - c.View(1).Fill(kv.AccountsDomain, addr, makeValue(1), 0) + c.View(testStateGeneration(1)).Fill(kv.AccountsDomain, addr, makeValue(1), 0) publication := publisher.Begin() - publication.Publish(2, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(2), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) - _, ok := c.View(2).Get(kv.AccountsDomain, addr) + _, ok := c.View(testStateGeneration(2)).Get(kv.AccountsDomain, addr) assert.False(t, ok) } @@ -527,7 +531,7 @@ func TestStateCache_Delete(t *testing.T) { // the caller unnecessarily falls through to the DB on every read. func TestStateCache_PutEmpty_ThenGet_IsCacheHit(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) key := make([]byte, 52) // addr(20) + slot(32) key[0] = 0x1d @@ -543,7 +547,7 @@ func TestStateCache_PutEmpty_ThenGet_IsCacheHit(t *testing.T) { // Same test for []byte{} (zero-length but non-nil). func TestStateCache_PutEmptySlice_ThenGet_IsCacheHit(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) key := make([]byte, 52) key[0] = 0x1d @@ -561,20 +565,20 @@ func TestStateCache_Delete_UnsupportedDomain(t *testing.T) { require.NotPanics(t, func() { publication := publisher.Begin() - publication.Publish(2, []Update{{Domain: kv.ReceiptDomain, Key: makeAddr(1)}}, false) + publication.Publish(testStateGeneration(2), []Update{{Domain: kv.ReceiptDomain, Key: makeAddr(1)}}, false) }) } func TestStateCache_Clear(t *testing.T) { c, publisher := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) view.Fill(kv.AccountsDomain, makeAddr(1), makeValue(1), 0) view.Fill(kv.StorageDomain, makeAddr(2), makeValue(2), 0) view.Fill(kv.CodeDomain, makeAddr(3), makeCode(3), 0) - publisher.Clear(2) - view = c.View(2) + publisher.Clear(testStateGeneration(2)) + view = c.View(testStateGeneration(2)) _, ok1 := view.Get(kv.AccountsDomain, makeAddr(1)) _, ok2 := view.Get(kv.StorageDomain, makeAddr(2)) @@ -656,7 +660,7 @@ func TestCodeCache_ConcurrentAccess(t *testing.T) { func TestStateCache_DomainIsolation(t *testing.T) { c, _ := readyStateCache(t, 1) - view := c.View(1) + view := c.View(testStateGeneration(1)) addr := makeAddr(1) accountData := []byte("account") @@ -771,21 +775,21 @@ func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { sc, publisher := readyStateCache(t, 1) key := makeAddr(1) fork := makeValue(2) - preReorg := sc.View(1) + preReorg := sc.View(testStateGeneration(1)) preReorg.Fill(kv.AccountsDomain, key, fork, 10) publication := publisher.Begin() - publication.Publish(2, nil, true) + publication.Publish(testStateGeneration(2), nil, true) preReorg.Fill(kv.AccountsDomain, key, fork, 10) - _, ok := sc.View(2).Get(kv.AccountsDomain, key) + _, ok := sc.View(testStateGeneration(2)).Get(kv.AccountsDomain, key) require.False(t, ok, "a pre-reorg view must not reinstate the discarded fork's value") } func TestStateCache_PublicationIsOneGeneration(t *testing.T) { sc, publisher := readyStateCache(t, 10) oldKey, changedKey := makeAddr(1), makeAddr(2) - oldView := sc.View(10) + oldView := sc.View(testStateGeneration(10)) oldView.Fill(kv.AccountsDomain, oldKey, makeValue(1), 1) oldView.Fill(kv.AccountsDomain, changedKey, makeValue(2), 2) @@ -793,7 +797,7 @@ func TestStateCache_PublicationIsOneGeneration(t *testing.T) { _, ok := oldView.Get(kv.AccountsDomain, oldKey) require.False(t, ok, "the old generation must be unavailable during publication") - publication.Publish(11, []Update{{ + publication.Publish(testStateGeneration(11), []Update{{ Domain: kv.AccountsDomain, Key: changedKey, Value: makeValue(3), @@ -802,7 +806,7 @@ func TestStateCache_PublicationIsOneGeneration(t *testing.T) { _, ok = oldView.Get(kv.AccountsDomain, oldKey) require.False(t, ok, "publication must revoke old read views") - freshView := sc.View(11) + freshView := sc.View(testStateGeneration(11)) got, ok := freshView.Get(kv.AccountsDomain, oldKey) require.True(t, ok) require.Equal(t, makeValue(1), got, "forward publication keeps unchanged entries") @@ -815,7 +819,7 @@ func TestStateCache_PublicationIsOneGeneration(t *testing.T) { func TestStateCache_AbortRestoresGeneration(t *testing.T) { sc, publisher := readyStateCache(t, 10) key := makeAddr(1) - view := sc.View(10) + view := sc.View(testStateGeneration(10)) view.Fill(kv.AccountsDomain, key, makeValue(1), 1) publication := publisher.Begin() @@ -831,9 +835,9 @@ func TestStateCache_AbortRestoresGeneration(t *testing.T) { func TestStateCache_UnpublishedVersionCannotReadOrFill(t *testing.T) { sc, _ := readyStateCache(t, 10) key := makeAddr(1) - unpublished := sc.View(11) + unpublished := sc.View(testStateGeneration(11)) unpublished.Fill(kv.AccountsDomain, key, makeValue(1), 1) - _, ok := sc.View(10).Get(kv.AccountsDomain, key) + _, ok := sc.View(testStateGeneration(10)).Get(kv.AccountsDomain, key) require.False(t, ok) } @@ -842,14 +846,14 @@ func TestStateCache_PublishDeleteAtomicWithOldFill(t *testing.T) { key := makeAddr(1) value := makeValue(1) for stateVersion := uint64(1); stateVersion < 2000; stateVersion++ { - oldView := sc.View(stateVersion) + oldView := sc.View(testStateGeneration(stateVersion)) var wg sync.WaitGroup wg.Go(func() { oldView.Fill(kv.AccountsDomain, key, value, 1) }) wg.Go(func() { publication := publisher.Begin() - publication.Publish(stateVersion+1, []Update{{ + publication.Publish(testStateGeneration(stateVersion+1), []Update{{ Domain: kv.AccountsDomain, Key: key, Step: 2, @@ -857,7 +861,7 @@ func TestStateCache_PublishDeleteAtomicWithOldFill(t *testing.T) { }) wg.Wait() - _, ok := sc.View(stateVersion+1).Get(kv.AccountsDomain, key) + _, ok := sc.View(testStateGeneration(stateVersion+1)).Get(kv.AccountsDomain, key) require.False(t, ok, "state version %d: stale fill survived publication", stateVersion) } } @@ -867,13 +871,13 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { addr := makeAddr(1) var h [32]byte h[0] = 0xaa - sc.View(1).SeedAddrCodeHash(addr, h) - _, ok := sc.View(1).GetAddrCodeHash(addr) + sc.View(testStateGeneration(1)).SeedAddrCodeHash(addr, h) + _, ok := sc.View(testStateGeneration(1)).GetAddrCodeHash(addr) require.True(t, ok) publication := publisher.Begin() - publication.Publish(2, []Update{{Domain: kv.CodeDomain, Key: addr}}, false) - _, ok = sc.View(2).GetAddrCodeHash(addr) + publication.Publish(testStateGeneration(2), []Update{{Domain: kv.CodeDomain, Key: addr}}, false) + _, ok = sc.View(testStateGeneration(2)).GetAddrCodeHash(addr) require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } @@ -882,17 +886,17 @@ func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { addr := makeAddr(1) code := makeCode(1) publication := publisher.Begin() - publication.Publish(2, []Update{{ + publication.Publish(testStateGeneration(2), []Update{{ Domain: kv.CodeDomain, Key: addr, Value: code, }}, false) - _, ok := sc.View(2).Get(kv.CodeDomain, addr) + _, ok := sc.View(testStateGeneration(2)).Get(kv.CodeDomain, addr) require.True(t, ok) publication = publisher.Begin() - publication.Publish(3, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) - _, ok = sc.View(3).Get(kv.CodeDomain, addr) + publication.Publish(testStateGeneration(3), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + _, ok = sc.View(testStateGeneration(3)).Get(kv.CodeDomain, addr) require.False(t, ok, "an account deletion must drop the addr→code binding") } @@ -945,7 +949,7 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { key := make([]byte, 20) key[0] = 0xaa - view := c.View(1) + view := c.View(testStateGeneration(1)) view.Fill(kv.AccountsDomain, key, []byte("value"), 10) _, ok := view.Get(kv.AccountsDomain, key) @@ -961,12 +965,12 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { require.False(t, ok, "content-addressed fills must be disabled too: the switch means no reader writes at all") publication := publisher.Begin() - publication.Publish(2, []Update{{ + publication.Publish(testStateGeneration(2), []Update{{ Domain: kv.AccountsDomain, Key: key, Value: []byte("applied"), }}, false) - got, ok := c.View(2).Get(kv.AccountsDomain, key) + got, ok := c.View(testStateGeneration(2)).Get(kv.AccountsDomain, key) require.True(t, ok, "publications must keep working") require.Equal(t, []byte("applied"), got) } @@ -974,11 +978,11 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { sc, publisher := readyStateCache(t, 1) key := makeAddr(1) - oldView := sc.View(1) - publisher.Clear(1) + oldView := sc.View(testStateGeneration(1)) + publisher.Clear(testStateGeneration(1)) oldView.Fill(kv.AccountsDomain, key, []byte("pre-delete"), 10) - freshView := sc.View(1) + freshView := sc.View(testStateGeneration(1)) _, ok := freshView.Get(kv.AccountsDomain, key) require.False(t, ok, "a retired view must not refill after Clear") @@ -994,13 +998,13 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { other, otherCode := makeAddr(2), makeCode(2) publication := publisher.Begin() - publication.Publish(2, []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) - stale := c.View(2) + publication.Publish(testStateGeneration(2), []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) + stale := c.View(testStateGeneration(2)) publication = publisher.Begin() - publication.Publish(3, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(3), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) stale.Fill(kv.CodeDomain, addr, code, 1) - fresh := c.View(3) + fresh := c.View(testStateGeneration(3)) _, ok := fresh.Get(kv.CodeDomain, addr) require.False(t, ok, "code of a deleted account must not be refillable from a pre-deletion view") diff --git a/execution/cache/files_publication_test.go b/execution/cache/files_publication_test.go index c6eac25c484..e8d6bc7510b 100644 --- a/execution/cache/files_publication_test.go +++ b/execution/cache/files_publication_test.go @@ -30,34 +30,41 @@ func TestStateCacheFilesPublication(t *testing.T) { value := makeValue(1) publication := publisher.Begin() - publication.Publish(2, []Update{{ + publication.Publish(testStateGeneration(2), []Update{{ Domain: kv.AccountsDomain, Key: key, Value: value, Step: 1, TxNum: 100, }}, false) - view := stateCache.View(2) + view := stateCache.View(testStateGeneration(2)) got, ok := view.Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, value, got) var filesEnd [kv.DomainLen]uint64 filesEnd[kv.AccountsDomain] = 101 - require.Nil(t, stateCache.BeginFilesPublication(filesEnd)) + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) _, ok = view.Get(kv.AccountsDomain, key) - require.True(t, ok, "files covered by committed updates must not clear the cache") + require.False(t, ok, "a files change must revoke views pinned to the old files") + change.Finish() + _, ok = stateCache.View(testStateGeneration(2)).Get(kv.AccountsDomain, key) + require.False(t, ok, "a transaction first bound after publication must present the new files identity") + covered := stateCache.View(StateGeneration(2, 101, 0, 0)) + _, ok = covered.Get(kv.AccountsDomain, key) + require.True(t, ok, "files covered by committed updates must retain cache entries") filesEnd[kv.AccountsDomain] = 150 - change := stateCache.BeginFilesPublication(filesEnd) + change = stateCache.BeginFilesPublication(filesEnd) require.NotNil(t, change) - _, ok = view.Get(kv.AccountsDomain, key) + _, ok = covered.Get(kv.AccountsDomain, key) require.False(t, ok, "foreign files must revoke the published generation") change.Finish() publication = publisher.Begin() - publication.Publish(3, nil, false) - current := stateCache.View(3) + publication.Publish(StateGeneration(3, 150, 0, 0), nil, false) + current := stateCache.View(StateGeneration(3, 150, 0, 0)) _, ok = current.Get(kv.AccountsDomain, key) require.False(t, ok, "the next commit must not reactivate entries from the old backing view") @@ -74,14 +81,14 @@ func TestFilesPublicationBlocksCachePublicationUntilVisible(t *testing.T) { change := stateCache.BeginFilesPublication(filesEnd) require.NotNil(t, change) - require.False(t, stateCache.version.publicationMu.TryLock(), + require.False(t, stateCache.generation.publicationMu.TryLock(), "cache publication must stay blocked while the backing-file view changes") change.Finish() - locked := stateCache.version.publicationMu.TryLock() + locked := stateCache.generation.publicationMu.TryLock() require.True(t, locked) if locked { - stateCache.version.publicationMu.Unlock() + stateCache.generation.publicationMu.Unlock() } publication := publisher.Begin() diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go new file mode 100644 index 00000000000..701417cedc8 --- /dev/null +++ b/execution/cache/generation_gate.go @@ -0,0 +1,334 @@ +// 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 cache + +import ( + "sync" + "sync/atomic" +) + +// FilesView records the immutable value files relevant to one cache. Equal +// exclusive ends mean that two pinned file views provide compatible latest +// state, even when the physical files were merged or repacked. +type FilesView struct { + accountsEnd uint64 + storageEnd uint64 + codeEnd uint64 + commitmentEnd uint64 +} + +func stateFilesView(accountsEnd, storageEnd, codeEnd uint64) FilesView { + return FilesView{accountsEnd: accountsEnd, storageEnd: storageEnd, codeEnd: codeEnd} +} + +// BranchFilesView identifies the files relevant to BranchCache. +func BranchFilesView(commitmentEnd uint64) FilesView { + return FilesView{commitmentEnd: commitmentEnd} +} + +// Generation identifies both parts of a cache snapshot: durable database state +// and the relevant immutable files pinned by its reader. +type Generation struct { + stateVersion uint64 + files FilesView +} + +// StateGeneration returns a StateCache identity for one pinned transaction. +func StateGeneration(stateVersion, accountsEnd, storageEnd, codeEnd uint64) Generation { + return Generation{stateVersion: stateVersion, files: stateFilesView(accountsEnd, storageEnd, codeEnd)} +} + +// BranchGeneration returns a BranchCache identity for one pinned transaction. +func BranchGeneration(stateVersion, commitmentEnd uint64) Generation { + return Generation{stateVersion: stateVersion, files: BranchFilesView(commitmentEnd)} +} + +// WithStateVersion returns the same files identity at another durable version. +func (g Generation) WithStateVersion(stateVersion uint64) Generation { + g.stateVersion = stateVersion + return g +} + +// publishedGeneration is immutable after publication. Pointer identity +// prevents a revoked view from becoming valid if the same Generation is +// published again. +type publishedGeneration struct { + identity Generation + active bool +} + +// GenerationGate binds lock-free cache reads and serialized fills to one +// durable database state over one compatible files view. +type GenerationGate struct { + current atomic.Pointer[publishedGeneration] + admissionMu sync.RWMutex + // publicationMu orders durable cache publication with independent changes + // to the backing-file view. Begin holds it until Publish or Abort. + publicationMu sync.Mutex +} + +// GenerationView is the immutable validity token held by one cache view. +type GenerationView struct { + gate *GenerationGate + generation *publishedGeneration +} + +// View returns an inert token unless identity is currently published. +func (g *GenerationGate) View(identity Generation) GenerationView { + if g == nil { + return GenerationView{} + } + generation := g.current.Load() + if generation == nil || !generation.active || generation.identity != identity { + return GenerationView{} + } + return GenerationView{gate: g, generation: generation} +} + +// Current reports whether the generation is still published. +func (v GenerationView) Current() bool { + return v.gate != nil && v.generation != nil && v.gate.current.Load() == v.generation +} + +// Admit runs fill only if the view remains current while serialized against +// publication. The early check avoids taking the read lock for stale views. +func (v GenerationView) Admit(fill func()) bool { + if !v.Current() { + return false + } + v.gate.admissionMu.RLock() + defer v.gate.admissionMu.RUnlock() + if v.gate.current.Load() != v.generation { + return false + } + fill() + return true +} + +// CurrentStateVersion reports the durable database version of the active +// generation. It returns false before initialization and during publication. +func (g *GenerationGate) CurrentStateVersion() (uint64, bool) { + if g == nil { + return 0, false + } + generation := g.current.Load() + if generation == nil || !generation.active { + return 0, false + } + return generation.identity.stateVersion, true +} + +// GenerationPublisher is the mutation capability for one generation gate. +type GenerationPublisher struct { + gate *GenerationGate +} + +// Publisher returns a handle that can initialize and publish the gate. +func (g *GenerationGate) Publisher() GenerationPublisher { + if g == nil { + return GenerationPublisher{} + } + return GenerationPublisher{gate: g} +} + +func (p GenerationPublisher) Enabled() bool { return p.gate != nil } + +// Initialize binds the gate to identity. A mismatch runs clear while fills are +// blocked because existing entries have an unknown origin relative to the +// requested database and files snapshot. +func (p GenerationPublisher) Initialize(identity Generation, clear func()) { + if p.gate == nil { + return + } + gate := p.gate + gate.publicationMu.Lock() + defer gate.publicationMu.Unlock() + gate.admissionMu.Lock() + defer gate.admissionMu.Unlock() + + current := gate.current.Load() + if current != nil && current.active && current.identity == identity { + return + } + + gate.current.Store(nil) + if clear != nil { + clear() + } + gate.current.Store(&publishedGeneration{identity: identity, active: true}) +} + +// GenerationPublication represents one pending durable transition. +type GenerationPublication struct { + gate *GenerationGate + previous *publishedGeneration + transition *publishedGeneration +} + +// Begin revokes all existing views without changing cache entries. It also +// blocks backing-file changes until Publish or Abort completes the durable +// transition. +func (p GenerationPublisher) Begin() *GenerationPublication { + if p.gate == nil { + return nil + } + gate := p.gate + gate.publicationMu.Lock() + gate.admissionMu.Lock() + defer gate.admissionMu.Unlock() + + previous := gate.current.Load() + if previous != nil && !previous.active { + gate.publicationMu.Unlock() + panic("cache generation publication already in progress") + } + transition := &publishedGeneration{} + gate.current.Store(transition) + return &GenerationPublication{gate: gate, previous: previous, transition: transition} +} + +// Abort restores the previous generation when no cache entries were changed. +func (p *GenerationPublication) Abort() { + if p == nil || p.gate == nil { + return + } + gate := p.gate + gate.admissionMu.Lock() + defer gate.publicationMu.Unlock() + defer gate.admissionMu.Unlock() + if gate.current.Load() != p.transition { + panic("cache generation publication changed before abort") + } + gate.current.Store(p.previous) + p.gate = nil +} + +// Publish applies the committed cache transition before exposing identity. +func (p *GenerationPublication) Publish(identity Generation, apply func()) { + if p == nil || p.gate == nil { + return + } + gate := p.gate + gate.admissionMu.Lock() + defer gate.publicationMu.Unlock() + defer gate.admissionMu.Unlock() + if gate.current.Load() != p.transition { + panic("cache generation publication changed before publish") + } + if apply != nil { + apply() + } + gate.current.Store(&publishedGeneration{identity: identity, active: true}) + p.gate = nil +} + +// Reset revokes all views, clears the cache, and leaves it unpublished. The +// next durable publication can start from this empty state. +func (g *GenerationGate) Reset(clear func()) { + if g == nil { + return + } + g.publicationMu.Lock() + defer g.publicationMu.Unlock() + g.admissionMu.Lock() + defer g.admissionMu.Unlock() + g.current.Store(nil) + if clear != nil { + clear() + } +} + +// BackingChange keeps cache publication blocked while a new files view becomes +// visible. +type BackingChange struct { + gate *GenerationGate + transition *publishedGeneration + next *publishedGeneration +} + +// BeginBackingChange runs reconcile while publications and fills are blocked. +// It always revokes an active generation when its files identity changes, but +// clears entries only when reconcile reports foreign state. The returned +// handle keeps publication blocked until Finish makes both the new files and +// their matching cache generation observable. +func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func() bool, clear func()) *BackingChange { + if p.gate == nil { + return nil + } + gate := p.gate + gate.publicationMu.Lock() + gate.admissionMu.Lock() + keepPublicationLocked := false + defer func() { + gate.admissionMu.Unlock() + if !keepPublicationLocked { + gate.publicationMu.Unlock() + } + }() + + incompatible := reconcile != nil && reconcile() + current := gate.current.Load() + if current != nil && !current.active { + panic("cache generation publication already in progress") + } + if current != nil && current.identity.files == files && !incompatible { + return nil + } + var transition, next *publishedGeneration + if current != nil { + transition = &publishedGeneration{} + next = &publishedGeneration{ + identity: Generation{stateVersion: current.identity.stateVersion, files: files}, + active: true, + } + gate.current.Store(transition) + } + if incompatible && clear != nil { + clear() + } + keepPublicationLocked = true + return &BackingChange{gate: gate, transition: transition, next: next} +} + +// Finish publishes the matching cache identity after the files view is visible. +func (c *BackingChange) Finish() { + if c == nil || c.gate == nil { + return + } + gate := c.gate + gate.admissionMu.Lock() + defer gate.publicationMu.Unlock() + defer gate.admissionMu.Unlock() + if c.transition != nil && gate.current.Load() != c.transition { + panic("cache generation changed during files publication") + } + gate.current.Store(c.next) + c.gate = nil +} + +// Close permanently revokes current views. The owner may then close its cache +// storage without admitting new fills. +func (g *GenerationGate) Close() { + if g == nil { + return + } + g.publicationMu.Lock() + defer g.publicationMu.Unlock() + g.admissionMu.Lock() + g.current.Store(nil) + g.admissionMu.Unlock() +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index f880a5f5ff9..169aa794fb3 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -36,16 +36,15 @@ const ( avgStorageEntryBytes = 80 ) -// StateCache holds account, storage, and code values for exactly one durable -// PlainStateVersion. Each reader carries a version token for its database -// snapshot. Publication revokes that view before changing entries, so old -// readers miss instead of observing a mixture of the old and new states. +// StateCache holds account, storage, and code values for one durable database +// state over one compatible files view. Publication revokes a reader's +// generation before changing entries, so it cannot observe mixed state. type StateCache struct { - version PlainStateVersionGate + generation GenerationGate // committedTxNumEnd is only a file-provenance watermark. Cache validity is - // still decided exclusively by version; these ends distinguish files built - // from published updates from files downloaded outside that stream. + // decided by Generation; these ends distinguish files built from published + // updates from files downloaded outside that stream. committedTxNumEnd [kv.DomainLen]uint64 caches [kv.DomainLen]Cache disableFills bool @@ -97,17 +96,23 @@ func NewDefaultStateCache() *StateCache { // all cache layers. It returns false while publication is in progress because // the old version has been revoked and the new version is not visible yet. func (c *StateCache) CurrentStateVersion() (uint64, bool) { - return c.version.CurrentStateVersion() + return c.generation.CurrentStateVersion() } -// BeginFilesPublication revokes and clears the cache when files expose state -// beyond this process's committed updates. Finish must be called after the new -// files view becomes visible. -func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *PlainStateVersionBackingChange { +// BeginFilesPublication revokes the old files generation. It retains entries +// backed by this process's committed updates and clears them when the new files +// contain foreign state. Finish publishes the new identity after the files +// become visible. +func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *BackingChange { if c == nil { return nil } - return c.version.Publisher().BeginBackingChange(func() bool { + files := stateFilesView( + filesEnd[kv.AccountsDomain], + filesEnd[kv.StorageDomain], + filesEnd[kv.CodeDomain], + ) + return c.generation.Publisher().BeginBackingChange(files, func() bool { extended := false for domain, cache := range c.caches { if cache == nil || filesEnd[domain] <= c.committedTxNumEnd[domain] { @@ -153,7 +158,7 @@ func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { } func (c *StateCache) fill( - version PlainStateVersionView, + generation GenerationView, domain kv.Domain, key, value []byte, step kv.Step, @@ -164,13 +169,13 @@ func (c *StateCache) fill( } value = bytes.Clone(value) - version.Admit(func() { + generation.Admit(func() { cache.PutIfAbsent(key, value, step) }) } func (c *StateCache) fillCode( - version PlainStateVersionView, + generation GenerationView, key, value []byte, step kv.Step, ) { @@ -181,27 +186,27 @@ func (c *StateCache) fillCode( value = bytes.Clone(value) codeHash := crypto.Keccak256(value) - version.Admit(func() { + generation.Admit(func() { codeCache.PutWithCodeHashIfAbsent(key, value, codeHash, step) }) } -func (c *StateCache) seedAddrCodeHash(version PlainStateVersionView, addr []byte, hash [32]byte) { +func (c *StateCache) seedAddrCodeHash(generation GenerationView, addr []byte, hash [32]byte) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - version.Admit(func() { + generation.Admit(func() { codeCache.PutAddrCodeHash(addr, hash) }) } -func (c *StateCache) fillCodeSize(version PlainStateVersionView, codeHash []byte, size int) { +func (c *StateCache) fillCodeSize(generation GenerationView, codeHash []byte, size int) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { return } - version.Admit(func() { + generation.Admit(func() { codeCache.PutCodeSizeByCodeHash(codeHash, size) }) } @@ -262,7 +267,7 @@ func (c *StateCache) clearLocked() { } func (c *StateCache) Close() { - c.version.Close() + c.generation.Close() for _, cache := range c.caches { if cache != nil { cache.Close() @@ -309,11 +314,11 @@ type Update struct { } // Publisher is the mutation capability for canonical state. Normal readers -// receive only ReadView, while code that makes a database state durable uses a -// Publisher to move every cache layer to the same PlainStateVersion. +// receive only ReadView, while code that makes database state durable uses a +// Publisher to move every cache layer to the same Generation. type Publisher struct { - c *StateCache - version PlainStateVersionPublisher + c *StateCache + generation GenerationPublisher } // Publisher returns a handle that can change the cache's canonical generation. @@ -322,20 +327,19 @@ func (c *StateCache) Publisher() Publisher { if c == nil { return Publisher{} } - return Publisher{c: c, version: c.version.Publisher()} + return Publisher{c: c, generation: c.generation.Publisher()} } -func (p Publisher) Enabled() bool { return p.c != nil && p.version.Enabled() } +func (p Publisher) Enabled() bool { return p.c != nil && p.generation.Enabled() } -// Initialize binds the cache to the durable version seen by its canonical -// owner. Existing entries are preserved when the version already matches. A -// mismatch clears them because this single-version cache cannot prove that any -// entry belongs to the owner's database snapshot. -func (p Publisher) Initialize(stateVersion uint64) { +// Initialize binds the cache to the database and files generation seen by its +// canonical owner. A mismatch clears entries because their origin cannot be +// proven compatible with that snapshot. +func (p Publisher) Initialize(generation Generation) { if p.c == nil { return } - p.version.Initialize(stateVersion, p.c.clearLocked) + p.generation.Initialize(generation, p.c.clearLocked) } // Publication represents one pending transition of the durable database @@ -343,8 +347,8 @@ func (p Publisher) Initialize(stateVersion uint64) { // Abort can restore the previous generation if the transaction rolls back. // Publish consumes the transition after the database commit succeeds. type Publication struct { - c *StateCache - version *PlainStateVersionPublication + c *StateCache + generation *GenerationPublication } // Begin revokes every existing ReadView and prevents creation of a new live @@ -354,7 +358,7 @@ func (p Publisher) Begin() *Publication { if p.c == nil { return nil } - return &Publication{c: p.c, version: p.version.Begin()} + return &Publication{c: p.c, generation: p.generation.Begin()} } // Abort restores the previous generation after a failed or abandoned database @@ -364,12 +368,12 @@ func (p *Publication) Abort() { if p == nil || p.c == nil { return } - p.version.Abort() + p.generation.Abort() p.c = nil } // Publish applies updates from a successful database transaction and exposes -// stateVersion as one complete cache generation. The caller must invoke it +// generation as one complete cache snapshot. The caller must invoke it // only after the database commit, so a visible cache generation is never ahead // of durable state. // @@ -377,11 +381,11 @@ func (p *Publication) Abort() { // have the same value in the new state. Canonical unwind sets clear because its // callbacks do not enumerate every value that may belong to the discarded // fork. -func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) { +func (p *Publication) Publish(generation Generation, updates []Update, clear bool) { if p == nil || p.c == nil { return } - p.version.Publish(stateVersion, func() { + p.generation.Publish(generation, func() { if clear { p.c.clearLocked() } @@ -393,8 +397,8 @@ func (p *Publication) Publish(stateVersion uint64, updates []Update, clear bool) } // Clear revokes current views, removes every cached value, and publishes an -// empty generation for stateVersion. -func (p Publisher) Clear(stateVersion uint64) { +// empty generation. +func (p Publisher) Clear(generation Generation) { publication := p.Begin() - publication.Publish(stateVersion, nil, true) + publication.Publish(generation, nil, true) } diff --git a/execution/cache/version_gate.go b/execution/cache/version_gate.go deleted file mode 100644 index 3f7e523efee..00000000000 --- a/execution/cache/version_gate.go +++ /dev/null @@ -1,270 +0,0 @@ -// 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 cache - -import ( - "sync" - "sync/atomic" -) - -// versionGeneration is immutable after publication. Pointer identity prevents -// a view revoked by one publication from becoming valid when the same -// PlainStateVersion is published again. -type versionGeneration struct { - stateVersion uint64 - active bool -} - -// PlainStateVersionGate binds lock-free cache reads and serialized fills to one -// durable PlainStateVersion. It also revokes views when the backing data -// changes without advancing that version. -type PlainStateVersionGate struct { - current atomic.Pointer[versionGeneration] - admissionMu sync.RWMutex - // publicationMu orders durable cache publication with independent changes - // to the backing-file view. Begin holds it until Publish or Abort. - publicationMu sync.Mutex -} - -// PlainStateVersionView is the immutable validity token held by one cache view. -type PlainStateVersionView struct { - gate *PlainStateVersionGate - generation *versionGeneration -} - -// View returns an inert token unless stateVersion is currently published. -func (g *PlainStateVersionGate) View(stateVersion uint64) PlainStateVersionView { - if g == nil { - return PlainStateVersionView{} - } - generation := g.current.Load() - if generation == nil || !generation.active || generation.stateVersion != stateVersion { - return PlainStateVersionView{} - } - return PlainStateVersionView{gate: g, generation: generation} -} - -// Current reports whether the generation is still published. -func (v PlainStateVersionView) Current() bool { - return v.gate != nil && v.generation != nil && v.gate.current.Load() == v.generation -} - -// Admit runs fill only if the view remains current while serialized against -// publication. The early check avoids taking the read lock for stale views. -func (v PlainStateVersionView) Admit(fill func()) bool { - if !v.Current() { - return false - } - v.gate.admissionMu.RLock() - defer v.gate.admissionMu.RUnlock() - if v.gate.current.Load() != v.generation { - return false - } - fill() - return true -} - -// CurrentStateVersion reports the active durable version. It returns false -// before initialization and while a publication is in progress. -func (g *PlainStateVersionGate) CurrentStateVersion() (uint64, bool) { - if g == nil { - return 0, false - } - generation := g.current.Load() - if generation == nil || !generation.active { - return 0, false - } - return generation.stateVersion, true -} - -// PlainStateVersionPublisher is the mutation capability for one version gate. -type PlainStateVersionPublisher struct { - gate *PlainStateVersionGate -} - -// Publisher returns a handle that can initialize and publish the gate. -func (g *PlainStateVersionGate) Publisher() PlainStateVersionPublisher { - if g == nil { - return PlainStateVersionPublisher{} - } - return PlainStateVersionPublisher{gate: g} -} - -func (p PlainStateVersionPublisher) Enabled() bool { return p.gate != nil } - -// Initialize binds the gate to stateVersion. A version mismatch runs clear -// while all fills are blocked because existing entries have an unknown origin -// relative to the requested database snapshot. -func (p PlainStateVersionPublisher) Initialize(stateVersion uint64, clear func()) { - if p.gate == nil { - return - } - gate := p.gate - gate.publicationMu.Lock() - defer gate.publicationMu.Unlock() - gate.admissionMu.Lock() - defer gate.admissionMu.Unlock() - - current := gate.current.Load() - if current != nil && current.active && current.stateVersion == stateVersion { - return - } - - gate.current.Store(nil) - if clear != nil { - clear() - } - gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) -} - -// PlainStateVersionPublication represents one pending durable transition. -type PlainStateVersionPublication struct { - gate *PlainStateVersionGate - previous *versionGeneration - transition *versionGeneration -} - -// Begin revokes all existing views without changing cache entries. It also -// blocks backing-file changes until Publish or Abort completes the durable -// transition. -func (p PlainStateVersionPublisher) Begin() *PlainStateVersionPublication { - if p.gate == nil { - return nil - } - gate := p.gate - gate.publicationMu.Lock() - gate.admissionMu.Lock() - defer gate.admissionMu.Unlock() - - previous := gate.current.Load() - if previous != nil && !previous.active { - gate.publicationMu.Unlock() - panic("cache version publication already in progress") - } - transition := &versionGeneration{} - gate.current.Store(transition) - return &PlainStateVersionPublication{gate: gate, previous: previous, transition: transition} -} - -// Abort restores the previous generation when no cache entries were changed. -func (p *PlainStateVersionPublication) Abort() { - if p == nil || p.gate == nil { - return - } - gate := p.gate - gate.admissionMu.Lock() - defer gate.publicationMu.Unlock() - defer gate.admissionMu.Unlock() - if gate.current.Load() != p.transition { - panic("cache version publication changed before abort") - } - gate.current.Store(p.previous) - p.gate = nil -} - -// Publish applies the committed cache transition before exposing stateVersion. -func (p *PlainStateVersionPublication) Publish(stateVersion uint64, apply func()) { - if p == nil || p.gate == nil { - return - } - gate := p.gate - gate.admissionMu.Lock() - defer gate.publicationMu.Unlock() - defer gate.admissionMu.Unlock() - if gate.current.Load() != p.transition { - panic("cache version publication changed before publish") - } - if apply != nil { - apply() - } - gate.current.Store(&versionGeneration{stateVersion: stateVersion, active: true}) - p.gate = nil -} - -// Reset revokes all views, clears the cache, and leaves it unpublished. The -// next durable publication can start from this empty state. -func (g *PlainStateVersionGate) Reset(clear func()) { - if g == nil { - return - } - g.publicationMu.Lock() - defer g.publicationMu.Unlock() - g.admissionMu.Lock() - defer g.admissionMu.Unlock() - g.current.Store(nil) - if clear != nil { - clear() - } -} - -// PlainStateVersionBackingChange keeps cache publication blocked while a new -// backing-file view becomes visible. -type PlainStateVersionBackingChange struct { - gate *PlainStateVersionGate -} - -// BeginBackingChange runs reconcile while publications and fills are blocked. -// If reconcile reports that cached entries no longer match the backing data, -// the current generation is revoked and cleared. The returned handle keeps -// publication blocked until Finish makes the new backing view observable. -func (p PlainStateVersionPublisher) BeginBackingChange(reconcile func() bool, clear func()) *PlainStateVersionBackingChange { - if p.gate == nil { - return nil - } - gate := p.gate - gate.publicationMu.Lock() - gate.admissionMu.Lock() - keepPublicationLocked := false - defer func() { - gate.admissionMu.Unlock() - if !keepPublicationLocked { - gate.publicationMu.Unlock() - } - }() - - if reconcile == nil || !reconcile() { - return nil - } - gate.current.Store(nil) - if clear != nil { - clear() - } - keepPublicationLocked = true - return &PlainStateVersionBackingChange{gate: gate} -} - -// Finish allows cache publication after the backing-file view is visible. -func (c *PlainStateVersionBackingChange) Finish() { - if c == nil || c.gate == nil { - return - } - c.gate.publicationMu.Unlock() - c.gate = nil -} - -// Close permanently revokes current views. The owner may then close its cache -// storage without admitting new fills. -func (g *PlainStateVersionGate) Close() { - if g == nil { - return - } - g.publicationMu.Lock() - defer g.publicationMu.Unlock() - g.admissionMu.Lock() - g.current.Store(nil) - g.admissionMu.Unlock() -} diff --git a/execution/cache/view.go b/execution/cache/view.go index 8d7593ee0f4..26b845e6acc 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -18,37 +18,31 @@ package cache import "github.com/erigontech/erigon/db/kv" -// ReadView is a cache handle bound to one durable PlainStateVersion. It does -// not pin the cache or delay publication. Instead, each read checks the +// ReadView is a cache handle bound to one durable database state and files +// view. It does not pin the cache or delay publication. Each read checks its // immutable generation token before and after accessing an underlying cache, -// so publication concurrent with the access turns the result into a miss. +// so concurrent publication turns the result into a miss. // // Fills check the same token while holding the cache admission lock. A value // read from an old database snapshot therefore cannot enter a newer cache // generation. The zero value is inert and safely falls back to the database. type ReadView struct { - c *StateCache - version PlainStateVersionView + c *StateCache + generation GenerationView } // View returns a live handle only when the cache currently represents -// stateVersion and no publication is in progress. Callers must pass the -// version of their own database snapshot, not a separately sampled latest -// version. A mismatch returns an inert view rather than serving newer or older -// cached state. -func (c *StateCache) View(stateVersion uint64) ReadView { +// generation and no publication is in progress. Callers must derive it from +// their own pinned transaction. A mismatch returns an inert view. +func (c *StateCache) View(generation Generation) ReadView { if c == nil { return ReadView{} } - version := c.version.View(stateVersion) - if !version.Current() { - return ReadView{} - } - return ReadView{c: c, version: version} + return ReadView{c: c, generation: c.generation.View(generation)} } func (v ReadView) current() bool { - return v.c != nil && v.version.Current() + return v.c != nil && v.generation.Current() } func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { @@ -109,22 +103,22 @@ func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { return } if domain == kv.CodeDomain { - v.c.fillCode(v.version, key, value, step) + v.c.fillCode(v.generation, key, value, step) return } - v.c.fill(v.version, domain, key, value, step) + v.c.fill(v.generation, domain, key, value, step) } func (v ReadView) SeedAddrCodeHash(addr []byte, hash [32]byte) { if !v.canFill() { return } - v.c.seedAddrCodeHash(v.version, addr, hash) + v.c.seedAddrCodeHash(v.generation, addr, hash) } func (v ReadView) FillCodeSize(codeHash []byte, size int) { if !v.canFill() { return } - v.c.fillCodeSize(v.version, codeHash, size) + v.c.fillCodeSize(v.generation, codeHash, size) } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index 93561f0de3a..41585e5ffba 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -56,7 +56,7 @@ func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(t *testing.T) { branchCache := NewBranchCache(64) t.Cleanup(branchCache.Close) publisher := branchCache.Publisher() - publisher.Initialize(1) + publisher.Initialize(testBranchGeneration(1)) cfg := DefaultAdaptivePinControllerConfig() cfg.PromoteThresholdMisses = 1 @@ -82,9 +82,9 @@ func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(t *testing.T) { plan = controller.PlanBlock(2, reader, nil, nil) publication := publisher.Begin() - publication.Publish(2, nil, false, plan) + publication.Publish(testBranchGeneration(2), nil, false, plan) plan.Commit() - _, _, ok = branchCache.View(2).Get(prefix) + _, _, ok = branchCache.View(testBranchGeneration(2)).Get(prefix) require.True(t, ok, "publication must apply the staged pin") } diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index d1d3e5c08de..f53381e5c71 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -48,13 +48,13 @@ func isCommitmentStateKey(prefix []byte) bool { // BranchCache stores commitment-trie branches in an aggregator-scope resident // trunk and bounded LRU tail. Concurrent storage operations are safe; shared // readers use View, and durable writers use Publisher, to keep all entries -// bound to one PlainStateVersion. +// bound to one database and files generation. type BranchCache struct { - version cache.PlainStateVersionGate + generation cache.GenerationGate // committedTxNumEnd is only a file-provenance watermark. Cache validity is - // still decided by PlainStateVersion; this end distinguishes locally built - // commitment files from files downloaded outside the publication stream. + // decided by Generation; this end distinguishes locally built commitment + // files from files downloaded outside the publication stream. committedTxNumEnd uint64 // Root tier — single slot for the root branch (always hottest, always @@ -343,7 +343,7 @@ func NewBranchCache(tailCapacity int) *BranchCache { // Close drops this cache from the active-instance count so later BranchCaches // size their trunk depth against real concurrency. Idempotent. func (c *BranchCache) Close() { - c.version.Close() + c.generation.Close() if c.closed.CompareAndSwap(false, true) { if t := c.tail.Load(); t != nil { t.Close() @@ -353,23 +353,23 @@ func (c *BranchCache) Close() { } // Reset clears cached branches and revokes all views until the next durable -// publication. It is required when the backing commitment view changes -// without advancing PlainStateVersion. +// publication. func (c *BranchCache) Reset() { - c.version.Reset(func() { + c.generation.Reset(func() { c.committedTxNumEnd = 0 c.Clear() }) } -// BeginFilesPublication revokes and clears the cache when commitment files -// expose state beyond this process's committed branch updates. Finish must be -// called after the new files view becomes visible. -func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.PlainStateVersionBackingChange { +// BeginFilesPublication revokes the old files generation. It retains entries +// backed by this process's committed updates and clears them when the new files +// contain foreign state. Finish publishes the new identity after the files +// become visible. +func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChange { if c == nil { return nil } - return c.version.Publisher().BeginBackingChange(func() bool { + return c.generation.Publisher().BeginBackingChange(cache.BranchFilesView(filesEnd), func() bool { if filesEnd <= c.committedTxNumEnd { return false } @@ -683,7 +683,7 @@ func (c *BranchCache) PinnedCount() int { // Get retrieves branch data from the cache. Returns the canonical encoded // bytes (with the leading 2-byte touch-map prefix) plus the on-disk file // step the bytes came from (0 if not tracked). Shared database readers use -// BranchReadView.Get so the result is checked against PlainStateVersion. +// BranchReadView.Get so the result is checked against their full generation. func (c *BranchCache) Get(prefix []byte) ([]byte, uint64, bool) { if isCommitmentStateKey(prefix) { return nil, 0, false diff --git a/execution/commitment/branch_cache_absorb_test.go b/execution/commitment/branch_cache_absorb_test.go index f6bbc87e800..292ac3e8bf1 100644 --- a/execution/commitment/branch_cache_absorb_test.go +++ b/execution/commitment/branch_cache_absorb_test.go @@ -20,41 +20,50 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/cache" ) func TestBranchCacheFilesPublication(t *testing.T) { branchCache := NewBranchCache(64) t.Cleanup(branchCache.Close) publisher := branchCache.Publisher() - publisher.Initialize(1) + publisher.Initialize(testBranchGeneration(1)) key := []byte{0x01} value := []byte{0xbb} publication := publisher.Begin() - publication.Publish(2, []BranchUpdate{{ + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ Key: key, Value: value, Step: 1, TxNum: 100, }}, false, nil) - view := branchCache.View(2) + view := branchCache.View(testBranchGeneration(2)) got, _, ok := view.Get(key) require.True(t, ok) require.Equal(t, value, got) - require.Nil(t, branchCache.BeginFilesPublication(101)) + change := branchCache.BeginFilesPublication(101) + require.NotNil(t, change) _, _, ok = view.Get(key) - require.True(t, ok, "files covered by committed updates must not clear the cache") + require.False(t, ok, "a files change must revoke views pinned to the old files") + change.Finish() + _, _, ok = branchCache.View(testBranchGeneration(2)).Get(key) + require.False(t, ok, "a transaction first bound after publication must present the new files identity") + covered := branchCache.View(cache.BranchGeneration(2, 101)) + _, _, ok = covered.Get(key) + require.True(t, ok, "files covered by committed updates must retain cache entries") - change := branchCache.BeginFilesPublication(150) + change = branchCache.BeginFilesPublication(150) require.NotNil(t, change) - _, _, ok = view.Get(key) + _, _, ok = covered.Get(key) require.False(t, ok, "foreign files must revoke the published generation") change.Finish() publication = publisher.Begin() - publication.Publish(3, nil, false, nil) - current := branchCache.View(3) + publication.Publish(cache.BranchGeneration(3, 150), nil, false, nil) + current := branchCache.View(cache.BranchGeneration(3, 150)) _, _, ok = current.Get(key) require.False(t, ok, "the next commit must not reactivate entries from the old backing view") diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index 38077c62307..daadb57bb27 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -23,8 +23,14 @@ import ( "testing" "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/execution/cache" ) +func testBranchGeneration(stateVersion uint64) cache.Generation { + return cache.BranchGeneration(stateVersion, 0) +} + // TestBranchCache_AccountTrunkRouting verifies account-trie branches at nibble // depths 1-4 land in the resident fixed-array trunk (counted as trunk hits), // and survive LRU tail-eviction pressure. @@ -287,14 +293,14 @@ func TestBranchCache_ConcurrentTailGrow(t *testing.T) { wg.Wait() } -func TestBranchCache_ViewRequiresExactStateVersion(t *testing.T) { +func TestBranchCache_ViewRequiresExactGeneration(t *testing.T) { c := NewBranchCache(100) t.Cleanup(c.Close) publisher := c.Publisher() - publisher.Initialize(7) + publisher.Initialize(testBranchGeneration(7)) key := []byte{0xa0, 0xb0} - view := c.View(7) + view := c.View(testBranchGeneration(7)) view.Fill(key, []byte("version-7"), 3) value, step, ok := view.Get(key) @@ -302,20 +308,22 @@ func TestBranchCache_ViewRequiresExactStateVersion(t *testing.T) { require.Equal(t, []byte("version-7"), value) require.Equal(t, uint64(3), step) - _, _, ok = c.View(6).Get(key) + _, _, ok = c.View(testBranchGeneration(6)).Get(key) require.False(t, ok, "an older database snapshot must not read the current branch generation") - _, _, ok = c.View(8).Get(key) + _, _, ok = c.View(testBranchGeneration(8)).Get(key) require.False(t, ok, "a newer database snapshot must wait for its branch generation to be published") + _, _, ok = c.View(cache.BranchGeneration(7, 1)).Get(key) + require.False(t, ok, "a different files view must not read the current branch generation") } func TestBranchCache_PublicationRejectsLateFill(t *testing.T) { c := NewBranchCache(100) t.Cleanup(c.Close) publisher := c.Publisher() - publisher.Initialize(1) + publisher.Initialize(testBranchGeneration(1)) key := []byte{0xa0, 0xb0} - oldView := c.View(1) + oldView := c.View(testBranchGeneration(1)) oldView.Fill(key, []byte("old"), 1) publication := publisher.Begin() @@ -323,7 +331,7 @@ func TestBranchCache_PublicationRejectsLateFill(t *testing.T) { require.False(t, ok, "Begin must revoke existing branch views") oldView.Fill(key, []byte("late-old-fill"), 1) - publication.Publish(2, []BranchUpdate{{ + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ Key: key, Value: []byte("new"), Step: 2, @@ -331,7 +339,7 @@ func TestBranchCache_PublicationRejectsLateFill(t *testing.T) { _, _, ok = oldView.Get(key) require.False(t, ok, "a published generation must not revalidate an old view") - value, step, ok := c.View(2).Get(key) + value, step, ok := c.View(testBranchGeneration(2)).Get(key) require.True(t, ok) require.Equal(t, []byte("new"), value) require.Equal(t, uint64(2), step) @@ -341,10 +349,10 @@ func TestBranchCache_PublicationAbortRestoresPreviousView(t *testing.T) { c := NewBranchCache(100) t.Cleanup(c.Close) publisher := c.Publisher() - publisher.Initialize(1) + publisher.Initialize(testBranchGeneration(1)) key := []byte{0xa0, 0xb0} - view := c.View(1) + view := c.View(testBranchGeneration(1)) view.Fill(key, []byte("unchanged"), 1) publication := publisher.Begin() @@ -361,25 +369,25 @@ func TestBranchCache_ResetRevokesViewsUntilNextPublication(t *testing.T) { c := NewBranchCache(100) t.Cleanup(c.Close) publisher := c.Publisher() - publisher.Initialize(1) + publisher.Initialize(testBranchGeneration(1)) key := []byte{0xa0, 0xb0} - oldView := c.View(1) + oldView := c.View(testBranchGeneration(1)) oldView.Fill(key, []byte("old-layout"), 1) c.Reset() _, _, ok := oldView.Get(key) require.False(t, ok) - _, _, ok = c.View(1).Get(key) + _, _, ok = c.View(testBranchGeneration(1)).Get(key) require.False(t, ok, "Reset must leave the cache unpublished") publication := publisher.Begin() - publication.Publish(2, []BranchUpdate{{ + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ Key: key, Value: []byte("new-layout"), Step: 2, }}, false, nil) - value, _, ok := c.View(2).Get(key) + value, _, ok := c.View(testBranchGeneration(2)).Get(key) require.True(t, ok) require.Equal(t, []byte("new-layout"), value) } diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 0ae87fc8b55..4f096064be7 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -18,29 +18,24 @@ package commitment import "github.com/erigontech/erigon/execution/cache" -// BranchReadView binds BranchCache access to one durable PlainStateVersion. -// Publication concurrent with a read turns the result into a miss, while fills -// are serialized so a value from an old database snapshot cannot enter a new -// generation. +// BranchReadView binds BranchCache access to one durable database state and +// files view. Concurrent publication turns a read into a miss, while fill +// admission prevents an old snapshot from entering a new generation. type BranchReadView struct { - c *BranchCache - version cache.PlainStateVersionView + c *BranchCache + generation cache.GenerationView } -// View returns an inert handle unless stateVersion is currently published. -func (c *BranchCache) View(stateVersion uint64) BranchReadView { +// View returns an inert handle unless generation is currently published. +func (c *BranchCache) View(generation cache.Generation) BranchReadView { if c == nil { return BranchReadView{} } - version := c.version.View(stateVersion) - if !version.Current() { - return BranchReadView{} - } - return BranchReadView{c: c, version: version} + return BranchReadView{c: c, generation: c.generation.View(generation)} } func (v BranchReadView) current() bool { - return v.c != nil && v.version.Current() + return v.c != nil && v.generation.Current() } // Get returns a branch only while the view remains current. @@ -60,7 +55,7 @@ func (v BranchReadView) Fill(prefix, value []byte, step uint64) { if !v.current() || len(value) == 0 { return } - v.version.Admit(func() { + v.generation.Admit(func() { v.c.Put(prefix, value, step) }) } @@ -76,8 +71,8 @@ type BranchUpdate struct { // BranchPublisher is the canonical mutation handle for BranchCache. type BranchPublisher struct { - c *BranchCache - version cache.PlainStateVersionPublisher + c *BranchCache + generation cache.GenerationPublisher } // Publisher returns a handle that can publish durable branch generations. @@ -85,25 +80,25 @@ func (c *BranchCache) Publisher() BranchPublisher { if c == nil { return BranchPublisher{} } - return BranchPublisher{c: c, version: c.version.Publisher()} + return BranchPublisher{c: c, generation: c.generation.Publisher()} } func (p BranchPublisher) Enabled() bool { - return p.c != nil && p.version.Enabled() + return p.c != nil && p.generation.Enabled() } -// Initialize binds an empty or previously published cache to stateVersion. -func (p BranchPublisher) Initialize(stateVersion uint64) { +// Initialize binds an empty or previously published cache to generation. +func (p BranchPublisher) Initialize(generation cache.Generation) { if p.c == nil { return } - p.version.Initialize(stateVersion, p.c.Clear) + p.generation.Initialize(generation, p.c.Clear) } // BranchPublication represents one pending durable branch transition. type BranchPublication struct { - c *BranchCache - version *cache.PlainStateVersionPublication + c *BranchCache + generation *cache.GenerationPublication } // Begin revokes current BranchReadViews without changing branch entries. @@ -111,7 +106,7 @@ func (p BranchPublisher) Begin() *BranchPublication { if p.c == nil { return nil } - return &BranchPublication{c: p.c, version: p.version.Begin()} + return &BranchPublication{c: p.c, generation: p.generation.Begin()} } // Abort restores the previous branch generation after database rollback. @@ -119,18 +114,18 @@ func (p *BranchPublication) Abort() { if p == nil || p.c == nil { return } - p.version.Abort() + p.generation.Abort() p.c = nil } // Publish applies staged pin changes and committed branch updates before it -// exposes stateVersion. clear is required after canonical unwind because its +// exposes generation. clear is required after canonical unwind because its // diffset is not a complete list of branches from the discarded fork. -func (p *BranchPublication) Publish(stateVersion uint64, updates []BranchUpdate, clear bool, adaptive *AdaptivePinPlan) { +func (p *BranchPublication) Publish(generation cache.Generation, updates []BranchUpdate, clear bool, adaptive *AdaptivePinPlan) { if p == nil || p.c == nil { return } - p.version.Publish(stateVersion, func() { + p.generation.Publish(generation, func() { if clear { p.c.Clear() } diff --git a/execution/commitment/preload_parallel.go b/execution/commitment/preload_parallel.go index 30058db0814..c3c886123fd 100644 --- a/execution/commitment/preload_parallel.go +++ b/execution/commitment/preload_parallel.go @@ -176,7 +176,7 @@ func (p *ContractTrunkPreloadParallel) Run( return false } // A branch resolved across merged files has no single source step. The - // enclosing publication binds the completed preload to PlainStateVersion. + // enclosing publication binds the completed preload to one generation. cache.PinEntry(pk.key, v, 0) p.pinnedPrefixes = append(p.pinnedPrefixes, bytes.Clone(pk.key)) p.usedBytes += cost diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 86462c69d0a..fb4bd3fa68f 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -86,10 +86,10 @@ type cachePopulatingGetter struct { } // readAheadGetter enables fills only when the transaction has an exact domain -// frontier. StateCache.View performs the second check: its PlainStateVersion -// must match the currently published generation. Failure of either check keeps -// read-ahead useful for the OS page cache without admitting unsafe values into -// StateCache. +// frontier. StateCache.View also requires the transaction's state version and +// pinned files ends to match the published generation. Failure of either check +// keeps read-ahead useful for the OS page cache without admitting unsafe values +// into StateCache. func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { if sc == nil { return ttx @@ -98,12 +98,19 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter if err != nil { return ttx } + debug := ttx.Debug() for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { - if _, ok := ttx.Debug().DomainVisibleEnd(domain); !ok { + if _, ok := debug.DomainVisibleEnd(domain); !ok { return ttx } } - return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(stateVersion)} + generation := cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) + return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(generation)} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 6990821f13b..dfb64c618df 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -49,7 +49,7 @@ func newTestStateCache(t *testing.T) *cache.StateCache { b := 1 * datasize.MB sc := cache.NewStateCache(b, b, b, b) t.Cleanup(sc.Close) - sc.Publisher().Initialize(1) + sc.Publisher().Initialize(cache.StateGeneration(1, 0, 0, 0)) return sc } @@ -57,7 +57,7 @@ func currentCacheView(t *testing.T, sc *cache.StateCache) cache.ReadView { t.Helper() stateVersion, ok := sc.CurrentStateVersion() require.True(t, ok) - return sc.View(stateVersion) + return sc.View(cache.StateGeneration(stateVersion, 0, 0, 0)) } func seedFill(t *testing.T, sc *cache.StateCache, domain kv.Domain, k, v []byte, step kv.Step) { @@ -155,7 +155,7 @@ func TestCachePopulatingGetterNegativeClearedByPublication(t *testing.T) { _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Publisher().Clear(2) + sc.Publisher().Clear(cache.StateGeneration(2, 0, 0, 0)) _, ok = currentCacheView(t, sc).Get(kv.AccountsDomain, key) require.False(t, ok) } @@ -181,7 +181,7 @@ func TestCachePopulatingGetterStaleViewDoesNotFill(t *testing.T) { view: currentCacheView(t, sc), } publication := sc.Publisher().Begin() - publication.Publish(2, nil, false) + publication.Publish(cache.StateGeneration(2, 0, 0, 0), nil, false) _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) From cfd5bf809ec55afe3318111f2dc7b6174921f24b Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:53:48 +0200 Subject: [PATCH 08/50] execution/commitment, cache: reject stale adaptive pin plans --- db/state/execctx/domain_shared.go | 8 +- execution/cache/generation_gate.go | 45 ++++++++++- execution/commitment/adaptive_pin.go | 93 +++++++++++++++++----- execution/commitment/adaptive_pin_test.go | 94 ++++++++++++++++++++++- execution/commitment/branch_cache.go | 4 + execution/commitment/branch_cache_view.go | 2 +- 6 files changed, 218 insertions(+), 28 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index cb8212035de..e5142f6b0f2 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1200,7 +1200,13 @@ func (sd *SharedDomains) planAdaptivePins(tx kv.RwTx) *commitment.AdaptivePinPla scan(oddFrom, oddTo) return branches } - return sd.adaptivePinController.PlanBlock(sd.txNum, reader, factory, provider) + return sd.adaptivePinController.PlanBlock( + sd.txNum, + sd.baseBranchCacheGeneration, + reader, + factory, + provider, + ) } // TemporalDomain satisfaction. Collects no read metrics — see diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 701417cedc8..8ffcb011f4b 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -79,6 +79,10 @@ type GenerationGate struct { // publicationMu orders durable cache publication with independent changes // to the backing-file view. Begin holds it until Publish or Abort. publicationMu sync.Mutex + // files remembers the latest publication even while no durable generation + // is active, so a later commit cannot restore an older transaction's view. + files FilesView + filesKnown bool } // GenerationView is the immutable validity token held by one cache view. @@ -147,9 +151,9 @@ func (g *GenerationGate) Publisher() GenerationPublisher { func (p GenerationPublisher) Enabled() bool { return p.gate != nil } -// Initialize binds the gate to identity. A mismatch runs clear while fills are -// blocked because existing entries have an unknown origin relative to the -// requested database and files snapshot. +// Initialize binds the gate to identity's state version and the newest files +// view already reported by the backing store. A mismatch clears entries while +// fills are blocked because their origin cannot be proven compatible. func (p GenerationPublisher) Initialize(identity Generation, clear func()) { if p.gate == nil { return @@ -160,6 +164,12 @@ func (p GenerationPublisher) Initialize(identity Generation, clear func()) { gate.admissionMu.Lock() defer gate.admissionMu.Unlock() + if gate.filesKnown { + identity.files = gate.files + } else { + gate.files = identity.files + gate.filesKnown = true + } current := gate.current.Load() if current != nil && current.active && current.identity == identity { return @@ -177,6 +187,8 @@ type GenerationPublication struct { gate *GenerationGate previous *publishedGeneration transition *publishedGeneration + files FilesView + filesKnown bool } // Begin revokes all existing views without changing cache entries. It also @@ -198,7 +210,22 @@ func (p GenerationPublisher) Begin() *GenerationPublication { } transition := &publishedGeneration{} gate.current.Store(transition) - return &GenerationPublication{gate: gate, previous: previous, transition: transition} + return &GenerationPublication{ + gate: gate, + previous: previous, + transition: transition, + files: gate.files, + filesKnown: gate.filesKnown, + } +} + +// StartedFrom reports whether view was the live token revoked by Begin. +func (p *GenerationPublication) StartedFrom(view GenerationView) bool { + return p != nil && + p.gate != nil && + p.previous != nil && + view.gate == p.gate && + view.generation == p.previous } // Abort restores the previous generation when no cache entries were changed. @@ -232,6 +259,12 @@ func (p *GenerationPublication) Publish(identity Generation, apply func()) { if apply != nil { apply() } + if p.filesKnown { + identity.files = p.files + } else { + gate.files = identity.files + gate.filesKnown = true + } gate.current.Store(&publishedGeneration{identity: identity, active: true}) p.gate = nil } @@ -247,6 +280,8 @@ func (g *GenerationGate) Reset(clear func()) { g.admissionMu.Lock() defer g.admissionMu.Unlock() g.current.Store(nil) + g.files = FilesView{} + g.filesKnown = false if clear != nil { clear() } @@ -285,6 +320,8 @@ func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func( if current != nil && !current.active { panic("cache generation publication already in progress") } + gate.files = files + gate.filesKnown = true if current != nil && current.identity.files == files && !incompatible { return nil } diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index e512d96d51c..170a0660322 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -24,6 +24,7 @@ import ( "sync/atomic" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/cache" ) // AdaptivePinControllerConfig sets the policy knobs for the adaptive @@ -60,8 +61,9 @@ type AdaptivePinController struct { misses sync.Map // [32]byte → *atomic.Uint64 - mu sync.Mutex - states map[[32]byte]*adaptiveContractState + mu sync.Mutex + states map[[32]byte]*adaptiveContractState + cacheClearEpoch uint64 } // ParallelResolverFactory builds a fresh BatchBranchResolver for one @@ -119,10 +121,15 @@ type AdaptivePinPlan struct { mutations adaptiveCacheMutations previousStates map[[32]byte]*adaptiveContractState observedMisses map[[32]byte]uint64 - txNum uint64 - promoted int - extended int - demoted int + // The source token and clear epoch must still match when publication + // applies the plan. Otherwise its branches came from obsolete backing state. + source cache.GenerationView + cacheClearEpoch uint64 + applied bool + txNum uint64 + promoted int + extended int + demoted int } type adaptiveContractState struct { @@ -228,10 +235,11 @@ func NewAdaptivePinController(cache *BranchCache, cfg AdaptivePinControllerConfi cfg.PromoteThresholdMisses = def.PromoteThresholdMisses } return &AdaptivePinController{ - cache: cache, - cfg: cfg, - logger: logger, - states: make(map[[32]byte]*adaptiveContractState), + cache: cache, + cfg: cfg, + logger: logger, + states: make(map[[32]byte]*adaptiveContractState), + cacheClearEpoch: cache.clearEpoch.Load(), } } @@ -256,11 +264,26 @@ func (c *AdaptivePinController) Reset() { } c.mu.Lock() defer c.mu.Unlock() + c.resetLocked() +} + +func (c *AdaptivePinController) resetLocked() { c.states = make(map[[32]byte]*adaptiveContractState) c.misses.Range(func(key, _ any) bool { c.misses.Delete(key) return true }) + c.cacheClearEpoch = c.cache.clearEpoch.Load() + mxAdaptiveActive.SetUint64(0) +} + +func (c *AdaptivePinController) syncCacheClearLocked() { + epoch := c.cache.clearEpoch.Load() + if epoch == c.cacheClearEpoch { + return + } + c.states = make(map[[32]byte]*adaptiveContractState) + c.cacheClearEpoch = epoch mxAdaptiveActive.SetUint64(0) } @@ -279,19 +302,29 @@ func (c *AdaptivePinController) onCacheMiss(prefix []byte) { // PlanBlock computes promotions, extensions, and demotions from the // uncommitted transaction without changing BranchCache. The returned plan -// keeps controller updates serialized until Commit or Abort. -func (c *AdaptivePinController) PlanBlock(txNum uint64, reader CommitmentReader, factory ParallelResolverFactory, provider DbBranchesProvider) *AdaptivePinPlan { +// keeps controller updates serialized until Commit or Abort. Publication +// discards it if sourceGeneration or the cache clear epoch changed meanwhile. +func (c *AdaptivePinController) PlanBlock( + txNum uint64, + sourceGeneration cache.Generation, + reader CommitmentReader, + factory ParallelResolverFactory, + provider DbBranchesProvider, +) *AdaptivePinPlan { c.mu.Lock() + c.syncCacheClearLocked() previousStates := c.states c.states = cloneAdaptiveStateHeaders(previousStates) misses := c.snapshotMisses() observedMisses := make(map[[32]byte]uint64, len(misses)) maps.Copy(observedMisses, misses) plan := &AdaptivePinPlan{ - controller: c, - previousStates: previousStates, - observedMisses: observedMisses, - txNum: txNum, + controller: c, + previousStates: previousStates, + observedMisses: observedMisses, + source: c.cache.generation.View(sourceGeneration), + cacheClearEpoch: c.cacheClearEpoch, + txNum: txNum, } // One factory call per block, shared across all contracts. nil falls back to serial. @@ -350,10 +383,15 @@ func (c *AdaptivePinController) PlanBlock(txNum uint64, reader CommitmentReader, return plan } -func (p *AdaptivePinPlan) apply() { - if p != nil && p.controller != nil { - p.mutations.apply(p.controller.cache) +func (p *AdaptivePinPlan) apply(publication *cache.GenerationPublication) { + if p == nil || p.controller == nil { + return } + if p.cacheClearEpoch != p.controller.cache.clearEpoch.Load() || !publication.StartedFrom(p.source) { + return + } + p.mutations.apply(p.controller.cache) + p.applied = true } // Commit accepts the planned controller state after its cache mutations have @@ -363,6 +401,10 @@ func (p *AdaptivePinPlan) Commit() { return } c := p.controller + if !p.applied || p.cacheClearEpoch != c.cache.clearEpoch.Load() { + p.discard() + return + } if p.promoted > 0 { mxAdaptivePromoted.AddUint64(uint64(p.promoted)) } @@ -394,12 +436,23 @@ func (p *AdaptivePinPlan) Abort() { if p == nil || p.controller == nil { return } + p.discard() +} + +func (p *AdaptivePinPlan) discard() { c := p.controller - c.states = p.previousStates + epoch := c.cache.clearEpoch.Load() + if epoch == p.cacheClearEpoch { + c.states = p.previousStates + } else { + c.states = make(map[[32]byte]*adaptiveContractState) + c.cacheClearEpoch = epoch + } for hash, count := range p.observedMisses { value, _ := c.misses.LoadOrStore(hash, new(atomic.Uint64)) value.(*atomic.Uint64).Add(count) } + mxAdaptiveActive.SetUint64(uint64(len(c.states))) p.controller = nil c.mu.Unlock() } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index 41585e5ffba..80f7f80c188 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -23,6 +23,7 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/commitment/nibbles" ) @@ -74,13 +75,13 @@ func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(t *testing.T) { return []byte{0, 0, 0, 0}, 1, true, nil } - plan := controller.PlanBlock(1, reader, nil, nil) + plan := controller.PlanBlock(1, testBranchGeneration(1), reader, nil, nil) _, _, ok := branchCache.Get(prefix) require.False(t, ok, "planning from an uncommitted transaction must not change BranchCache") plan.Abort() require.Empty(t, controller.states, "aborting the database transaction must restore controller state") - plan = controller.PlanBlock(2, reader, nil, nil) + plan = controller.PlanBlock(2, testBranchGeneration(1), reader, nil, nil) publication := publisher.Begin() publication.Publish(testBranchGeneration(2), nil, false, plan) plan.Commit() @@ -88,3 +89,92 @@ func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(t *testing.T) { _, _, ok = branchCache.View(testBranchGeneration(2)).Get(prefix) require.True(t, ok, "publication must apply the staged pin") } + +func TestAdaptivePinPlanIsDiscardedAfterFilesPublication(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(testBranchGeneration(1)) + + cfg := DefaultAdaptivePinControllerConfig() + cfg.PromoteThresholdMisses = 1 + cfg.MaxPromotedContracts = 1 + controller := NewAdaptivePinController(branchCache, cfg, log.Root()) + + var contractHash [32]byte + contractHash[0] = 1 + prefix := nibbles.HexToCompact(ContractNibbles(contractHash[:])) + controller.onCacheMiss(prefix) + reader := func(key []byte) ([]byte, uint64, bool, error) { + if !bytes.Equal(key, prefix) { + return nil, 0, false, nil + } + return []byte{0, 0, 0, 0}, 1, true, nil + } + plan := controller.PlanBlock(1, testBranchGeneration(1), reader, nil, nil) + + change := branchCache.BeginFilesPublication(100) + require.NotNil(t, change) + change.Finish() + + committedKey := []byte{0x01} + committedValue := []byte{0xaa} + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ + Key: committedKey, + Value: committedValue, + Step: 2, + TxNum: 100, + }}, false, plan) + plan.Commit() + + current := branchCache.View(cache.BranchGeneration(2, 100)) + got, _, ok := current.Get(committedKey) + require.True(t, ok, "commit publication must retain the files identity published while the plan was prepared") + require.Equal(t, committedValue, got) + _, _, ok = current.Get(prefix) + require.False(t, ok, "a pin prepared from the previous files must not enter the new generation") + require.Empty(t, controller.states, "discarding the stale plan must also discard its residency state") +} + +func TestAdaptivePinControllerForgetsPinsClearedByFilesPublication(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(testBranchGeneration(1)) + + cfg := DefaultAdaptivePinControllerConfig() + cfg.PromoteThresholdMisses = 1 + cfg.MaxPromotedContracts = 1 + cfg.DemoteCooldownBlocks = 100 + controller := NewAdaptivePinController(branchCache, cfg, log.Root()) + + var contractHash [32]byte + contractHash[0] = 1 + prefix := nibbles.HexToCompact(ContractNibbles(contractHash[:])) + controller.onCacheMiss(prefix) + reader := func(key []byte) ([]byte, uint64, bool, error) { + if !bytes.Equal(key, prefix) { + return nil, 0, false, nil + } + return []byte{0, 0, 0, 0}, 1, true, nil + } + + plan := controller.PlanBlock(1, testBranchGeneration(1), reader, nil, nil) + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), nil, false, plan) + plan.Commit() + require.NotEmpty(t, controller.states) + + change := branchCache.BeginFilesPublication(100) + require.NotNil(t, change) + change.Finish() + + currentGeneration := cache.BranchGeneration(2, 100) + plan = controller.PlanBlock(2, currentGeneration, reader, nil, nil) + publication = publisher.Begin() + publication.Publish(cache.BranchGeneration(3, 100), nil, false, plan) + plan.Commit() + + require.Empty(t, controller.states, "residency state must not outlive the BranchCache entries it describes") +} diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index f53381e5c71..96a73406bad 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -56,6 +56,9 @@ type BranchCache struct { // decided by Generation; this end distinguishes locally built commitment // files from files downloaded outside the publication stream. committedTxNumEnd uint64 + // clearEpoch lets optimistic adaptive plans detect that their pinned + // entries were removed without extending the cache publication lock. + clearEpoch atomic.Uint64 // Root tier — single slot for the root branch (always hottest, always // present). Atomic-pointer access so no lock is needed for the hot @@ -767,6 +770,7 @@ func (c *BranchCache) Clear() { c.tailHits.Store(0) c.tailMisses.Store(0) c.bytesServed.Store(0) + c.clearEpoch.Add(1) } // Stats returns a one-line summary of the cache tiers' hit/miss counters plus diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 4f096064be7..8c795cfffd8 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -129,7 +129,7 @@ func (p *BranchPublication) Publish(generation cache.Generation, updates []Branc if clear { p.c.Clear() } - adaptive.apply() + adaptive.apply(p.generation) for i := range updates { update := &updates[i] if committedEnd := update.TxNum + 1; committedEnd > p.c.committedTxNumEnd { From f9823c9357189205f57f93985eb8572dd4c21b9a Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:52:33 +0200 Subject: [PATCH 09/50] db/state/execctx: bind cache views to transaction files --- db/state/execctx/domain_shared.go | 33 ++++---- .../statecache_rpc_integration_test.go | 79 +++++++++++++++++++ 2 files changed, 93 insertions(+), 19 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index d93a9072cb5..670cd596bfc 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -88,32 +88,29 @@ type cacheViews struct { } // cacheViewsFor binds both process-global caches to the database and files -// generation pinned by tx. The common path reuses construction-time metadata; -// reads through another transaction derive its generation again. +// generation pinned by tx. ViewID identifies only the database snapshot: +// files can change without a database commit, so their identity and cache +// eligibility must always come from the transaction being read. func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { if tx == nil { return cacheViews{} } - var stateGeneration, branchGeneration cache.Generation - var stateEligible, branchEligible bool + stateVersion := sd.baseStateVersion if tx.ViewID() == sd.baseViewID { if !sd.baseStateVersionKnown { return cacheViews{} } - stateGeneration = sd.baseStateCacheGeneration - branchGeneration = sd.baseBranchCacheGeneration - stateEligible = sd.baseStateCacheEligible - branchEligible = sd.baseBranchCacheEligible } else { - stateVersion, err := rawdb.GetStateVersion(tx) + var err error + stateVersion, err = rawdb.GetStateVersion(tx) if err != nil { return cacheViews{} } - debug := tx.Debug() - stateGeneration, branchGeneration = cacheGenerationsFor(debug, stateVersion) - stateEligible = cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) - branchEligible = cacheViewEligible(debug, kv.CommitmentDomain) } + debug := tx.Debug() + stateGeneration, branchGeneration := cacheGenerationsFor(debug, stateVersion) + stateEligible := cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) + branchEligible := cacheViewEligible(debug, kv.CommitmentDomain) var views cacheViews if sd.stateCache != nil && stateEligible { views.state = sd.stateCache.View(stateGeneration) @@ -167,14 +164,13 @@ type SharedDomains struct { logger log.Logger // These fields describe the database snapshot used to construct this - // SharedDomains. The common read path reuses them instead of reading cache - // eligibility metadata for every GetLatest call. + // SharedDomains. A read with the same ViewID can reuse the state version, + // but its independently pinned files metadata must still be derived again. baseViewID uint64 + baseStateVersion uint64 baseStateCacheGeneration cache.Generation baseBranchCacheGeneration cache.Generation baseStateVersionKnown bool - baseStateCacheEligible bool - baseBranchCacheEligible bool txNum uint64 currentStep kv.Step @@ -288,11 +284,10 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, stepSize: debug.StepSize(), baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, baseStateCacheGeneration: stateGeneration, baseBranchCacheGeneration: branchGeneration, baseStateVersionKnown: stateVersionErr == nil, - baseStateCacheEligible: cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain), - baseBranchCacheEligible: cacheViewEligible(debug, kv.CommitmentDomain), } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 62240e42969..4658c9334c0 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -26,6 +26,7 @@ import ( "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/rawdbv3" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" @@ -267,6 +268,84 @@ func TestSharedDomainsOldFilesTxBoundAfterPublicationDoesNotUseNewCacheGeneratio require.Equal(t, v1, got, "a transaction pinned to the old files must not use the new files cache generation") } +func TestSharedDomainsSameDatabaseViewUsesReadTxFilesGeneration(t *testing.T) { + const stepSize = uint64(1) + ctx := t.Context() + db := newTestDb(t, stepSize) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + execctx.BindStateCacheToAggregator(db, stateCache) + + key := make([]byte, 20) + key[0] = 0xaa + values := [][]byte{encAccount(1), encAccount(2), encAccount(3)} + + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + for txNum := range uint64(len(values)) { + var prevValue []byte + if txNum > 0 { + prevValue = values[txNum-1] + } + domains.SetTxNum(txNum) + require.NoError(t, domains.DomainPut(kv.AccountsDomain, rwTx, key, values[txNum], txNum, prevValue)) + _, err = domains.ComputeCommitment(ctx, rwTx, true, txNum, txNum, "", nil) + require.NoError(t, err) + require.NoError(t, domains.Flush(ctx, rwTx)) + require.NoError(t, rawdbv3.TxNums.Append(rwTx, txNum, txNum)) + } + domains.Close() + require.NoError(t, rwTx.Commit()) + + oldTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer oldTx.Rollback() + oldDomains, err := execctx.NewSharedDomains(ctx, oldTx, log.New()) + require.NoError(t, err) + oldDomains.SetStateCacheForTest(stateCache) + oldDomains.Close() + oldFilesEnd := oldTx.Debug().TxNumsInFiles(kv.AccountsDomain) + + agg := db.(state.HasAgg).Agg().(*state.Aggregator) + require.NoError(t, agg.BuildFiles(3)) + + freshTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer freshTx.Rollback() + require.Equal(t, oldTx.ViewID(), freshTx.ViewID(), "publishing files must not advance the database snapshot") + require.Greater(t, freshTx.Debug().TxNumsInFiles(kv.AccountsDomain), oldFilesEnd) + + stateVersion, err := rawdb.GetStateVersion(freshTx) + require.NoError(t, err) + freshDebug := freshTx.Debug() + freshGeneration := cache.StateGeneration( + stateVersion, + freshDebug.TxNumsInFiles(kv.AccountsDomain), + freshDebug.TxNumsInFiles(kv.StorageDomain), + freshDebug.TxNumsInFiles(kv.CodeDomain), + ) + stateCache.Publisher().Clear(freshGeneration) + cacheOnlyValue := []byte{0xff} + freshCacheView := stateCache.View(freshGeneration) + freshCacheView.Fill(kv.AccountsDomain, key, cacheOnlyValue, 0) + cached, ok := freshCacheView.Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, cacheOnlyValue, cached) + + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetStateCacheReaderForTest(stateCache) + + got, _, err := freshDomains.GetLatest(kv.AccountsDomain, oldTx, key) + require.NoError(t, err) + require.Equal(t, values[2], got, "the old files transaction must not use the fresh files cache generation") +} + func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { const stepSize = uint64(16) ctx := t.Context() From 88fc6bafc106eb1b5da55bccf7392db2b736a438 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:15:28 +0200 Subject: [PATCH 10/50] execution/commitment, cache: reset file provenance on lineage change --- execution/cache/files_publication_test.go | 43 ++++++++++++++++++ execution/cache/state_cache.go | 21 +++++---- execution/commitment/branch_cache.go | 14 +++--- .../commitment/branch_cache_absorb_test.go | 44 +++++++++++++++++++ execution/commitment/branch_cache_view.go | 10 +++-- 5 files changed, 114 insertions(+), 18 deletions(-) diff --git a/execution/cache/files_publication_test.go b/execution/cache/files_publication_test.go index e8d6bc7510b..68ebb70e910 100644 --- a/execution/cache/files_publication_test.go +++ b/execution/cache/files_publication_test.go @@ -74,6 +74,49 @@ func TestStateCacheFilesPublication(t *testing.T) { require.True(t, ok, "an already absorbed files view must not clear again") } +func TestStateCacheCanonicalClearResetsFileProvenance(t *testing.T) { + stateCache, publisher, key := stateCacheWithPublishedCoverage(t) + publication := publisher.Begin() + publication.Publish(testStateGeneration(3), nil, true) + requireStateCacheForeignFilesClear(t, stateCache, key, 3) +} + +func TestStateCacheInitializeMismatchResetsFileProvenance(t *testing.T) { + stateCache, publisher, key := stateCacheWithPublishedCoverage(t) + publisher.Initialize(testStateGeneration(3)) + requireStateCacheForeignFilesClear(t, stateCache, key, 3) +} + +func stateCacheWithPublishedCoverage(t *testing.T) (*StateCache, Publisher, []byte) { + t.Helper() + stateCache, publisher := readyStateCache(t, 1) + key := makeAddr(1) + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), []Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: makeValue(1), + Step: 1, + TxNum: 100, + }}, false) + return stateCache, publisher, key +} + +func requireStateCacheForeignFilesClear(t *testing.T, stateCache *StateCache, key []byte, stateVersion uint64) { + t.Helper() + current := stateCache.View(testStateGeneration(stateVersion)) + current.Fill(kv.AccountsDomain, key, makeValue(2), 2) + + var filesEnd [kv.DomainLen]uint64 + filesEnd[kv.AccountsDomain] = 50 + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + change.Finish() + + _, ok := stateCache.View(StateGeneration(stateVersion, 50, 0, 0)).Get(kv.AccountsDomain, key) + require.False(t, ok, "files not covered by the current lineage must clear cache entries") +} + func TestFilesPublicationBlocksCachePublicationUntilVisible(t *testing.T) { stateCache, publisher := readyStateCache(t, 1) var filesEnd [kv.DomainLen]uint64 diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 169aa794fb3..fc5f0e33df6 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -43,8 +43,8 @@ type StateCache struct { generation GenerationGate // committedTxNumEnd is only a file-provenance watermark. Cache validity is - // decided by Generation; these ends distinguish files built from published - // updates from files downloaded outside that stream. + // decided by Generation; these ends distinguish files covered by published + // updates in the current canonical lineage from files downloaded outside it. committedTxNumEnd [kv.DomainLen]uint64 caches [kv.DomainLen]Cache disableFills bool @@ -266,6 +266,11 @@ func (c *StateCache) clearLocked() { } } +func (c *StateCache) resetProvenanceAndClearLocked() { + c.committedTxNumEnd = [kv.DomainLen]uint64{} + c.clearLocked() +} + func (c *StateCache) Close() { c.generation.Close() for _, cache := range c.caches { @@ -333,13 +338,13 @@ func (c *StateCache) Publisher() Publisher { func (p Publisher) Enabled() bool { return p.c != nil && p.generation.Enabled() } // Initialize binds the cache to the database and files generation seen by its -// canonical owner. A mismatch clears entries because their origin cannot be -// proven compatible with that snapshot. +// canonical owner. A mismatch clears entries and their file provenance because +// neither can be proven compatible with that snapshot. func (p Publisher) Initialize(generation Generation) { if p.c == nil { return } - p.generation.Initialize(generation, p.c.clearLocked) + p.generation.Initialize(generation, p.c.resetProvenanceAndClearLocked) } // Publication represents one pending transition of the durable database @@ -379,15 +384,15 @@ func (p *Publication) Abort() { // // A forward commit can retain entries that were not updated because they still // have the same value in the new state. Canonical unwind sets clear because its -// callbacks do not enumerate every value that may belong to the discarded -// fork. +// callbacks do not enumerate every value or file-coverage claim that may belong +// to the discarded fork. func (p *Publication) Publish(generation Generation, updates []Update, clear bool) { if p == nil || p.c == nil { return } p.generation.Publish(generation, func() { if clear { - p.c.clearLocked() + p.c.resetProvenanceAndClearLocked() } for i := range updates { p.c.applyLocked(updates[i]) diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 96a73406bad..a5279c93e7c 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -53,8 +53,8 @@ type BranchCache struct { generation cache.GenerationGate // committedTxNumEnd is only a file-provenance watermark. Cache validity is - // decided by Generation; this end distinguishes locally built commitment - // files from files downloaded outside the publication stream. + // decided by Generation; this end distinguishes commitment files covered by + // updates in the current canonical lineage from files downloaded outside it. committedTxNumEnd uint64 // clearEpoch lets optimistic adaptive plans detect that their pinned // entries were removed without extending the cache publication lock. @@ -358,10 +358,12 @@ func (c *BranchCache) Close() { // Reset clears cached branches and revokes all views until the next durable // publication. func (c *BranchCache) Reset() { - c.generation.Reset(func() { - c.committedTxNumEnd = 0 - c.Clear() - }) + c.generation.Reset(c.resetProvenanceAndClear) +} + +func (c *BranchCache) resetProvenanceAndClear() { + c.committedTxNumEnd = 0 + c.Clear() } // BeginFilesPublication revokes the old files generation. It retains entries diff --git a/execution/commitment/branch_cache_absorb_test.go b/execution/commitment/branch_cache_absorb_test.go index 292ac3e8bf1..0e4f767bf9c 100644 --- a/execution/commitment/branch_cache_absorb_test.go +++ b/execution/commitment/branch_cache_absorb_test.go @@ -72,3 +72,47 @@ func TestBranchCacheFilesPublication(t *testing.T) { _, _, ok = current.Get(key) require.True(t, ok, "an already absorbed files view must not clear again") } + +func TestBranchCacheCanonicalClearResetsFileProvenance(t *testing.T) { + branchCache, publisher, key := branchCacheWithPublishedCoverage(t) + publication := publisher.Begin() + publication.Publish(testBranchGeneration(3), nil, true, nil) + requireBranchCacheForeignFilesClear(t, branchCache, key, 3) +} + +func TestBranchCacheInitializeMismatchResetsFileProvenance(t *testing.T) { + branchCache, publisher, key := branchCacheWithPublishedCoverage(t) + publisher.Initialize(testBranchGeneration(3)) + requireBranchCacheForeignFilesClear(t, branchCache, key, 3) +} + +func branchCacheWithPublishedCoverage(t *testing.T) (*BranchCache, BranchPublisher, []byte) { + t.Helper() + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(testBranchGeneration(1)) + key := []byte{0x01} + + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ + Key: key, + Value: []byte{0xbb}, + Step: 1, + TxNum: 100, + }}, false, nil) + return branchCache, publisher, key +} + +func requireBranchCacheForeignFilesClear(t *testing.T, branchCache *BranchCache, key []byte, stateVersion uint64) { + t.Helper() + current := branchCache.View(testBranchGeneration(stateVersion)) + current.Fill(key, []byte{0xcc}, 2) + + change := branchCache.BeginFilesPublication(50) + require.NotNil(t, change) + change.Finish() + + _, _, ok := branchCache.View(cache.BranchGeneration(stateVersion, 50)).Get(key) + require.False(t, ok, "files not covered by the current lineage must clear cached branches") +} diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 8c795cfffd8..3a0785b314e 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -87,12 +87,13 @@ func (p BranchPublisher) Enabled() bool { return p.c != nil && p.generation.Enabled() } -// Initialize binds an empty or previously published cache to generation. +// Initialize binds the cache to generation. A mismatch clears branches and +// their file provenance because neither can be attributed to that snapshot. func (p BranchPublisher) Initialize(generation cache.Generation) { if p.c == nil { return } - p.generation.Initialize(generation, p.c.Clear) + p.generation.Initialize(generation, p.c.resetProvenanceAndClear) } // BranchPublication represents one pending durable branch transition. @@ -120,14 +121,15 @@ func (p *BranchPublication) Abort() { // Publish applies staged pin changes and committed branch updates before it // exposes generation. clear is required after canonical unwind because its -// diffset is not a complete list of branches from the discarded fork. +// diffset is not a complete list of branches or file-coverage claims from the +// discarded fork. func (p *BranchPublication) Publish(generation cache.Generation, updates []BranchUpdate, clear bool, adaptive *AdaptivePinPlan) { if p == nil || p.c == nil { return } p.generation.Publish(generation, func() { if clear { - p.c.Clear() + p.c.resetProvenanceAndClear() } adaptive.apply(p.generation) for i := range updates { From 72f06fe2630ae4437d4ce2fc2438e3b4927e7244 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:11:03 +0200 Subject: [PATCH 11/50] db/state, rawdbreset: revoke caches on execution reset --- db/state/aggregator.go | 17 +++ execution/cache/state_cache.go | 9 ++ .../stagedsync/rawdbreset/reset_stages.go | 35 +++--- .../rawdbreset/reset_stages_test.go | 116 ++++++++++++++++++ 4 files changed, 159 insertions(+), 18 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index d66951841ba..a549649ac60 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -580,6 +580,23 @@ func (a *Aggregator) BindStateCache(stateCache *cache.StateCache) { change.Finish() } +// ResetExecutionCaches revokes state backed by execution tables that are about +// to be replaced outside SharedDomains.Commit. Both caches remain unpublished +// until a later canonical owner initializes or publishes their new generation. +func (a *Aggregator) ResetExecutionCaches() { + a.dirtyFilesLock.Lock() + defer a.dirtyFilesLock.Unlock() + + // Match the publication lock order used by SharedDomains.Commit and file + // publication so concurrent transitions cannot deadlock. + if domain := a.d[kv.CommitmentDomain]; domain != nil && domain.branchCache != nil { + domain.branchCache.Reset() + } + if a.boundStateCache != nil { + a.boundStateCache.Reset() + } +} + func (a *Aggregator) setUnalignedDomain(d kv.Domain, v bool) { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index fc5f0e33df6..0c0b9fb25e4 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -271,6 +271,15 @@ func (c *StateCache) resetProvenanceAndClearLocked() { c.clearLocked() } +// Reset revokes all views, clears entries and file provenance, and leaves the +// cache unpublished until its canonical owner initializes or publishes it. +func (c *StateCache) Reset() { + if c == nil { + return + } + c.generation.Reset(c.resetProvenanceAndClearLocked) +} + func (c *StateCache) Close() { c.generation.Close() for _, cache := range c.caches { diff --git a/execution/stagedsync/rawdbreset/reset_stages.go b/execution/stagedsync/rawdbreset/reset_stages.go index edcc60ffff9..51c14515102 100644 --- a/execution/stagedsync/rawdbreset/reset_stages.go +++ b/execution/stagedsync/rawdbreset/reset_stages.go @@ -175,6 +175,20 @@ func ResetExec(ctx context.Context, db kv.TemporalRwDB) error { cleanupList = append(cleanupList, db.Debug().DomainTables(kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain, kv.CommitmentDomain, kv.ReceiptDomain, kv.RCacheDomain)...) cleanupList = append(cleanupList, db.Debug().InvertedIdxTables(kv.LogAddrIdx, kv.LogTopicIdx, kv.TracesFromIdx, kv.TracesToIdx)...) + // ResetExec replaces durable state without SharedDomains.Commit, so revoke + // both shared caches before the replacement can become visible. On failure, + // leaving them empty is safe; their canonical owner can initialize them again. + executionCachesReset := false + if hasAgg, ok := db.(dbstate.HasAgg); ok { + if agg, ok := hasAgg.Agg().(*dbstate.Aggregator); ok { + agg.ResetExecutionCaches() + executionCachesReset = true + } + } + if !executionCachesReset { + log.Warn("[reset] execution caches not reset before wiping state tables (no *state.Aggregator); continued use of an external cache may serve stale state") + } + if err := db.Update(ctx, func(tx kv.RwTx) error { if err := clearStageProgress(tx, stages.Execution); err != nil { return fmt.Errorf("clearing Execution stage progress: %w", err) @@ -183,28 +197,13 @@ func ResetExec(ctx context.Context, db kv.TemporalRwDB) error { if err := backup.ClearTables(ctx, db, tx, cleanupList...); err != nil { return fmt.Errorf("reset exec state tables: %w", err) } + if _, err := rawdb.IncrementStateVersion(tx); err != nil { + return fmt.Errorf("advancing state version after exec reset: %w", err) + } return nil }); err != nil { return err } - - // Wiping the commitment table leaves the aggregator's in-memory branchCache - // pointing at now-deleted trie nodes; drop it so a from-0 re-exec repopulates - // from the wiped table instead of computing a wrong root off stale nodes. - branchCacheCleared := false - if hasAgg, ok := db.(dbstate.HasAgg); ok { - if agg, ok := hasAgg.Agg().(*dbstate.Aggregator); ok { - aggTx := agg.BeginFilesRo() - defer aggTx.Close() - if bc := aggTx.BranchCache(); bc != nil { - bc.Clear() - } - branchCacheCleared = true - } - } - if !branchCacheCleared { - log.Warn("[reset] commitment branch cache not cleared after wiping the table (no *state.Aggregator); a from-0 re-exec may read stale commitment nodes and produce a wrong trie root") - } return nil } diff --git a/execution/stagedsync/rawdbreset/reset_stages_test.go b/execution/stagedsync/rawdbreset/reset_stages_test.go index aa829c0930f..8c6c922da54 100644 --- a/execution/stagedsync/rawdbreset/reset_stages_test.go +++ b/execution/stagedsync/rawdbreset/reset_stages_test.go @@ -23,16 +23,132 @@ import ( "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/datadir" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/kv/temporal/temporaltest" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" + dbstate "github.com/erigontech/erigon/db/state" + "github.com/erigontech/erigon/execution/cache" + "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/stagedsync/rawdbreset" "github.com/erigontech/erigon/execution/stagedsync/stages" ) +func resetCacheTestDB(t *testing.T) (kv.TemporalRwDB, *dbstate.Aggregator) { + t.Helper() + previous := dbg.UseStateCache + dbg.SetUseStateCache(true) + t.Cleanup(func() { dbg.SetUseStateCache(previous) }) + + db := temporaltest.NewTestDBWithStepSize(t, datadir.New(t.TempDir()), 16) + hasAgg, ok := db.(dbstate.HasAgg) + require.True(t, ok) + agg, ok := hasAgg.Agg().(*dbstate.Aggregator) + require.True(t, ok) + return db, agg +} + +func cacheGenerations(t *testing.T, db kv.TemporalRwDB) (cache.Generation, cache.Generation, *commitment.BranchCache) { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + debug := tx.Debug() + stateGeneration := cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) + branchGeneration := cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) + provider, ok := tx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + return stateGeneration, branchGeneration, provider.BranchCache() +} + +func stateVersion(t *testing.T, db kv.TemporalRwDB) uint64 { + t.Helper() + tx, err := db.BeginRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + version, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + return version +} + +func TestResetExecAdvancesStateVersion(t *testing.T) { + db, _ := resetCacheTestDB(t) + require.NoError(t, db.Update(t.Context(), func(tx kv.RwTx) error { + _, err := rawdb.IncrementStateVersion(tx) + return err + })) + before := stateVersion(t, db) + + require.NoError(t, rawdbreset.ResetExec(t.Context(), db)) + + require.Equal(t, before+1, stateVersion(t, db)) +} + +func TestResetExecResetsBoundStateCache(t *testing.T) { + db, agg := resetCacheTestDB(t) + stateGeneration, _, _ := cacheGenerations(t, db) + + stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(stateCache.Close) + agg.BindStateCache(stateCache) + publisher := stateCache.Publisher() + publisher.Initialize(stateGeneration) + publication := publisher.Begin() + key := []byte{0x01} + publication.Publish(stateGeneration, []cache.Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: []byte{0xaa}, + }}, false) + oldView := stateCache.View(stateGeneration) + _, ok := oldView.Get(kv.AccountsDomain, key) + require.True(t, ok, "precondition: state entry is cached") + + require.NoError(t, rawdbreset.ResetExec(t.Context(), db)) + + _, ok = oldView.Get(kv.AccountsDomain, key) + require.False(t, ok, "reset must revoke views of the pre-reset state") + oldView.Fill(kv.AccountsDomain, key, []byte{0xbb}, 0) + publication = publisher.Begin() + publication.Publish(stateGeneration, nil, false) + _, ok = stateCache.View(stateGeneration).Get(kv.AccountsDomain, key) + require.False(t, ok, "the same numeric generation must not expose or accept pre-reset state") +} + +func TestResetExecResetsBranchCacheGeneration(t *testing.T) { + db, _ := resetCacheTestDB(t) + _, branchGeneration, branchCache := cacheGenerations(t, db) + require.NotNil(t, branchCache) + + publisher := branchCache.Publisher() + publisher.Initialize(branchGeneration) + key := []byte{0x01} + oldView := branchCache.View(branchGeneration) + oldView.Fill(key, []byte{0xaa}, 0) + _, _, ok := oldView.Get(key) + require.True(t, ok, "precondition: branch entry is cached") + + require.NoError(t, rawdbreset.ResetExec(t.Context(), db)) + + oldView.Fill(key, []byte{0xbb}, 0) + publication := publisher.Begin() + publication.Publish(branchGeneration, nil, false, nil) + _, _, ok = branchCache.View(branchGeneration).Get(key) + require.False(t, ok, "a pre-reset view must not refill the reset branch generation") +} + // TestResetCanonicalAndRefillFromSnapshots_ClearsStaleSidechainPointers // verifies the fix for a stale-canonical-pointer leak observed on hoodi // snapshotters running release/3.4: a sidechain block was once canonical from From 3b80d6340bd7e4e7d771eeb160db773bfe51d2dc Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:44:34 +0200 Subject: [PATCH 12/50] cmd/integration: publish caches after execution unwind --- cmd/integration/commands/stages.go | 11 ++- cmd/integration/commands/stages_test.go | 93 +++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 cmd/integration/commands/stages_test.go diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index 21f547f4d62..cb58231c1fc 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -717,12 +717,7 @@ func stageExec(db kv.TemporalRwDB, ctx context.Context, logger log.Logger) error if err := stagedsync.UnwindExecutionStage(u, s, doms, tx, ctx, cfg, logger); err != nil { return err } - if err := doms.Flush(ctx, tx); err != nil { - return err - } - err = tx.Commit() - tx = nil - return err + return commitExecUnwind(ctx, doms, tx) } if pruneTo > 0 { @@ -824,6 +819,10 @@ func stageExec(db kv.TemporalRwDB, ctx context.Context, logger log.Logger) error return nil } +func commitExecUnwind(ctx context.Context, doms *execctx.SharedDomains, tx kv.TemporalRwTx) error { + return doms.Commit(ctx, tx) +} + // execBlocksBatch runs one stage_exec batch in its own rwtx and SharedDomains: // exec up to toBlock (or the batch limit), then doms.Commit. Commit (not Flush) // refreshes the aggregator BranchCache to match committed state — a stale cache diff --git a/cmd/integration/commands/stages_test.go b/cmd/integration/commands/stages_test.go new file mode 100644 index 00000000000..a0ffab35d3a --- /dev/null +++ b/cmd/integration/commands/stages_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 commands + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/db/datadir" + "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/kv/temporal/temporaltest" + "github.com/erigontech/erigon/db/rawdb" + "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/cache" + "github.com/erigontech/erigon/execution/commitment" +) + +func branchGeneration(t *testing.T, tx kv.TemporalTx) cache.Generation { + t.Helper() + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + return cache.BranchGeneration(stateVersion, tx.Debug().TxNumsInFiles(kv.CommitmentDomain)) +} + +func TestCommitExecUnwindDoesNotRepublishDiscardedBranches(t *testing.T) { + previous := dbg.UseStateCache + dbg.SetUseStateCache(true) + t.Cleanup(func() { dbg.SetUseStateCache(previous) }) + + ctx := t.Context() + logger := log.New() + db := temporaltest.NewTestDBWithStepSize(t, datadir.New(t.TempDir()), 100) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, logger) + require.NoError(t, err) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + unwindTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer unwindTx.Rollback() + unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, logger) + require.NoError(t, err) + + provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + require.NotNil(t, branchCache) + discardedKey := []byte{0xa0, 0xb0} + oldView := branchCache.View(branchGeneration(t, unwindTx)) + oldView.Fill(discardedKey, []byte("discarded-fork"), 1) + _, _, ok = oldView.Get(discardedKey) + require.True(t, ok, "precondition: discarded branch is cached") + + var diffs [kv.DomainLen][]kv.DomainEntryDiff + unwindDomains.Unwind(0, &diffs) + require.NoError(t, commitExecUnwind(ctx, unwindDomains, unwindTx)) + unwindDomains.Close() + + nextTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer nextTx.Rollback() + nextDomains, err := execctx.NewSharedDomains(ctx, nextTx, logger) + require.NoError(t, err) + require.NoError(t, nextDomains.Commit(ctx, nextTx)) + nextDomains.Close() + + readTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer readTx.Rollback() + _, _, ok = branchCache.View(branchGeneration(t, readTx)).Get(discardedKey) + require.False(t, ok, "a later commit must not republish a branch discarded by the unwind") +} From 718d4bb45dae75f3267082035c17e95026fcc067 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:53:38 +0200 Subject: [PATCH 13/50] db, execution: avoid cache-view frontier cursors --- db/kv/kv_interface.go | 3 ++ db/kv/remotedb/kv_remote.go | 1 + db/kv/temporal/kv_temporal.go | 6 +++ db/kv/temporal/kv_temporal_test.go | 1 + db/state/aggregator.go | 11 ++-- db/state/aggregator_align_test.go | 1 + .../execctx/cache_view_eligibility_test.go | 51 +++++++++++++++++++ db/state/execctx/domain_shared.go | 14 +++-- .../execctx/statecache_readfill_bench_test.go | 50 ++++++++++++++++++ execution/exec/blocks_read_ahead.go | 2 +- 10 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 db/state/execctx/cache_view_eligibility_test.go diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index cc75136347a..91d549231a1 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -525,6 +525,9 @@ type TemporalDebugTx interface { // DomainVisibleEnd returns the exact exclusive txNum bound of the tx's // domain read view. ok is false when the backend cannot provide an exact bound. DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) + // HasExactDomainVisibleEnd reports DomainVisibleEnd's ok result without + // resolving the bound, which may require a database cursor. + HasExactDomainVisibleEnd(domain Domain) bool IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 // Retire retires frozen history files entirely below their diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index 1e7260a7fbd..4c6b620c0f7 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -258,6 +258,7 @@ func (tx *tx) DomainProgress(domain kv.Domain) uint64 { panic("not impl func (tx *tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return 0, false } +func (tx *tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { return false } func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index f134154737a..60c6992f3a0 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -796,6 +796,12 @@ func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) } +func (tx *Tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { + return tx.aggtx.HasExactDomainVisibleEnd(domain) +} +func (tx *RwTx) HasExactDomainVisibleEnd(domain kv.Domain) bool { + return tx.aggtx.HasExactDomainVisibleEnd(domain) +} func (tx *Tx) IIProgress(domain kv.InvertedIdx) uint64 { return tx.aggtx.IIProgress(domain, tx.Tx) } diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index 37884f86bac..abedcd823de 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -294,6 +294,7 @@ func TestTemporalTx_DomainVisibleEndConcurrent(t *testing.T) { defer baseTtx.Rollback() for d := range kv.DomainLen { expectedEnd[d], expectedOk[d] = baseTtx.Debug().DomainVisibleEnd(d) + require.Equal(t, expectedOk[d], baseTtx.Debug().HasExactDomainVisibleEnd(d)) } baseTtx.Rollback() require.Equal(t, uint64(2), expectedEnd[kv.StorageDomain]) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index a549649ac60..54c777139aa 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2786,19 +2786,20 @@ func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { } return at.d[name].ht.iit.Progress(tx) } -func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { +func (at *AggregatorRoTx) HasExactDomainVisibleEnd(name kv.Domain) bool { d := at.d[name] - if d.d.HistoryDisabled { - return 0, false - } + return !d.d.HistoryDisabled && d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() +} +func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { // A dependency checker can clamp the values view below the history-II end. // Such a view has no exact frontier: reads mix fresh DB-resident keys with // older file values for the gap, and raising the dependent file's // visibility later reveals state without any cache apply — a fill made // during the clamp would never be invalidated. - if d.files.EndTxNum() < d.ht.iit.files.EndTxNum() { + if !at.HasExactDomainVisibleEnd(name) { return 0, false } + d := at.d[name] return d.ht.iit.visibleEnd(tx), true } func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index d8a44dfd8fc..02b320f69dc 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -231,6 +231,7 @@ func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) require.False(t, ok, "a dependency-clamped values view has no exact frontier") + require.False(t, at.HasExactDomainVisibleEnd(kv.AccountsDomain)) } // The forbid assert must also watch the history-II ends: they are the base of diff --git a/db/state/execctx/cache_view_eligibility_test.go b/db/state/execctx/cache_view_eligibility_test.go new file mode 100644 index 00000000000..6375f5b70a0 --- /dev/null +++ b/db/state/execctx/cache_view_eligibility_test.go @@ -0,0 +1,51 @@ +// 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 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/db/kv" +) + +type exactDomainViewStub struct { + exact map[kv.Domain]bool + checked []kv.Domain +} + +func (s *exactDomainViewStub) HasExactDomainVisibleEnd(domain kv.Domain) bool { + s.checked = append(s.checked, domain) + return s.exact[domain] +} + +func TestCacheViewEligibleUsesExactViewAvailability(t *testing.T) { + exact := map[kv.Domain]bool{ + kv.AccountsDomain: true, + kv.StorageDomain: true, + kv.CodeDomain: true, + } + debug := &exactDomainViewStub{exact: exact} + require.True(t, cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) + require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain}, debug.checked) + + exact[kv.StorageDomain] = false + debug.checked = nil + require.False(t, cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) + require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain}, debug.checked) +} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 670cd596bfc..b1a36280134 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -132,12 +132,16 @@ func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) (cache.G return stateGeneration, branchGeneration } -// cacheViewEligible rejects a dependency-clamped domain view. Its reads mix -// database state with an older values frontier, so it has no exact cache -// identity. -func cacheViewEligible(debug kv.TemporalDebugTx, domains ...kv.Domain) bool { +type exactDomainVisibleEnd interface { + HasExactDomainVisibleEnd(domain kv.Domain) bool +} + +// cacheViewEligible checks only whether exact ends are available. Resolving the +// ends would open database cursors, but their numeric frontier values are not +// part of cache identity. +func cacheViewEligible(debug exactDomainVisibleEnd, domains ...kv.Domain) bool { for _, domain := range domains { - if _, ok := debug.DomainVisibleEnd(domain); !ok { + if !debug.HasExactDomainVisibleEnd(domain) { return false } } diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index f2fc5f7fa00..42a23ce1338 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -99,3 +99,53 @@ func BenchmarkGetLatestColdNegativeRw(b *testing.B) { benchColdNegativeReads(b, func BenchmarkGetLatestColdNegativeRwNoCache(b *testing.B) { benchColdNegativeReads(b, false, true) } + +var benchmarkTemporalGetter kv.TemporalGetter + +func benchmarkCacheGetterConstruction(b *testing.B, resolveVisibleEnds bool) { + db := benchSeedDb(b) + ctx := b.Context() + baseTx, err := db.BeginTemporalRo(ctx) + require.NoError(b, err) + defer baseTx.Rollback() + sd, err := execctx.NewSharedDomains(ctx, baseTx, log.New()) + require.NoError(b, err) + defer sd.Close() + stateCache := newSmallStateCache() + defer stateCache.Close() + sd.SetStateCacheForTest(stateCache) + + domains := [...]kv.Domain{ + kv.AccountsDomain, + kv.StorageDomain, + kv.CodeDomain, + kv.CommitmentDomain, + } + b.ResetTimer() + b.StopTimer() + for range b.N { + tx, err := db.BeginTemporalRo(ctx) //nolint:gocritic // benchmark loop; explicit Rollback below + if err != nil { + b.Fatal(err) + } + b.StartTimer() + if resolveVisibleEnds { + debug := tx.Debug() + for _, domain := range domains { + debug.DomainVisibleEnd(domain) + } + } + benchmarkTemporalGetter = sd.AsGetter(tx) + b.StopTimer() + tx.Rollback() + } +} + +func BenchmarkCacheGetterConstruction(b *testing.B) { + b.Run("exactness_check", func(b *testing.B) { + benchmarkCacheGetterConstruction(b, false) + }) + b.Run("visible_end_resolution", func(b *testing.B) { + benchmarkCacheGetterConstruction(b, true) + }) +} diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 4f8a783e2af..099861d044c 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -106,7 +106,7 @@ func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter } debug := ttx.Debug() for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { - if _, ok := debug.DomainVisibleEnd(domain); !ok { + if !debug.HasExactDomainVisibleEnd(domain) { return ttx } } From b98dcc4e505b92ad3b156f0846f29bde57d0bdd4 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:44:56 +0200 Subject: [PATCH 14/50] db/state: restore visibility guard after file reset --- db/state/aggregator.go | 3 ++- db/state/aggregator_align_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 54c777139aa..73b4e564bd2 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -751,9 +751,10 @@ func (a *Aggregator) ReloadFiles() error { func (a *Aggregator) closeDirtyFilesNoReopen() { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() + loweringWasForbidden := a.visibilityLoweringForbidden.Swap(false) + defer a.visibilityLoweringForbidden.Store(loweringWasForbidden) // This path removes every visible file before replacing them, so no cache // view may remain live across the reset. - a.visibilityLoweringForbidden.Store(false) if cd := a.d[kv.CommitmentDomain]; cd != nil && cd.branchCache != nil { cd.branchCache.Reset() } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 02b320f69dc..94849a33854 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -18,6 +18,7 @@ package state import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" @@ -187,6 +188,23 @@ func TestVisibilityLowering_ForbiddenAggregatorPanicsOnLoweringOnly(t *testing.T require.Panics(t, func() { realign() }, "realigning a still-lagging receipt lowers the state domains' ends") } +func TestCloseDirtyFilesNoReopenRestoresVisibilityLoweringGuard(t *testing.T) { + t.Parallel() + + for _, initiallyForbidden := range []bool{false, true} { + t.Run(fmt.Sprintf("initially_forbidden_%t", initiallyForbidden), func(t *testing.T) { + _, agg := testDbAndAggregatorv3(t, alignStepSize) + if initiallyForbidden { + agg.ForbidVisibilityLowering() + } + + agg.closeDirtyFilesNoReopen() + + require.Equal(t, initiallyForbidden, agg.visibilityLoweringForbidden.Load()) + }) + } +} + // craftedClampedVisible replaces the current visible bundle with one where // every state domain's values files end one segment below its history-II end // — the divergence a dependency checker produces when a dependent file is From cd785c68a5d362e16b6de43a2fa71cd4f826646b Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:13:17 +0200 Subject: [PATCH 15/50] db/state: remove obsolete cache tx number lookup --- db/state/aggregator.go | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 73b4e564bd2..ba646b6a397 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2838,40 +2838,25 @@ func (at *AggregatorRoTx) MeteredGetLatest(domain kv.Domain, k []byte, tx kv.Tx, return at.getLatest(domain, k, tx, maxStep, metrics, start) } -// MeteredGetLatestWithTxN returns the high-water txN alongside (value, -// step) for tagging BranchCache entries so a lazy unwind can drop them by -// (txN, epoch). Non-CommitmentDomain reads return txN=0. -func (at *AggregatorRoTx) MeteredGetLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error) { - return at.getLatestWithTxN(domain, k, tx, maxStep, metrics, start) -} - func (at *AggregatorRoTx) getLatest(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.DomainMetrics, start time.Time) (v []byte, step kv.Step, ok bool, err error) { - v, step, _, ok, err = at.getLatestWithTxN(domain, k, tx, maxStep, metrics, start) - return v, step, ok, err -} - -func (at *AggregatorRoTx) getLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, maxStep kv.Step, metrics *kvmetrics.DomainMetrics, start time.Time) (v []byte, step kv.Step, txN uint64, ok bool, err error) { if domain != kv.CommitmentDomain { - v, step, ok, err = at.d[domain].getLatest(k, tx, maxStep, metrics, start) - return v, step, 0, ok, err + return at.d[domain].getLatest(k, tx, maxStep, metrics, start) } v, step, ok, err = at.d[domain].getLatestFromDb(k, tx) if err != nil { - return nil, kv.Step(0), 0, false, err + return nil, kv.Step(0), false, err } if ok && step <= maxStep { if metrics != nil && dbg.KVReadLevelledMetrics { metrics.UpdateDbReads(domain, start) } - // DB-sourced: tag with the step's high-water; the exact write - // txN isn't recoverable from the step-keyed record. - return v, step, lastTxNumOfStep(step, at.StepSize()), true, nil + return v, step, true, nil } v, found, fileStartTxNum, fileEndTxNum, err := at.d[domain].getLatestFromFiles(k, 0) if !found { - return nil, kv.Step(0), 0, false, err + return nil, kv.Step(0), false, err } if metrics != nil && dbg.KVReadLevelledMetrics { // UpdateFileReadsUnique tracks total + distinct prefixes; the @@ -2879,9 +2864,7 @@ func (at *AggregatorRoTx) getLatestWithTxN(domain kv.Domain, k []byte, tx kv.Tx, metrics.UpdateFileReadsUnique(domain, k, start) } v, err = at.replaceShortenedKeysInBranch(k, commitment.BranchData(v), fileStartTxNum, fileEndTxNum) - // File-sourced: tag with fileEndTxNum; snapshots are immutable and - // unwind can't cross them, so this is always <= any legal watermark. - return v, kv.Step(fileEndTxNum / at.StepSize()), fileEndTxNum, found, err + return v, kv.Step(fileEndTxNum / at.StepSize()), found, err } func (at *AggregatorRoTx) DebugGetLatestFromDB(domain kv.Domain, key []byte, tx kv.Tx) ([]byte, kv.Step, bool, error) { From d7b56932f29526a1eede9596a264c85f191703d3 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:25:08 +0200 Subject: [PATCH 16/50] execution/cache: simplify generation publication state --- execution/cache/generation_gate.go | 59 +++++++++++------------------- 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 8ffcb011f4b..a1722a4f965 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -68,12 +68,12 @@ func (g Generation) WithStateVersion(stateVersion uint64) Generation { // published again. type publishedGeneration struct { identity Generation - active bool } // GenerationGate binds lock-free cache reads and serialized fills to one // durable database state over one compatible files view. type GenerationGate struct { + // current is nil until initialization and while publication is in progress. current atomic.Pointer[publishedGeneration] admissionMu sync.RWMutex // publicationMu orders durable cache publication with independent changes @@ -97,7 +97,7 @@ func (g *GenerationGate) View(identity Generation) GenerationView { return GenerationView{} } generation := g.current.Load() - if generation == nil || !generation.active || generation.identity != identity { + if generation == nil || generation.identity != identity { return GenerationView{} } return GenerationView{gate: g, generation: generation} @@ -130,7 +130,7 @@ func (g *GenerationGate) CurrentStateVersion() (uint64, bool) { return 0, false } generation := g.current.Load() - if generation == nil || !generation.active { + if generation == nil { return 0, false } return generation.identity.stateVersion, true @@ -171,7 +171,7 @@ func (p GenerationPublisher) Initialize(identity Generation, clear func()) { gate.filesKnown = true } current := gate.current.Load() - if current != nil && current.active && current.identity == identity { + if current != nil && current.identity == identity { return } @@ -179,16 +179,13 @@ func (p GenerationPublisher) Initialize(identity Generation, clear func()) { if clear != nil { clear() } - gate.current.Store(&publishedGeneration{identity: identity, active: true}) + gate.current.Store(&publishedGeneration{identity: identity}) } // GenerationPublication represents one pending durable transition. type GenerationPublication struct { - gate *GenerationGate - previous *publishedGeneration - transition *publishedGeneration - files FilesView - filesKnown bool + gate *GenerationGate + previous *publishedGeneration } // Begin revokes all existing views without changing cache entries. It also @@ -204,18 +201,10 @@ func (p GenerationPublisher) Begin() *GenerationPublication { defer gate.admissionMu.Unlock() previous := gate.current.Load() - if previous != nil && !previous.active { - gate.publicationMu.Unlock() - panic("cache generation publication already in progress") - } - transition := &publishedGeneration{} - gate.current.Store(transition) + gate.current.Store(nil) return &GenerationPublication{ - gate: gate, - previous: previous, - transition: transition, - files: gate.files, - filesKnown: gate.filesKnown, + gate: gate, + previous: previous, } } @@ -237,7 +226,7 @@ func (p *GenerationPublication) Abort() { gate.admissionMu.Lock() defer gate.publicationMu.Unlock() defer gate.admissionMu.Unlock() - if gate.current.Load() != p.transition { + if gate.current.Load() != nil { panic("cache generation publication changed before abort") } gate.current.Store(p.previous) @@ -253,19 +242,19 @@ func (p *GenerationPublication) Publish(identity Generation, apply func()) { gate.admissionMu.Lock() defer gate.publicationMu.Unlock() defer gate.admissionMu.Unlock() - if gate.current.Load() != p.transition { + if gate.current.Load() != nil { panic("cache generation publication changed before publish") } if apply != nil { apply() } - if p.filesKnown { - identity.files = p.files + if gate.filesKnown { + identity.files = gate.files } else { gate.files = identity.files gate.filesKnown = true } - gate.current.Store(&publishedGeneration{identity: identity, active: true}) + gate.current.Store(&publishedGeneration{identity: identity}) p.gate = nil } @@ -290,9 +279,8 @@ func (g *GenerationGate) Reset(clear func()) { // BackingChange keeps cache publication blocked while a new files view becomes // visible. type BackingChange struct { - gate *GenerationGate - transition *publishedGeneration - next *publishedGeneration + gate *GenerationGate + next *publishedGeneration } // BeginBackingChange runs reconcile while publications and fills are blocked. @@ -317,28 +305,23 @@ func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func( incompatible := reconcile != nil && reconcile() current := gate.current.Load() - if current != nil && !current.active { - panic("cache generation publication already in progress") - } gate.files = files gate.filesKnown = true if current != nil && current.identity.files == files && !incompatible { return nil } - var transition, next *publishedGeneration + var next *publishedGeneration if current != nil { - transition = &publishedGeneration{} next = &publishedGeneration{ identity: Generation{stateVersion: current.identity.stateVersion, files: files}, - active: true, } - gate.current.Store(transition) + gate.current.Store(nil) } if incompatible && clear != nil { clear() } keepPublicationLocked = true - return &BackingChange{gate: gate, transition: transition, next: next} + return &BackingChange{gate: gate, next: next} } // Finish publishes the matching cache identity after the files view is visible. @@ -350,7 +333,7 @@ func (c *BackingChange) Finish() { gate.admissionMu.Lock() defer gate.publicationMu.Unlock() defer gate.admissionMu.Unlock() - if c.transition != nil && gate.current.Load() != c.transition { + if gate.current.Load() != nil { panic("cache generation changed during files publication") } gate.current.Store(c.next) From 8c3a7d3efded634a2e0e5e11ea024e12436a2ce9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:35:44 +0200 Subject: [PATCH 17/50] db/state/execctx: pair cache generations and clears --- db/state/execctx/domain_shared.go | 114 +++++++++++++++--------------- db/state/execctx/export_test.go | 8 +-- 2 files changed, 62 insertions(+), 60 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b1a36280134..1ac1c95fb8f 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -87,6 +87,17 @@ type cacheViews struct { branch commitment.BranchReadView } +type cacheGenerations struct { + state cache.Generation + branch cache.Generation +} + +func (g cacheGenerations) withStateVersion(stateVersion uint64) cacheGenerations { + g.state = g.state.WithStateVersion(stateVersion) + g.branch = g.branch.WithStateVersion(stateVersion) + return g +} + // cacheViewsFor binds both process-global caches to the database and files // generation pinned by tx. ViewID identifies only the database snapshot: // files can change without a database commit, so their identity and cache @@ -108,28 +119,29 @@ func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { } } debug := tx.Debug() - stateGeneration, branchGeneration := cacheGenerationsFor(debug, stateVersion) + generations := cacheGenerationsFor(debug, stateVersion) stateEligible := cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) branchEligible := cacheViewEligible(debug, kv.CommitmentDomain) var views cacheViews if sd.stateCache != nil && stateEligible { - views.state = sd.stateCache.View(stateGeneration) + views.state = sd.stateCache.View(generations.state) } if sd.branchCache != nil && branchEligible { - views.branch = sd.branchCache.View(branchGeneration) + views.branch = sd.branchCache.View(generations.branch) } return views } -func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) (cache.Generation, cache.Generation) { - stateGeneration := cache.StateGeneration( - stateVersion, - debug.TxNumsInFiles(kv.AccountsDomain), - debug.TxNumsInFiles(kv.StorageDomain), - debug.TxNumsInFiles(kv.CodeDomain), - ) - branchGeneration := cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) - return stateGeneration, branchGeneration +func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) cacheGenerations { + return cacheGenerations{ + state: cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ), + branch: cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)), + } } type exactDomainVisibleEnd interface { @@ -170,11 +182,10 @@ type SharedDomains struct { // These fields describe the database snapshot used to construct this // SharedDomains. A read with the same ViewID can reuse the state version, // but its independently pinned files metadata must still be derived again. - baseViewID uint64 - baseStateVersion uint64 - baseStateCacheGeneration cache.Generation - baseBranchCacheGeneration cache.Generation - baseStateVersionKnown bool + baseViewID uint64 + baseStateVersion uint64 + baseCacheGenerations cacheGenerations + baseStateVersionKnown bool txNum uint64 currentStep kv.Step @@ -199,15 +210,15 @@ type SharedDomains struct { // to read from the FCU's published SD without writing to it. parent *SharedDomains - // stateCache provides generation-bound reads and fills. cachePublisher is set + // stateCache provides generation-bound reads and fills. statePublisher is set // only when this SharedDomains owns publication of durable canonical state; // a speculative SharedDomains may read the cache but cannot move its // generation or change its authoritative entries. stateCache *cache.StateCache - cachePublisher cache.Publisher - // Unwind and Merge preserve this flag after detaching the reader so a later - // canonical Commit clears entries from the discarded state. - clearStateCache bool + statePublisher cache.Publisher + // Unwind and Merge preserve this flag after detaching both cache readers so + // a later canonical Commit clears entries from the discarded state. + clearExecutionCaches bool // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code // cache, reached via temporalGetter so an addr-keyed reader can serve a @@ -225,8 +236,6 @@ type SharedDomains struct { // SharedDomains from observing another transaction's cached branches. branchCache *commitment.BranchCache branchPublisher commitment.BranchPublisher - // Like clearStateCache, this survives reader detachment and Merge. - clearBranchCache bool // collector is the process-level KV-read metrics collector (aggregator // scope). Finished per-worker metrics are sent here (ownership transfer) @@ -282,16 +291,15 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) debug := tx.Debug() - stateGeneration, branchGeneration := cacheGenerationsFor(debug, stateVersion) + baseCacheGenerations := cacheGenerationsFor(debug, stateVersion) sd := &SharedDomains{ - logger: logger, - metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, - stepSize: debug.StepSize(), - baseViewID: tx.ViewID(), - baseStateVersion: stateVersion, - baseStateCacheGeneration: stateGeneration, - baseBranchCacheGeneration: branchGeneration, - baseStateVersionKnown: stateVersionErr == nil, + logger: logger, + metrics: kvmetrics.DomainMetrics{Domains: map[kv.Domain]*kvmetrics.DomainIOMetrics{}}, + stepSize: debug.StepSize(), + baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, + baseCacheGenerations: baseCacheGenerations, + baseStateVersionKnown: stateVersionErr == nil, } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -384,13 +392,10 @@ func (sd *SharedDomains) Merge(ctx context.Context, sdTxNum uint64, other *Share if err := sd.mem.Merge(other.mem); err != nil { return err } - if other.clearStateCache { + if other.clearExecutionCaches { sd.stateCache = nil - sd.clearStateCache = true - } - if other.clearBranchCache { sd.branchCache = nil - sd.clearBranchCache = true + sd.clearExecutionCaches = true } // Merge block-level metadata from other's overlay into ours by flushing @@ -758,8 +763,7 @@ func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][ // diff is not a complete inventory of entries from the discarded fork. sd.stateCache = nil sd.branchCache = nil - sd.clearStateCache = true - sd.clearBranchCache = true + sd.clearExecutionCaches = true } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -827,7 +831,7 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } - if !sd.clearStateCache { + if !sd.clearExecutionCaches { sd.stateCache = stateCache } } @@ -846,11 +850,11 @@ func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil || !sd.baseStateVersionKnown { return } - if !sd.clearStateCache { + if !sd.clearExecutionCaches { sd.stateCache = stateCache } - sd.cachePublisher = stateCache.Publisher() - sd.cachePublisher.Initialize(sd.baseStateCacheGeneration) + sd.statePublisher = stateCache.Publisher() + sd.statePublisher.Initialize(sd.baseCacheGenerations.state) } // BindStateCacheToAggregator binds StateCache to the aggregator's file @@ -1015,7 +1019,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return nil } - stateCacheEnabled := sd.cachePublisher.Enabled() + stateCacheEnabled := sd.statePublisher.Enabled() branchCacheEnabled := sd.branchPublisher.Enabled() if !stateCacheEnabled && !branchCacheEnabled && sd.codeStore == nil { if err := sd.flushMem(ctx, tx); err != nil { @@ -1089,14 +1093,13 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return err } - var stateGeneration, branchGeneration cache.Generation + var nextCacheGenerations cacheGenerations if stateCacheEnabled || branchCacheEnabled { stateVersion, err := rawdb.GetStateVersion(tx) if err != nil { return fmt.Errorf("read plain state version: %w", err) } - stateGeneration = sd.baseStateCacheGeneration.WithStateVersion(stateVersion) - branchGeneration = sd.baseBranchCacheGeneration.WithStateVersion(stateVersion) + nextCacheGenerations = sd.baseCacheGenerations.withStateVersion(stateVersion) } var statePublication *cache.Publication @@ -1111,29 +1114,28 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // Canonical commits and file-view changes both acquire BranchCache before // StateCache. Keeping one order prevents their publications from deadlocking. if branchCacheEnabled { - if !sd.clearBranchCache { + if !sd.clearExecutionCaches { adaptivePlan = sd.planAdaptivePins(tx) } branchPublication = sd.branchPublisher.Begin() } if stateCacheEnabled { - statePublication = sd.cachePublisher.Begin() + statePublication = sd.statePublisher.Begin() } if err := tx.Commit(); err != nil { return err } - statePublication.Publish(stateGeneration, stateUpdates, sd.clearStateCache) + statePublication.Publish(nextCacheGenerations.state, stateUpdates, sd.clearExecutionCaches) statePublication = nil - branchPublication.Publish(branchGeneration, branchUpdates, sd.clearBranchCache, adaptivePlan) + branchPublication.Publish(nextCacheGenerations.branch, branchUpdates, sd.clearExecutionCaches, adaptivePlan) branchPublication = nil adaptivePlan.Commit() adaptivePlan = nil - if sd.clearBranchCache && sd.adaptivePinController != nil { + if sd.clearExecutionCaches && sd.adaptivePinController != nil { sd.adaptivePinController.Reset() } - sd.clearStateCache = false - sd.clearBranchCache = false + sd.clearExecutionCaches = false return nil } @@ -1192,7 +1194,7 @@ func (sd *SharedDomains) planAdaptivePins(tx kv.RwTx) *commitment.AdaptivePinPla } return sd.adaptivePinController.PlanBlock( sd.txNum, - sd.baseBranchCacheGeneration, + sd.baseCacheGenerations.branch, reader, factory, provider, diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index c27063cf44b..87a86fd7aca 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -17,17 +17,17 @@ 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) { - if !sd.clearStateCache { + if !sd.clearExecutionCaches { sd.stateCache = sc } if sd.baseStateVersionKnown { - sd.cachePublisher = sc.Publisher() - sd.cachePublisher.Initialize(sd.baseStateCacheGeneration) + sd.statePublisher = sc.Publisher() + sd.statePublisher.Initialize(sd.baseCacheGenerations.state) } } func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { - if !sd.clearStateCache { + if !sd.clearExecutionCaches { sd.stateCache = sc } } From 40255e7b0cf8089607711e96a7b49a4e9d9b46bd Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:39:53 +0200 Subject: [PATCH 18/50] execution/stagedsync: remove stale state cache comment --- execution/stagedsync/exec3_parallel.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index cef5e5f02e8..1e87573d4e6 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -1299,11 +1299,6 @@ func (pe *parallelExecutor) decideStop(blockResult *blockResult, sizeCutPending } func (pe *parallelExecutor) processRequest(ctx context.Context, execRequest *execRequest) (err error) { - // The state cache is a SharedDomain implementation detail: it is populated - // only at flush (committed, fork-agnostic state) and invalidated only on - // unwind (txNum/epoch — see StateCache.Unwind). The executor does not touch - // it during forward execution. - if execRequest.block == nil { return errors.New("parallel exec request has no block") } From c620fb2440b6685ae6387aeee3504de784ba1641 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:42:09 +0200 Subject: [PATCH 19/50] execution/cache: correct unwind test name --- execution/cache/cache_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index c87db939d13..1628c1b7bd1 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -771,7 +771,7 @@ func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { } } -func TestStateCache_UnwindReadmitsPreReorgFill(t *testing.T) { +func TestStateCache_UnwindRejectsPreReorgFill(t *testing.T) { sc, publisher := readyStateCache(t, 1) key := makeAddr(1) fork := makeValue(2) From 498f21490dde5b98037f82e39a1bbc3b0d9e90f3 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:02:53 +0200 Subject: [PATCH 20/50] execution/cache, commitment: simplify cache lifecycle APIs --- db/kv/temporal/kv_temporal.go | 4 +- db/state/aggregator.go | 1 - db/state/execctx/codehash_routing_test.go | 10 +-- db/state/execctx/flush_storage_cache_test.go | 4 +- db/state/execctx/statecache_readfill_test.go | 61 ++++++++++++------- .../statecache_rpc_integration_test.go | 18 +++--- execution/cache/cache_test.go | 6 +- execution/cache/generation_gate.go | 18 ------ execution/cache/state_cache.go | 28 ++------- execution/commitment/branch_cache.go | 15 ++--- execution/commitment/branch_cache_test.go | 33 ++++++---- execution/commitment/branch_cache_view.go | 14 ++--- execution/exec/blocks_read_ahead_test.go | 43 ++++++------- 13 files changed, 120 insertions(+), 135 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 60c6992f3a0..05a9b6d6b93 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -282,8 +282,8 @@ type domainVisibleEnds struct { // generation, end from another) can only be stale-low, which merely // over-rejects fills: a view's frontier never decreases in a process that // fills a cache — the DB component is frozen at tx begin, and a files - // reopen only extends it, an invariant the aggregator enforces once a - // shared latest-state cache is bound (BindStateCache). + // reopen only extends it. The aggregator rejects visibility lowering while + // a shared latest-state cache is in use. ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/db/state/aggregator.go b/db/state/aggregator.go index ba646b6a397..567ce245413 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -790,7 +790,6 @@ func (a *Aggregator) Close() { // eagerly and drop this cache from the active-instance count so later // BranchCaches size their trunk depth against real concurrency. if cd := a.d[kv.CommitmentDomain]; cd != nil && cd.branchCache != nil { - cd.branchCache.Clear() cd.branchCache.Close() } diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 8cdf39f25da..a5c6bdadf00 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -45,7 +45,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - currentStateCacheView(t, sc).SeedAddrCodeHash(addr[:], staleArr) + currentStateCacheView(t, db, sc).SeedAddrCodeHash(addr[:], staleArr) t.Run("empty in-batch account wins (codeHash-no-code repro)", func(t *testing.T) { acc := accounts.Account{Nonce: 7, CodeHash: accounts.EmptyCodeHash} @@ -99,9 +99,9 @@ func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { require.NoError(t, seedSD.Commit(ctx, seedTx)) seedSD.Close() - _, ok := currentStateCacheView(t, sc).Get(kv.AccountsDomain, addr[:]) + _, ok := currentStateCacheView(t, db, sc).Get(kv.AccountsDomain, addr[:]) require.True(t, ok, "the committed record must be served by the accounts cache") - _, ok = currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) + _, ok = currentStateCacheView(t, db, sc).GetAddrCodeHash(addr[:]) require.False(t, ok, "the post-commit apply must leave the derived mapping empty") roTx, err := db.BeginTemporalRo(ctx) @@ -114,7 +114,7 @@ func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - h, ok := currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) + h, ok := currentStateCacheView(t, db, sc).GetAddrCodeHash(addr[:]) require.True(t, ok) require.Equal(t, [32]byte(codeHash), h) } @@ -160,7 +160,7 @@ func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) - h, ok := currentStateCacheView(t, sc).GetAddrCodeHash(addr[:]) + h, ok := currentStateCacheView(t, db, sc).GetAddrCodeHash(addr[:]) require.True(t, ok, "a view-sourced record must seed the mapping") require.Equal(t, [32]byte(codeHash), h) } diff --git a/db/state/execctx/flush_storage_cache_test.go b/db/state/execctx/flush_storage_cache_test.go index 7235a2a6297..25752fdd2aa 100644 --- a/db/state/execctx/flush_storage_cache_test.go +++ b/db/state/execctx/flush_storage_cache_test.go @@ -77,14 +77,14 @@ func TestCommit_UpdatesStorageStateCache(t *testing.T) { // First commit: the storage callback must fire and populate the cache. commit(1, val1, nil) - got, ok := currentStateCacheView(t, sc).Get(kv.StorageDomain, key) + got, ok := currentStateCacheView(t, db, sc).Get(kv.StorageDomain, key) require.True(t, ok, "storage cache must be populated by the commit callback") require.Equal(t, val1, got) // Overwrite in a second tx: the callback must fire again and refresh the // entry — not leave the stale val1 behind. commit(stepSize+1, val2, val1) - got, ok = currentStateCacheView(t, sc).Get(kv.StorageDomain, key) + got, ok = currentStateCacheView(t, db, sc).Get(kv.StorageDomain, key) require.True(t, ok) require.Equal(t, val2, got, "commit must refresh the storage cache; stale value served on hit was the bug") } diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 20f87f1595d..7c89105086e 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -28,6 +28,7 @@ import ( "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" + "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/types/accounts" @@ -76,11 +77,25 @@ func newSmallStateCache() *cache.StateCache { return cache.NewStateCache(b, b, b, b) } -func currentStateCacheView(t *testing.T, stateCache *cache.StateCache) cache.ReadView { +func currentStateCacheGeneration(t *testing.T, db kv.TemporalRoDB) cache.Generation { t.Helper() - stateVersion, ok := stateCache.CurrentStateVersion() - require.True(t, ok) - return stateCache.View(cache.StateGeneration(stateVersion, 0, 0, 0)) + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + debug := tx.Debug() + return cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) +} + +func currentStateCacheView(t *testing.T, db kv.TemporalRoDB, stateCache *cache.StateCache) cache.ReadView { + t.Helper() + return stateCache.View(currentStateCacheGeneration(t, db)) } // During an in-flight unwind this SharedDomains is detached from StateCache, @@ -183,9 +198,12 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { db := newTestDb(t, stepSize) sc := newSmallStateCache() key, _, v2, diffs := twoStepRows(t, db, sc) - stateVersion, ok := sc.CurrentStateVersion() - require.True(t, ok) - sc.Publisher().Begin().Publish(cache.StateGeneration(stateVersion, 0, 0, 0), nil, true) + generation := currentStateCacheGeneration(t, db) + sc.Publisher().Begin().Publish(generation, nil, true) + durableView := sc.View(generation) + sentinelKey := make([]byte, 20) + sentinelKey[0] = 0xdd + durableView.Fill(kv.AccountsDomain, sentinelKey, []byte("durable"), 0) roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) @@ -201,10 +219,9 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { require.NoError(t, err) require.Equal(t, v2, got) - currentVersion, ok := sc.CurrentStateVersion() + _, ok := durableView.Get(kv.AccountsDomain, sentinelKey) require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") - require.Equal(t, stateVersion, currentVersion) - _, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) + _, ok = durableView.Get(kv.AccountsDomain, key) require.False(t, ok, "the detached SharedDomains must not fill from its rewound database view") } @@ -235,9 +252,12 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { sd.SetTxNum(20) require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, value, 20, nil)) require.NoError(t, sd.Commit(ctx, rwTx)) - stateVersion, ok := sc.CurrentStateVersion() - require.True(t, ok) - sc.Publisher().Begin().Publish(cache.StateGeneration(stateVersion, 0, 0, 0), nil, true) + generation := currentStateCacheGeneration(t, db) + sc.Publisher().Begin().Publish(generation, nil, true) + durableView := sc.View(generation) + sentinelKey := make([]byte, 20) + sentinelKey[0] = 0xee + durableView.Fill(kv.AccountsDomain, sentinelKey, []byte("durable"), 0) stepBytes := make([]byte, 8) binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) @@ -256,10 +276,9 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { got := sd2.CodeHashForAddr(roTx, key, 20) require.Equal(t, codeHash[:], got) - currentVersion, ok := sc.CurrentStateVersion() + _, ok := durableView.Get(kv.AccountsDomain, sentinelKey) require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") - require.Equal(t, stateVersion, currentVersion) - _, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) + _, ok = durableView.Get(kv.AccountsDomain, key) require.False(t, ok, "code-hash lookup through the rewound view must not fill the durable cache") } @@ -273,9 +292,8 @@ func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { t.Cleanup(sc.Close) key, _, v2, diffs := twoStepRows(t, db, sc) - stateVersion, ok := sc.CurrentStateVersion() - require.True(t, ok) - got, ok := sc.View(cache.StateGeneration(stateVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) + view := currentStateCacheView(t, db, sc) + got, ok := view.Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, v2, got) @@ -288,10 +306,7 @@ func TestSpeculativeUnwindDoesNotPublishStateCache(t *testing.T) { sd.SetStateCacheReaderForTest(sc) sd.Unwind(10, &diffs) - currentVersion, ok := sc.CurrentStateVersion() - require.True(t, ok) - require.Equal(t, stateVersion, currentVersion) - got, ok = sc.View(cache.StateGeneration(currentVersion, 0, 0, 0)).Get(kv.AccountsDomain, key) + got, ok = view.Get(kv.AccountsDomain, key) require.True(t, ok) require.Equal(t, v2, got) } diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 4658c9334c0..af1e2731950 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -83,7 +83,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { require.NoError(t, err) require.Equal(t, v2, got, "the pre-reorg RPC view still sees the discarded fork") - _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, key) + _, ok := currentStateCacheView(t, db, stateCache).Get(kv.AccountsDomain, key) require.False(t, ok, "the pre-reorg RPC view must not refill the discarded fork") freshTx, err := db.BeginTemporalRo(ctx) @@ -140,7 +140,7 @@ func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) require.NoError(t, err) require.Equal(t, v2, got, "the old RPC transaction still sees the discarded fork") - _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, key) + _, ok := currentStateCacheView(t, db, stateCache).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) @@ -182,7 +182,7 @@ func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testin require.NoError(t, err) require.Equal(t, v2, got, "the old transaction still sees the discarded fork") - _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, key) + _, ok := currentStateCacheView(t, db, stateCache).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) @@ -328,7 +328,7 @@ func TestSharedDomainsSameDatabaseViewUsesReadTxFilesGeneration(t *testing.T) { freshDebug.TxNumsInFiles(kv.StorageDomain), freshDebug.TxNumsInFiles(kv.CodeDomain), ) - stateCache.Publisher().Clear(freshGeneration) + stateCache.Publisher().Begin().Publish(freshGeneration, nil, true) cacheOnlyValue := []byte{0xff} freshCacheView := stateCache.View(freshGeneration) freshCacheView.Fill(kv.AccountsDomain, key, cacheOnlyValue, 0) @@ -412,7 +412,7 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { require.NoError(t, err) require.Equal(t, code, got) - cached, ok := currentStateCacheView(t, stateCache).Get(kv.CodeDomain, contractAddr) + cached, ok := currentStateCacheView(t, db, stateCache).Get(kv.CodeDomain, contractAddr) require.True(t, ok, "an account-only deletion must not block unrelated code fills") require.Equal(t, code, cached) } @@ -439,7 +439,7 @@ func TestCanonicalUnwindClearsNegativeCacheEntry(t *testing.T) { require.Empty(t, got) readDomains.Close() - _, ok := currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, missingKey) + _, ok := currentStateCacheView(t, db, stateCache).Get(kv.AccountsDomain, missingKey) require.True(t, ok) unwindTx, err := db.BeginTemporalRw(ctx) @@ -452,7 +452,7 @@ func TestCanonicalUnwindClearsNegativeCacheEntry(t *testing.T) { unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) - _, ok = currentStateCacheView(t, stateCache).Get(kv.AccountsDomain, missingKey) + _, ok = currentStateCacheView(t, db, stateCache).Get(kv.AccountsDomain, missingKey) require.False(t, ok, "canonical unwind must clear entries without an unwind callback") } @@ -610,13 +610,13 @@ func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { // The view outlives the overlay teardown on purpose, as above. deleteDomains.Close() - _, ok := currentStateCacheView(t, stateCache).Get(kv.CodeDomain, addr) + _, ok := currentStateCacheView(t, db, stateCache).Get(kv.CodeDomain, addr) require.False(t, ok, "the account deletion must drop the cached code entry") got, err := rpcView.GetCode(addr) require.NoError(t, err) require.Empty(t, got, "the view keeps serving the published SD's state after teardown, so the deletion stays visible") - _, ok = currentStateCacheView(t, stateCache).Get(kv.CodeDomain, addr) + _, ok = currentStateCacheView(t, db, stateCache).Get(kv.CodeDomain, addr) require.False(t, ok, "a pre-deletion RPC view must not refill the deleted account's code") } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 1628c1b7bd1..81ab8a795c7 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -577,7 +577,8 @@ func TestStateCache_Clear(t *testing.T) { view.Fill(kv.StorageDomain, makeAddr(2), makeValue(2), 0) view.Fill(kv.CodeDomain, makeAddr(3), makeCode(3), 0) - publisher.Clear(testStateGeneration(2)) + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), nil, true) view = c.View(testStateGeneration(2)) _, ok1 := view.Get(kv.AccountsDomain, makeAddr(1)) @@ -979,7 +980,8 @@ func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { sc, publisher := readyStateCache(t, 1) key := makeAddr(1) oldView := sc.View(testStateGeneration(1)) - publisher.Clear(testStateGeneration(1)) + publication := publisher.Begin() + publication.Publish(testStateGeneration(1), nil, true) oldView.Fill(kv.AccountsDomain, key, []byte("pre-delete"), 10) freshView := sc.View(testStateGeneration(1)) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index a1722a4f965..690558e50bf 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -123,19 +123,6 @@ func (v GenerationView) Admit(fill func()) bool { return true } -// CurrentStateVersion reports the durable database version of the active -// generation. It returns false before initialization and during publication. -func (g *GenerationGate) CurrentStateVersion() (uint64, bool) { - if g == nil { - return 0, false - } - generation := g.current.Load() - if generation == nil { - return 0, false - } - return generation.identity.stateVersion, true -} - // GenerationPublisher is the mutation capability for one generation gate. type GenerationPublisher struct { gate *GenerationGate @@ -143,14 +130,9 @@ type GenerationPublisher struct { // Publisher returns a handle that can initialize and publish the gate. func (g *GenerationGate) Publisher() GenerationPublisher { - if g == nil { - return GenerationPublisher{} - } return GenerationPublisher{gate: g} } -func (p GenerationPublisher) Enabled() bool { return p.gate != nil } - // Initialize binds the gate to identity's state version and the newest files // view already reported by the backing store. A mismatch clears entries while // fills are blocked because their origin cannot be proven compatible. diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 0c0b9fb25e4..f11092078bc 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -92,13 +92,6 @@ func NewDefaultStateCache() *StateCache { ) } -// CurrentStateVersion reports the durable PlainStateVersion represented by -// all cache layers. It returns false while publication is in progress because -// the old version has been revoked and the new version is not visible yet. -func (c *StateCache) CurrentStateVersion() (uint64, bool) { - return c.generation.CurrentStateVersion() -} - // BeginFilesPublication revokes the old files generation. It retains entries // backed by this process's committed updates and clears them when the new files // contain foreign state. Finish publishes the new identity after the files @@ -331,20 +324,16 @@ type Update struct { // receive only ReadView, while code that makes database state durable uses a // Publisher to move every cache layer to the same Generation. type Publisher struct { - c *StateCache - generation GenerationPublisher + c *StateCache } // Publisher returns a handle that can change the cache's canonical generation. // It must not be given to speculative execution whose writes may be discarded. func (c *StateCache) Publisher() Publisher { - if c == nil { - return Publisher{} - } - return Publisher{c: c, generation: c.generation.Publisher()} + return Publisher{c: c} } -func (p Publisher) Enabled() bool { return p.c != nil && p.generation.Enabled() } +func (p Publisher) Enabled() bool { return p.c != nil } // Initialize binds the cache to the database and files generation seen by its // canonical owner. A mismatch clears entries and their file provenance because @@ -353,7 +342,7 @@ func (p Publisher) Initialize(generation Generation) { if p.c == nil { return } - p.generation.Initialize(generation, p.c.resetProvenanceAndClearLocked) + p.c.generation.Publisher().Initialize(generation, p.c.resetProvenanceAndClearLocked) } // Publication represents one pending transition of the durable database @@ -372,7 +361,7 @@ func (p Publisher) Begin() *Publication { if p.c == nil { return nil } - return &Publication{c: p.c, generation: p.generation.Begin()} + return &Publication{c: p.c, generation: p.c.generation.Publisher().Begin()} } // Abort restores the previous generation after a failed or abandoned database @@ -409,10 +398,3 @@ func (p *Publication) Publish(generation Generation, updates []Update, clear boo }) p.c = nil } - -// Clear revokes current views, removes every cached value, and publishes an -// empty generation. -func (p Publisher) Clear(generation Generation) { - publication := p.Begin() - publication.Publish(generation, nil, true) -} diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index a5279c93e7c..fb1f0f87b0c 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -343,11 +343,12 @@ func NewBranchCache(tailCapacity int) *BranchCache { return bc } -// Close drops this cache from the active-instance count so later BranchCaches -// size their trunk depth against real concurrency. Idempotent. +// Close revokes all views, releases cached entries, and drops this cache from +// the active-instance count. Idempotent. func (c *BranchCache) Close() { c.generation.Close() if c.closed.CompareAndSwap(false, true) { + c.resetProvenanceAndClear() if t := c.tail.Load(); t != nil { t.Close() } @@ -363,7 +364,7 @@ func (c *BranchCache) Reset() { func (c *BranchCache) resetProvenanceAndClear() { c.committedTxNumEnd = 0 - c.Clear() + c.clear() } // BeginFilesPublication revokes the old files generation. It retains entries @@ -380,7 +381,7 @@ func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChang } c.committedTxNumEnd = filesEnd return true - }, c.Clear) + }, c.clear) } // tailForWrite returns the LRU tail, allocating it on first use so a cache whose @@ -746,9 +747,9 @@ func (c *BranchCache) Invalidate(prefix []byte) { } } -// Clear empties the root, trunk, pinned, and tail tiers and resets their stats. +// clear empties the root, trunk, pinned, and tail tiers and resets their stats. // It holds every writer stripe so a write cannot cross the clear. -func (c *BranchCache) Clear() { +func (c *BranchCache) clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() @@ -766,7 +767,7 @@ func (c *BranchCache) Clear() { c.pinnedHits.Store(0) c.pinnedMisses.Store(0) // Reset the publish watermarks too, else the next PublishMetrics computes a - // wrapped (huge) delta against the pre-Clear counter. + // wrapped (huge) delta against the pre-clear counter. c.lastPublishedPinnedHits.Store(0) c.lastPublishedPinnedMisses.Store(0) c.tailHits.Store(0) diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index daadb57bb27..b16790049b4 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -142,8 +142,8 @@ func TestBranchCache_Invalidate(t *testing.T) { require.False(t, ok, "deep invalidated") } -// TestBranchCache_Clear empties everything and resets stats. -func TestBranchCache_Clear(t *testing.T) { +// TestBranchCache_Reset empties everything and resets stats. +func TestBranchCache_Reset(t *testing.T) { c := NewBranchCache(100) deepKey := []byte{0x12, 0x34, 0x56} // 5 nibbles → LRU tail c.Put([]byte{0x00}, []byte("r"), 0) @@ -154,7 +154,7 @@ func TestBranchCache_Clear(t *testing.T) { require.Equal(t, uint64(1), c.rootHits.Load()) require.Equal(t, uint64(1), c.tailHits.Load()) - c.Clear() + c.Reset() require.Equal(t, uint64(0), c.rootHits.Load()) require.Equal(t, uint64(0), c.tailHits.Load()) _, _, ok := c.Get([]byte{0x00}) @@ -163,7 +163,18 @@ func TestBranchCache_Clear(t *testing.T) { require.False(t, ok) } -func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func()) { +func TestBranchCache_CloseClearsEntries(t *testing.T) { + c := NewBranchCache(100) + key := []byte{0x00} + c.Put(key, []byte("root"), 0) + + c.Close() + + _, _, ok := c.Get(key) + require.False(t, ok) +} + +func resetDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func()) { // Limit Go execution to one logical processor. Each runtime.Gosched call // yields to the queued goroutine, which runs until it reaches the blocked lock. previousProcs := runtime.GOMAXPROCS(1) @@ -182,7 +193,7 @@ func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write clearDone := make(chan struct{}) go func() { - c.Clear() + c.Reset() close(clearDone) }() runtime.Gosched() @@ -192,31 +203,31 @@ func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write <-clearDone } -func TestBranchCache_ClearFencesStartedPut(t *testing.T) { +func TestBranchCache_ResetFencesStartedPut(t *testing.T) { c := NewBranchCache(100) defer c.Close() key := []byte{0x12, 0x34, 0x56} - clearDuringBlockedBranchCacheWrite(c, &c.tailMu, func() { + resetDuringBlockedBranchCacheWrite(c, &c.tailMu, func() { c.Put(key, []byte("dead-fork-branch"), 0) }) _, _, ok := c.Get(key) - require.False(t, ok, "Clear must remove a Put that started in the retiring generation") + require.False(t, ok, "Reset must remove a Put that started in the retiring generation") } -func TestBranchCache_ClearFencesStartedPinEntry(t *testing.T) { +func TestBranchCache_ResetFencesStartedPinEntry(t *testing.T) { c := NewBranchCache(100) defer c.Close() key := make([]byte, 33) key[32] = 1 - clearDuringBlockedBranchCacheWrite(c, &c.pinnedMu, func() { + resetDuringBlockedBranchCacheWrite(c, &c.pinnedMu, func() { c.PinEntry(key, []byte("dead-fork-branch"), 0) }) _, _, ok := c.Get(key) - require.False(t, ok, "Clear must remove a PinEntry that started in the retiring generation") + require.False(t, ok, "Reset must remove a PinEntry that started in the retiring generation") } // TestBranchCache_Stats verifies the format of the stats string is diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 3a0785b314e..082d76a2891 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -71,20 +71,16 @@ type BranchUpdate struct { // BranchPublisher is the canonical mutation handle for BranchCache. type BranchPublisher struct { - c *BranchCache - generation cache.GenerationPublisher + c *BranchCache } // Publisher returns a handle that can publish durable branch generations. func (c *BranchCache) Publisher() BranchPublisher { - if c == nil { - return BranchPublisher{} - } - return BranchPublisher{c: c, generation: c.generation.Publisher()} + return BranchPublisher{c: c} } func (p BranchPublisher) Enabled() bool { - return p.c != nil && p.generation.Enabled() + return p.c != nil } // Initialize binds the cache to generation. A mismatch clears branches and @@ -93,7 +89,7 @@ func (p BranchPublisher) Initialize(generation cache.Generation) { if p.c == nil { return } - p.generation.Initialize(generation, p.c.resetProvenanceAndClear) + p.c.generation.Publisher().Initialize(generation, p.c.resetProvenanceAndClear) } // BranchPublication represents one pending durable branch transition. @@ -107,7 +103,7 @@ func (p BranchPublisher) Begin() *BranchPublication { if p.c == nil { return nil } - return &BranchPublication{c: p.c, generation: p.generation.Begin()} + return &BranchPublication{c: p.c, generation: p.c.generation.Publisher().Begin()} } // Abort restores the previous branch generation after database rollback. diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 5521362584e..c1e7a3e8afd 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -57,16 +57,13 @@ func newTestStateCache(t *testing.T) *cache.StateCache { return sc } -func currentCacheView(t *testing.T, sc *cache.StateCache) cache.ReadView { - t.Helper() - stateVersion, ok := sc.CurrentStateVersion() - require.True(t, ok) +func cacheView(sc *cache.StateCache, stateVersion uint64) cache.ReadView { return sc.View(cache.StateGeneration(stateVersion, 0, 0, 0)) } func seedFill(t *testing.T, sc *cache.StateCache, domain kv.Domain, k, v []byte, step kv.Step) { t.Helper() - currentCacheView(t, sc).Fill(domain, k, v, step) + cacheView(sc, 1).Fill(domain, k, v, step) } func TestBlockReadAheaderCarriesBlockAccessList(t *testing.T) { @@ -95,13 +92,13 @@ func TestCachePopulatingGetterKeepsFresherEntry(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache(t) seedFill(t, sc, domain, key, fresh, 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, view: currentCacheView(t, sc)} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: stale}, view: cacheView(sc, 1)} v, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) require.Equal(t, stale, v, "read-through must still return the view's value") - got, ok := currentCacheView(t, sc).Get(domain, key) + got, ok := cacheView(sc, 1).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, fresh, got, "domain %s: warmup must not clobber the fresher entry", domain) } @@ -115,12 +112,12 @@ func TestCachePopulatingGetterKeepsFresherCodeBinding(t *testing.T) { staleCode := []byte{0xbb, 0x04, 0x05, 0x06} sc := newTestStateCache(t) seedFill(t, sc, kv.CodeDomain, addr, freshCode, 54) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, view: currentCacheView(t, sc)} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: staleCode}, view: cacheView(sc, 1)} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) - got, ok := currentCacheView(t, sc).Get(kv.CodeDomain, addr) + got, ok := cacheView(sc, 1).Get(kv.CodeDomain, addr) require.True(t, ok) require.Equal(t, freshCode, got, "warmup must not rebind addr to older code") } @@ -133,31 +130,31 @@ func TestCachePopulatingGetterWarmsColdKeys(t *testing.T) { for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain} { sc := newTestStateCache(t) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, view: currentCacheView(t, sc)} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: val}, view: cacheView(sc, 1)} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) - got, ok := currentCacheView(t, sc).Get(domain, key) + got, ok := cacheView(sc, 1).Get(domain, key) require.True(t, ok, "domain %s", domain) require.Equal(t, val, got, "domain %s", domain) } sc := newTestStateCache(t) - cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, view: currentCacheView(t, sc)} + cpg := &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: code}, view: cacheView(sc, 1)} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) - got, ok := currentCacheView(t, sc).Get(kv.CodeDomain, key) + got, ok := cacheView(sc, 1).Get(kv.CodeDomain, key) require.True(t, ok) require.Equal(t, code, got) - got, ok = currentCacheView(t, sc).GetCodeByHash(crypto.Keccak256(code)) + got, ok = cacheView(sc, 1).GetCodeByHash(crypto.Keccak256(code)) require.True(t, ok) require.Equal(t, code, got) // Negative results (missing account, empty slot) are cached as nil hits. sc = newTestStateCache(t) - cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, view: currentCacheView(t, sc)} + cpg = &cachePopulatingGetter{TemporalGetter: stubTemporalGetter{v: nil}, view: cacheView(sc, 1)} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - got, ok = currentCacheView(t, sc).Get(kv.AccountsDomain, key) + got, ok = cacheView(sc, 1).Get(kv.AccountsDomain, key) require.True(t, ok) require.Empty(t, got) } @@ -167,15 +164,15 @@ func TestCachePopulatingGetterNegativeClearedByPublication(t *testing.T) { sc := newTestStateCache(t) cpg := &cachePopulatingGetter{ TemporalGetter: stubTemporalGetter{v: nil}, - view: currentCacheView(t, sc), + view: cacheView(sc, 1), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) + _, ok := cacheView(sc, 1).Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Publisher().Clear(cache.StateGeneration(2, 0, 0, 0)) - _, ok = currentCacheView(t, sc).Get(kv.AccountsDomain, key) + sc.Publisher().Begin().Publish(cache.StateGeneration(2, 0, 0, 0), nil, true) + _, ok = cacheView(sc, 2).Get(kv.AccountsDomain, key) require.False(t, ok) } @@ -188,7 +185,7 @@ func TestCachePopulatingGetterInertViewNeverFills(t *testing.T) { } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) + _, ok := cacheView(sc, 1).Get(kv.AccountsDomain, key) require.False(t, ok) } @@ -197,13 +194,13 @@ func TestCachePopulatingGetterStaleViewDoesNotFill(t *testing.T) { sc := newTestStateCache(t) cpg := &cachePopulatingGetter{ TemporalGetter: stubTemporalGetter{v: []byte("pre-delete-record")}, - view: currentCacheView(t, sc), + view: cacheView(sc, 1), } publication := sc.Publisher().Begin() publication.Publish(cache.StateGeneration(2, 0, 0, 0), nil, false) _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := currentCacheView(t, sc).Get(kv.AccountsDomain, key) + _, ok := cacheView(sc, 2).Get(kv.AccountsDomain, key) require.False(t, ok) } From fb7c61fd33f25bc0f072f12ae643f3f202dced9a Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:19:54 +0200 Subject: [PATCH 21/50] exec, execctx: share state cache view derivation --- db/state/execctx/domain_shared.go | 58 ++++++++++++++++++++++------- execution/exec/blocks_read_ahead.go | 31 ++++----------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 1ac1c95fb8f..40b799b90c5 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -119,31 +119,61 @@ func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { } } debug := tx.Debug() - generations := cacheGenerationsFor(debug, stateVersion) - stateEligible := cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) - branchEligible := cacheViewEligible(debug, kv.CommitmentDomain) var views cacheViews - if sd.stateCache != nil && stateEligible { - views.state = sd.stateCache.View(generations.state) + if stateView, identityKnown := stateCacheReadViewFor(debug, stateVersion, sd.stateCache); identityKnown { + views.state = stateView } - if sd.branchCache != nil && branchEligible { - views.branch = sd.branchCache.View(generations.branch) + if sd.branchCache != nil && cacheViewEligible(debug, kv.CommitmentDomain) { + views.branch = sd.branchCache.View(branchCacheGenerationFor(debug, stateVersion)) } return views } +// StateCacheReadView binds stateCache to the state version and files ends pinned +// by tx. It returns false if the cache is nil or that exact identity cannot be +// derived. Even when true, the view is inert if the identity is not published. +func StateCacheReadView(tx kv.TemporalTx, stateCache *cache.StateCache) (view cache.ReadView, identityKnown bool) { + if tx == nil || stateCache == nil { + return cache.ReadView{}, false + } + stateVersion, err := rawdb.GetStateVersion(tx) + if err != nil { + return cache.ReadView{}, false + } + return stateCacheReadViewFor(tx.Debug(), stateVersion, stateCache) +} + +func stateCacheReadViewFor( + debug kv.TemporalDebugTx, + stateVersion uint64, + stateCache *cache.StateCache, +) (cache.ReadView, bool) { + if stateCache == nil || !cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) { + return cache.ReadView{}, false + } + return stateCache.View(stateCacheGenerationFor(debug, stateVersion)), true +} + func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) cacheGenerations { return cacheGenerations{ - state: cache.StateGeneration( - stateVersion, - debug.TxNumsInFiles(kv.AccountsDomain), - debug.TxNumsInFiles(kv.StorageDomain), - debug.TxNumsInFiles(kv.CodeDomain), - ), - branch: cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)), + state: stateCacheGenerationFor(debug, stateVersion), + branch: branchCacheGenerationFor(debug, stateVersion), } } +func stateCacheGenerationFor(debug kv.TemporalDebugTx, stateVersion uint64) cache.Generation { + return cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) +} + +func branchCacheGenerationFor(debug kv.TemporalDebugTx, stateVersion uint64) cache.Generation { + return cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) +} + type exactDomainVisibleEnd interface { HasExactDomainVisibleEnd(domain kv.Domain) bool } diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 099861d044c..6a13d5defce 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -17,7 +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/db/state/execctx" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" @@ -91,32 +91,15 @@ type cachePopulatingGetter struct { view cache.ReadView } -// readAheadGetter enables fills only when the transaction has an exact domain -// frontier. StateCache.View also requires the transaction's state version and -// pinned files ends to match the published generation. Failure of either check -// keeps read-ahead useful for the OS page cache without admitting unsafe values -// into StateCache. +// readAheadGetter uses StateCache only when the transaction's durable state and +// pinned files form an exact identity. Returning the transaction unchanged on +// uncertainty still lets read-ahead warm the database and OS page cache. func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { - if sc == nil { + view, identityKnown := execctx.StateCacheReadView(ttx, sc) + if !identityKnown { return ttx } - stateVersion, err := rawdb.GetStateVersion(ttx) - if err != nil { - return ttx - } - debug := ttx.Debug() - for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { - if !debug.HasExactDomainVisibleEnd(domain) { - return ttx - } - } - generation := cache.StateGeneration( - stateVersion, - debug.TxNumsInFiles(kv.AccountsDomain), - debug.TxNumsInFiles(kv.StorageDomain), - debug.TxNumsInFiles(kv.CodeDomain), - ) - return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(generation)} + return &cachePopulatingGetter{TemporalGetter: ttx, view: view} } func (cpg *cachePopulatingGetter) GetLatest(name kv.Domain, k []byte) ([]byte, kv.Step, error) { From 0dd480f6be9396c89fd50c267ef105b85260cd5e Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:03:06 +0200 Subject: [PATCH 22/50] execution, db: clarify cache coherence comments --- cmd/integration/commands/stages.go | 11 +-- db/kv/temporal/kv_temporal.go | 10 +-- db/state/aggregator.go | 59 ++++++++------- db/state/execctx/branch_cache_flush_test.go | 2 +- db/state/execctx/codehash_routing_test.go | 9 +-- db/state/execctx/domain_shared.go | 71 ++++++++----------- db/state/execctx/export_test.go | 7 +- db/state/execctx/flush_storage_cache_test.go | 16 ++--- db/state/execctx/options.go | 6 +- .../execctx/statecache_readfill_bench_test.go | 2 +- execution/cache/cache_test.go | 4 +- execution/cache/code_cache.go | 9 +-- .../cache/code_cache_concurrency_test.go | 2 +- execution/cache/generation_gate.go | 9 ++- execution/cache/generic_cache.go | 22 +++--- .../cache/generic_cache_concurrency_test.go | 15 ++-- execution/cache/state_cache.go | 11 +-- execution/cache/view.go | 9 +-- execution/commitment/branch_cache.go | 30 ++++---- execution/commitment/branch_cache_test.go | 4 +- execution/commitment/branch_cache_view.go | 8 ++- execution/commitment/hex_patricia_hashed.go | 5 +- execution/exec/blocks_read_ahead.go | 24 ++----- execution/exec/blocks_read_ahead_test.go | 7 +- execution/execmodule/exec_module.go | 55 +++++--------- execution/execmodule/forkchoice.go | 28 ++++---- execution/execmodule/set_head.go | 4 +- 27 files changed, 201 insertions(+), 238 deletions(-) diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index cb58231c1fc..a36bc47b95f 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -825,11 +825,12 @@ func commitExecUnwind(ctx context.Context, doms *execctx.SharedDomains, tx kv.Te // execBlocksBatch runs one stage_exec batch in its own rwtx and SharedDomains: // exec up to toBlock (or the batch limit), then doms.Commit. Commit (not Flush) -// refreshes the aggregator BranchCache to match committed state — a stale cache -// makes the next batch compute a wrong trie root — and commits the tx. A fresh -// SharedDomains per call avoids reusing a committed (spent) one. Pruning and -// file-building are the caller's job (agg.CollateAndPrune). Returns the Execution -// stage progress after the batch. +// publishes StateCache and BranchCache only after the database commit and +// consumes the transaction. BranchCache must match durable state because the +// next batch uses its branches to compute the trie root. A fresh SharedDomains +// per call avoids reusing the spent transaction. Pruning and file building are +// the caller's job. The function returns Execution stage progress after the +// batch. func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Sync, cfg stagedsync.ExecuteBlockCfg, toBlock uint64, initialCycle bool, stateCache *cache.StateCache, codeStore *cache.CodeStore, logger log.Logger) (uint64, error) { tx, err := db.BeginTemporalRw(ctx) if err != nil { diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 05a9b6d6b93..dba6c96f22d 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -277,13 +277,9 @@ type RwTx struct { } type domainVisibleEnds struct { - // ends is atomic so a lock-free read can overlap a reset-and-reload of - // the same slot without a data race. A torn read (state bit from one - // generation, end from another) can only be stale-low, which merely - // over-rejects fills: a view's frontier never decreases in a process that - // fills a cache — the DB component is frozen at tx begin, and a files - // reopen only extends it. The aggregator rejects visibility lowering while - // a shared latest-state cache is in use. + // ends is atomic because a lock-free read may overlap reset after the files + // transaction reopens. state publishes a slot only after its end is stored; + // reset takes mu so an in-flight load cannot republish old data. ends [kv.DomainLen]atomic.Uint64 mu sync.Mutex state atomic.Uint32 diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 567ce245413..acb561c2e20 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -91,9 +91,9 @@ type Aggregator struct { // regenerates them. Guarded by dirtyFilesLock. unalignedDomain [kv.DomainLen]bool unalignedIdx [kv.StandaloneIdxLen]bool - // Cache reconciliation assumes that visible ends only advance. Lowering one - // could retain an entry that existed only in the newer files view. Close - // clears this guard because shutdown is not a cache-read window. + // Cache provenance and exact-view eligibility assume that visible values and + // history-II ends only advance. Close clears this guard because shutdown is + // not a cache-read window. visibilityLoweringForbidden atomic.Bool // boundStateCache is reconciled before a new files view becomes visible. // Guarded by dirtyFilesLock. @@ -551,11 +551,11 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// ForbidVisibilityLowering marks this aggregator as backing a shared latest -// state cache. From then on recalcVisibleFiles rejects lowering a cached -// domain's visible end because cache file-provenance watermarks only advance. -// Serialized with recalcVisibleFiles via dirtyFilesLock so "from then on" -// holds against a recalculation already in flight. +// ForbidVisibilityLowering marks this aggregator as backing shared latest-state +// caches. It rejects lowering values-file ends because cache provenance only +// advances, and history-II ends because they determine exact cache-view +// eligibility. dirtyFilesLock orders the guard with a recalculation already in +// progress. func (a *Aggregator) ForbidVisibilityLowering() { if a.visibilityLoweringForbidden.Load() { return @@ -743,18 +743,18 @@ func (a *Aggregator) ReloadFiles() error { return a.openFolder() } -// closeDirtyFilesNoReopen drops all dirty-file mmaps without re-scanning the -// snapshots dir, so a caller can rename the underlying files (Windows forbids -// renaming a mapped file); a later ReloadFiles re-opens them. -// closeDirtyFilesNoReopen is an exclusive tooling operation: it temporarily -// removes all visible files and invalidates cache guarantees tied to them. +// closeDirtyFilesNoReopen drops all dirty-file mappings without rescanning the +// snapshots directory, allowing tooling to rename files on platforms that +// forbid renaming mapped files. It temporarily publishes an empty files view +// and revokes cache views tied to the old one; ReloadFiles opens the +// replacements. func (a *Aggregator) closeDirtyFilesNoReopen() { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() loweringWasForbidden := a.visibilityLoweringForbidden.Swap(false) defer a.visibilityLoweringForbidden.Store(loweringWasForbidden) - // This path removes every visible file before replacing them, so no cache - // view may remain live across the reset. + // A lower file end normally retains committed entries. This tooling path + // removes every file, so discard BranchCache entries and provenance first. if cd := a.d[kv.CommitmentDomain]; cd != nil && cd.branchCache != nil { cd.branchCache.Reset() } @@ -1967,7 +1967,10 @@ func (p *cacheFilesPublication) Finish() { // a fresh immutable aggregatorVisible bundle via the per-entity calcVisibleFiles // helpers, then publishes the completed snapshot with a.visible.Store(next). // Per-entity visibility is not mutated; readers atomically observe one -// cross-entity-consistent generation. +// cross-entity-consistent generation. When the files identity changes, cache +// views tied to the old files are revoked before the store, and the matching +// identities are published after it. A cache generation is therefore never +// backed by files that are not yet visible. func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { toTxNum := a.dirtyFilesEndTxNumMinimax() next := &aggregatorVisible{} @@ -2002,7 +2005,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevII := prev.dhii[d].files.EndTxNum() nextII := next.dhii[d].files.EndTxNum() if nextII < prevII { - panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a shared cache is wired — exact cache-view eligibility derives its frontier from history-II", d, prevII, nextII)) + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a shared cache is wired — exact cache-view eligibility depends on history-II coverage", d, prevII, nextII)) } } } @@ -2016,8 +2019,8 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { a.visible.Store(next) cachePublication.Finish() - // `recalcVisibleFiles` is rare background operation under `dirtyFilesLock` - // it's good idea to delete files here, then hot reader-Close path will more likely be lock-free + // Reclamation is cheap here under dirtyFilesLock and keeps the hot reader + // Close path likely to remain lock-free. reclaimFiles(a.reclaimRetiredLocked()) } @@ -2779,29 +2782,31 @@ func (at *AggregatorRoTx) standaloneIIs() []*InvertedIndexRoTx { return at.iis[: func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { d := at.d[name] if d.d.HistoryDisabled { - // this is not accurate, okay for reporting... - // if historyDisabled, there's no way to get progress in - // terms of exact txNum + // Without history there is no exact txNum progress, so reporting uses + // the latest database step as an approximation. return at.d[name].d.maxStepInDBNoHistory(tx).ToTxNum(at.a.stepSize.Load()) } return at.d[name].ht.iit.Progress(tx) } + +// HasExactDomainVisibleEnd reports whether an exact combined frontier exists. +// History must be enabled and values files must cover the history-II frontier; +// otherwise reads mix newer database keys with older file values. func (at *AggregatorRoTx) HasExactDomainVisibleEnd(name kv.Domain) bool { d := at.d[name] return !d.d.HistoryDisabled && d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() } + +// DomainVisibleEnd returns the exact combined frontier after verifying that +// the values files cover history-II. func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { - // A dependency checker can clamp the values view below the history-II end. - // Such a view has no exact frontier: reads mix fresh DB-resident keys with - // older file values for the gap, and raising the dependent file's - // visibility later reveals state without any cache apply — a fill made - // during the clamp would never be invalidated. if !at.HasExactDomainVisibleEnd(name) { return 0, false } d := at.d[name] return d.ht.iit.visibleEnd(tx), true } + func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { return at.searchII(name).Progress(tx) } diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index c830681f0e9..dd53528ec3e 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -44,7 +44,7 @@ func branchGenerationForTx(t *testing.T, tx kv.TemporalTx) cache.Generation { return cache.BranchGeneration(stateVersion, tx.Debug().TxNumsInFiles(kv.CommitmentDomain)) } -// Use Commit (not Flush) so the rebuilt branch refreshes the BranchCache entry. +// Commit, unlike Flush, publishes the rebuilt branch after the database commit. func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { stepSize := uint64(100) db := newTestDb(t, stepSize) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index a5c6bdadf00..3ca7cb5b429 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -13,8 +13,9 @@ import ( "github.com/erigontech/erigon/execution/types/accounts" ) -// Pins that an in-batch account write overrides a stale addr→codeHash LRU entry -// (the LRU caches committed state and is invalidated only at flush). +// Pins that an in-batch account write overrides a stale addr→codeHash LRU entry. +// The LRU caches committed state and is invalidated when the account update is +// published. func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { if testing.Short() { t.Skip() @@ -119,8 +120,8 @@ func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { require.Equal(t, [32]byte(codeHash), h) } -// A record read from the tx's read view (accounts-cache miss) is exactly what -// the admission gate vouches for, so it still seeds the mapping. +// A record read from the transaction's exact generation can safely seed the +// derived addr→codeHash mapping. func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { t.Parallel() diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 40b799b90c5..b25dcf933b3 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -129,7 +129,7 @@ func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { return views } -// StateCacheReadView binds stateCache to the state version and files ends pinned +// StateCacheReadView binds stateCache to the state version and file ends pinned // by tx. It returns false if the cache is nil or that exact identity cannot be // derived. Even when true, the view is inert if the identity is not published. func StateCacheReadView(tx kv.TemporalTx, stateCache *cache.StateCache) (view cache.ReadView, identityKnown bool) { @@ -226,18 +226,13 @@ type SharedDomains struct { mem kv.TemporalMemBatch metrics kvmetrics.DomainMetrics - // blockOverlay is an in-memory overlay for block-level metadata writes (headers, bodies, - // canonical hashes, TD, stage progress, forkchoice markers). It allows execution to - // operate without holding an RwTx — writes accumulate here and are flushed atomically - // alongside domain state via Flush(). - // Atomic because concurrent readers (RPC via LatestSD) may call BlockOverlay() - // while Close() nils the pointer. + // blockOverlay accumulates block metadata while execution holds only a read + // transaction. It is flushed atomically with domain state. The pointer is + // atomic because concurrent readers may load it while Close clears it. blockOverlay atomic.Pointer[membatchwithdb.MemoryMutation] - // parent is an optional parent SD for read-through chaining. When set, - // domain reads that miss in the local mem batch fall through to the parent's - // mem batch before consulting the underlying tx. Used by the block builder - // to read from the FCU's published SD without writing to it. + // parent is an optional read-through chain for uncommitted domain state and + // accumulated diffsets. A child reads but never writes its parent. parent *SharedDomains // stateCache provides generation-bound reads and fills. statePublisher is set @@ -773,11 +768,9 @@ func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumb if ok || err != nil { return d, ok, err } - // Resolve through the parent chain: a fork-validation SD is freshly - // constructed with an empty mem batch, so the diffsets of the canonical - // blocks it must unwind live in the canonical generation's - // pastChangesAccumulator, reachable only via the parent link. Without - // this an unwind silently runs with no unwind set. + // A child can have no local history while its parent's overlay holds the + // canonical diffset needed for unwind. Continue through the chain before + // reporting a miss. if sd.parent != nil { return sd.parent.GetDiffset(tx, blockHash, blockNumber) } @@ -787,10 +780,10 @@ func (sd *SharedDomains) GetDiffset(tx kv.RwTx, blockHash common.Hash, blockNumb // Unwind drops [txNumUnwindTo, ∞) func (sd *SharedDomains) Unwind(txNumUnwindTo uint64, changeset *[kv.DomainLen][]kv.DomainEntryDiff) { sd.mem.Unwind(txNumUnwindTo, changeset) - // The global caches still describe the durable database until Commit. + // The process-global caches still describe the durable database until Commit. // Detaching keeps this rewound overlay from reading or filling that version. - // If the overlay is committed, both caches are cleared because the unwind - // diff is not a complete inventory of entries from the discarded fork. + // If a canonical owner commits the overlay, both caches are cleared because + // the unwind diff is not a complete inventory of discarded-fork entries. sd.stateCache = nil sd.branchCache = nil sd.clearExecutionCaches = true @@ -1007,8 +1000,8 @@ func (sd *SharedDomains) Close() { } // Flush writes the in-memory batch without committing or publishing cache -// updates. A canonical SharedDomains must use Commit so the database and cache -// become visible in that order. +// updates. A SharedDomains with canonical publication authority must use Commit +// so the database and cache become visible in that order. func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { defer mxFlushTook.ObserveDuration(time.Now()) return sd.flushMem(ctx, tx) @@ -1345,9 +1338,9 @@ func (sd *SharedDomains) getLatestMetered(domain kv.Domain, tx kv.TemporalTx, k } } - // branchCache sits between sd.mem/parent.mem and the aggTx files for - // CommitmentDomain only. Snapshot-isolated readers must disable it because - // concurrent commits can advance the cache beyond their transaction view. + // branchCache sits between the memory overlays and aggregator storage for + // CommitmentDomain. Its generation-bound view turns a publication outside + // this transaction's snapshot into a miss. if domain == kv.CommitmentDomain && sd.branchCache != nil { if cv, cStepU64, ok := views.branch.Get(k); ok { // Get returns the on-disk step index directly — do NOT divide by @@ -1503,10 +1496,10 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if len(addr) == 0 { return nil } - // In-batch state is authoritative: sd.mem / parent.mem hold this batch's - // uncommitted account writes, while the addr→codeHash LRU is invalidated only - // on flush. Route mem-first; the LRU is a committed-state layer that may only - // answer once mem has missed. + // In-batch state is authoritative: the memory overlays hold uncommitted + // account writes. The LRU contains committed state and is invalidated when + // the corresponding account update is published, so it may answer only after + // the overlays miss. if v, _, ok := sd.mem.GetLatest(kv.AccountsDomain, addr); ok { return accounts.DeserialiseV3CodeHash(v) } @@ -1516,9 +1509,8 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, } } - // Below mem: the addr → codeHash LRU caches committed state - // (flush-invalidated). The zero-hash sentinel means "no code / missing - // account" (negative cache). + // The zero-hash sentinel is the negative-cache entry for an account with no + // code or no account. if sd.stateCache != nil { if h, ok := view.GetAddrCodeHash(addr); ok { if h == ([32]byte{}) { @@ -1552,6 +1544,8 @@ func (sd *SharedDomains) codeHashForAddr(tx kv.TemporalTx, view cache.ReadView, if len(h) == 32 { copy(fixed[:], h) } + // A generation-bound account result can safely seed this derived mapping: + // publication revokes the view before changing either cache layer. view.SeedAddrCodeHash(addr, fixed) } return h @@ -1681,12 +1675,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. + // This write remains in sd.mem, which every read checks before the shared + // caches. Only a successful canonical Commit publishes it, so speculative + // or fork-local writes cannot enter a sibling transaction's cache. // Serialize against the calculator's accumulator-swap window — see // changesetMu doc on the SharedDomains struct. Skipped when the caller @@ -1743,9 +1734,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. + // Like DomainPut, this deletion remains in sd.mem until a successful + // canonical Commit publishes it. Serialize against the calculator's swap + // window for non-commitment domains; see DomainPut for the locking invariant. 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 87a86fd7aca..bba1d175bb8 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -12,10 +12,9 @@ func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui return sd.codeHashForAddr(tx, sd.cacheViewsFor(tx).state, addr) } -// SetStateCacheForTest attaches a cache unconditionally, bypassing the -// USE_STATE_CACHE env gate that SetStateCache honors. Cache-behavior tests use -// 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). +// SetStateCacheForTest attaches canonical cache capability without the +// USE_STATE_CACHE gate used by SetCanonicalStateCache and SetStateCacheReader. +// It avoids changing the process-wide flag in parallel tests. func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { if !sd.clearExecutionCaches { sd.stateCache = sc diff --git a/db/state/execctx/flush_storage_cache_test.go b/db/state/execctx/flush_storage_cache_test.go index 25752fdd2aa..a2fef6c61e7 100644 --- a/db/state/execctx/flush_storage_cache_test.go +++ b/db/state/execctx/flush_storage_cache_test.go @@ -27,14 +27,14 @@ import ( "github.com/erigontech/erigon/execution/cache" ) -// Pins that Commit fires the StorageDomain flush-callback so the storage cache -// is refreshed. Storage lives in a separate btree (sd.storage), not sd.domains, -// so the callback loop must cover it — else a cached slot serves a stale value. +// Pins that Commit fires the StorageDomain flush callback so the storage cache +// is refreshed. Storage lives in a separate btree, so the callback loop must +// cover it or a cached slot can retain a stale value. // -// The caches are commit-gated: they are populated only after tx.Commit succeeds -// (Commit stashes the flush-callback tuples and applies them post-commit), never -// by a bare Flush. The test commits a slot, then commits an overwrite in a second -// tx, and asserts the module-scope cache reflects the second write, not the first. +// Authoritative cache updates are commit-gated: Commit stashes callback tuples +// and publishes them only after tx.Commit succeeds. A bare Flush cannot expose +// uncommitted values. The test commits a slot, then commits an overwrite and +// checks that the shared cache reflects the second write. func TestCommit_UpdatesStorageStateCache(t *testing.T) { if testing.Short() { t.Skip() @@ -70,7 +70,7 @@ func TestCommit_UpdatesStorageStateCache(t *testing.T) { sd.SetTxNum(txNum) require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTx, key, val, txNum, prevVal)) - // Commit commits rwTx and only then applies the flush-callback tuples to + // Commit commits rwTx and only then publishes the flush-callback tuples to // the cache. The StorageDomain callback must fire (iterate sd.storage). require.NoError(t, sd.Commit(ctx, rwTx)) } diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 85ae5e31034..850d05e1562 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -36,9 +36,9 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false } } -// WithoutSharedBranchCache keeps commitment reads within the transaction -// snapshot. Use it when tooling intentionally lowers or rebuilds the -// commitment-file frontier. +// WithoutSharedBranchCache disables BranchCache use and the monotonic +// visibility guard it requires. Use it when tooling intentionally lowers or +// rebuilds the commitment-file frontier. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 42a23ce1338..47bcf08b3b4 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -89,7 +89,7 @@ func benchColdNegativeReads(b *testing.B, withCache, writable bool) { func BenchmarkGetLatestColdNegative(b *testing.B) { benchColdNegativeReads(b, true, false) } -// The baseline for the generation check and fill. +// Baseline without StateCache generation checks or fills. func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { benchColdNegativeReads(b, false, false) } diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 81ab8a795c7..5d982603bd8 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -752,7 +752,7 @@ func TestCodeCache_PutWithCodeHashIfAbsent(t *testing.T) { // A conditional put must be atomic w.r.t. a concurrent unconditional Put of // the same key: without a shared critical section the conditional writer can // check (absent), lose the CPU to the authoritative writer's insert, then -// clobber it — the prefetch-vs-flush staleness this cache guards against. +// clobber it. The shared stripe makes the conditional update atomic. func TestDomainCache_PutIfAbsentAtomicWithPut(t *testing.T) { c := closeOnCleanup(t, NewDomainCacheMode(1*datasize.MB, ModeEvictLRU)) fresh := []byte("fresh") @@ -922,7 +922,7 @@ func TestDomainCache_DeleteAtomicWithPut_NoSizeDrift(t *testing.T) { } // A Clear racing a put must not leave phantom bytes: unless Clear excludes -// writers via the put stripes, a put that loaded the retiring generation +// writers via the put stripes, a put that loaded the retiring LRU // lands its entry where no reader sees it and adds the entry's size after // Clear zeroed the counter — inflating SizeBytes for an invisible entry. func TestDomainCache_ClearAtomicWithPut_NoSizeDrift(t *testing.T) { diff --git a/execution/cache/code_cache.go b/execution/cache/code_cache.go index 12e2a905dbb..62f76a43dc1 100644 --- a/execution/cache/code_cache.go +++ b/execution/cache/code_cache.go @@ -146,8 +146,8 @@ type CodeCache struct { // insertion per key hash: freelru has no LoadOrStore, so without this two // concurrent Puts of the same cold code both miss the check and both add to // the byte counter while only one entry survives, drifting the stat upward. - // Clear uses every stripe to fence publications while distinct keys still - // write in parallel. + // A full clear takes every stripe so no put crosses it; puts to distinct keys + // remain parallel otherwise. putStripes [256]sync.Mutex // Stats counters (atomic for concurrent access) @@ -269,7 +269,7 @@ func (c *CodeCache) Put(addr []byte, code []byte, step kv.Step) { } // PutIfAbsent implements Cache.PutIfAbsent for the addr→code binding; the -// content-addressed layers skip live entries regardless. +// content-addressed layers skip existing entries regardless. func (c *CodeCache) PutIfAbsent(addr []byte, code []byte, step kv.Step) { c.putCode(addr, code, [32]byte{}, step, false) } @@ -489,7 +489,8 @@ func (c *CodeCache) Delete(addr []byte) { c.addrBindMu.Unlock() } -// Clear removes every layer and resets accounting. +// Clear removes every layer and resets accounting. It holds every put stripe +// so no put can cross the multi-layer clear. func (c *CodeCache) Clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() diff --git a/execution/cache/code_cache_concurrency_test.go b/execution/cache/code_cache_concurrency_test.go index 2271721303e..0fefe86b9cc 100644 --- a/execution/cache/code_cache_concurrency_test.go +++ b/execution/cache/code_cache_concurrency_test.go @@ -168,5 +168,5 @@ func TestCodeCache_ClearFencesStartedPut(t *testing.T) { wg.Wait() _, ok := cc.Get(addr) - require.False(t, ok, "Clear must remove a write that started in the retiring generation") + require.False(t, ok, "Clear must remove a write that started against the old LRU") } diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 690558e50bf..7bd1d793aca 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -215,7 +215,10 @@ func (p *GenerationPublication) Abort() { p.gate = nil } -// Publish applies the committed cache transition before exposing identity. +// Publish applies the committed cache transition before exposing identity. The +// state version comes from identity, while the files view is replaced by the +// newest backing view known to the gate. This prevents a transaction opened +// before a files publication from restoring its older files identity. func (p *GenerationPublication) Publish(identity Generation, apply func()) { if p == nil || p.gate == nil { return @@ -322,8 +325,8 @@ func (c *BackingChange) Finish() { c.gate = nil } -// Close permanently revokes current views. The owner may then close its cache -// storage without admitting new fills. +// Close waits for in-flight fills and revokes current views before the owner +// closes cache storage. func (g *GenerationGate) Close() { if g == nil { return diff --git a/execution/cache/generic_cache.go b/execution/cache/generic_cache.go index 1d3ec3ca92d..da0ba02e11f 100644 --- a/execution/cache/generic_cache.go +++ b/execution/cache/generic_cache.go @@ -60,9 +60,8 @@ type entry[T any] struct { // data. Eviction mode is fixed at construction (see policy.go). type GenericCache[T any] struct { // data is the sharded LRU, replaced wholesale only with every put stripe - // held — on a jump-grow (fully copied generation) and on Clear (fresh - // empty one) — so no write lands in a retired generation and no reader - // sees a partial copy (see maybeGrow, Clear). + // held: on a jump-grow with a complete copy, and on Clear with a fresh empty + // LRU. No write can land in a retired LRU, and no reader sees a partial copy. data atomic.Pointer[freelru.ShardedLRU[uint64, entry[T]]] capacityB datasize.ByteSize mode Mode @@ -81,9 +80,9 @@ type GenericCache[T any] struct { resizeMu sync.Mutex reservedBytes int64 - // shardCount is the live generation's freelru shard count, bounded by + // shardCount is the current LRU's freelru shard count, bounded by // shardCeil (freelru's own GOMAXPROCS-derived choice). Left to freelru, a - // grown generation could pick more, smaller shards and evict entries during + // grown LRU could pick more, smaller shards and evict entries during // the migration copy; instead shards double across grows only while // per-shard capacity does not shrink (see maybeGrow). Mutated under resizeMu. shardCount uint32 @@ -205,12 +204,11 @@ func (c *GenericCache[T]) newShards(capacity, shards uint32) *freelru.ShardedLRU // LRU keeps its size and freelru evicts within it. Must not be called with a // stripe held (it takes them all). // -// The copy runs with every put stripe held: writers (and the striped -// stale-drop) are excluded, so no write can land in the generation being -// retired and a conditional put never sees a mid-resize gap it could fill -// with a stale value; readers stay on the retiring generation until the swap -// and never miss. Grows are a handful of steps per cache lifetime, so the -// writer stall is a bounded one-off. +// The copy runs with every put stripe held, excluding writers and deletes. +// No write can land in the retired LRU, a conditional put cannot observe a +// mid-resize gap, and readers stay on the old LRU until the atomic swap. +// Grows are a handful of steps per cache lifetime, so the writer stall is a +// bounded one-off. func (c *GenericCache[T]) maybeGrow() { c.resizeMu.Lock() defer c.resizeMu.Unlock() @@ -419,7 +417,7 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, overwrite bool) bool { // Delete removes the data for the given key. Runs under the key's put stripe // so the check-then-remove is atomic against same-key puts and excluded from -// generation swaps (maybeGrow, Clear), which fence via the stripes. +// LRU swaps (maybeGrow and Clear), which fence via the stripes. func (c *GenericCache[T]) Delete(key []byte) { h := maphash.Hash(key) mu := &c.putStripes[h&(putStripeCount-1)] diff --git a/execution/cache/generic_cache_concurrency_test.go b/execution/cache/generic_cache_concurrency_test.go index 4ad8bbb3597..4c0a742906c 100644 --- a/execution/cache/generic_cache_concurrency_test.go +++ b/execution/cache/generic_cache_concurrency_test.go @@ -125,15 +125,12 @@ func TestGenericCache_PutNotLostAcrossGrow(t *testing.T) { } } -// A conditional put must keep deferring to a live entry across a grow. The -// vulnerable writer class: a put of a brand-new key that lands in the -// retiring generation after the copy snapshotted Keys() is lost on the swap, -// and a follow-up PutIfAbsent finds the key absent and installs its stale -// value as live. With the fence the put either lands pre-fence (and is -// migrated — Keys() is taken with every stripe held) or lands in the new -// generation; either way the conditional put defers. +// A conditional put must keep deferring to an existing entry across a grow. +// Without the fence, a new key written to the old LRU after the copy is lost +// during the swap, allowing a later PutIfAbsent to install its stale value. +// With the fence, the write is either copied or lands in the new LRU. // -// A writer hammers fresh keys while the grow swaps generations; every key +// A writer hammers fresh keys while the grow swaps LRUs; every key // that straddled the swap is then probed with a stale conditional put. The // grow is forced by lowering curCap over a lightly-populated cache, so // capacity eviction cannot explain a missing key. @@ -183,7 +180,7 @@ func TestGenericCache_PutIfAbsentDefersAcrossGrow(t *testing.T) { v, ok := c.Get(k) require.True(t, ok, "round %d: candidate %d missing", round, i) require.Equal(t, fresh, v, - "round %d: candidate %d: PutIfAbsent installed a stale value over a put lost in the retiring generation", round, i) + "round %d: candidate %d: PutIfAbsent installed a stale value over a put lost in the retired LRU", round, i) } c.Close() } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index f11092078bc..23a758bdd06 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -38,7 +38,7 @@ const ( // StateCache holds account, storage, and code values for one durable database // state over one compatible files view. Publication revokes a reader's -// generation before changing entries, so it cannot observe mixed state. +// generation before changing entries, so the reader cannot observe mixed state. type StateCache struct { generation GenerationGate @@ -273,6 +273,8 @@ func (c *StateCache) Reset() { c.generation.Reset(c.resetProvenanceAndClearLocked) } +// Close revokes current views and releases the sub-caches' shared-envelope +// reservations. It is idempotent. func (c *StateCache) Close() { c.generation.Close() for _, cache := range c.caches { @@ -309,9 +311,10 @@ func (c *StateCache) PrintStatsAndReset() { } // Update is one value written by the database transaction being published. -// Step is returned by GetLatest. TxNum records how far this process's committed -// writes cover the domain, allowing file publication to detect downloaded -// state that never passed through this publisher. +// Step is the source step returned on cache hits, preserving bounded-read +// semantics. TxNum records how far this process's committed writes cover the +// domain, allowing file publication to detect downloaded state that never +// passed through this publisher. type Update struct { Domain kv.Domain Key []byte diff --git a/execution/cache/view.go b/execution/cache/view.go index 26b845e6acc..6be18cef922 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -25,15 +25,16 @@ import "github.com/erigontech/erigon/db/kv" // // Fills check the same token while holding the cache admission lock. A value // read from an old database snapshot therefore cannot enter a newer cache -// generation. The zero value is inert and safely falls back to the database. +// generation. The zero value is inert and makes callers fall back to the +// database. type ReadView struct { c *StateCache generation GenerationView } -// View returns a live handle only when the cache currently represents -// generation and no publication is in progress. Callers must derive it from -// their own pinned transaction. A mismatch returns an inert view. +// View returns a live handle only when the cache currently represents the +// requested generation and no publication is in progress. Callers must derive +// it from their own pinned transaction. A mismatch returns an inert view. func (c *StateCache) View(generation Generation) ReadView { if c == nil { return ReadView{} diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index fb1f0f87b0c..5c75c9f43f2 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -119,26 +119,24 @@ type BranchCache struct { onMiss atomic.Pointer[MissCallback] // last-published pinned counter snapshots — PublishMetrics emits the delta - // since the previous publish so the Prometheus counters track per-Flush + // since the previous publish so the Prometheus counters track per-publication // activity, not snapshot absolutes. lastPublishedPinnedHits atomic.Uint64 lastPublishedPinnedMisses atomic.Uint64 - // putStripes serialize writes to one prefix and fence Clear while - // preserving parallel writes to unrelated prefixes. + // putStripes serialize Put and PinEntry for one prefix and fence a full clear + // while preserving parallel inserts for unrelated prefixes. putStripes [256]sync.Mutex } type branchCacheEntry struct { - // data is the canonical encoded form (with the leading 2-byte touch-map - // prefix). Always populated by Put. + // data owns the canonical encoded form, including the leading two-byte + // touch-map prefix. data []byte - // step is the on-disk file step the cached bytes came from. Returned - // by Get so callers (e.g. CheckDataAvailable) can validate against - // the latest visible step. 0 means "step not tracked" — fine for - // in-memory tests but real callers should always pass the step - // returned by aggTx.MeteredGetLatest / tx.GetLatest. + // step is the source step returned with a cache hit. It may come from MDBX, + // a committed update, or a file read. Zero can mean either step zero or that + // no single source step was available. step uint64 } @@ -649,8 +647,7 @@ func (c *BranchCache) store(prefix []byte, entry *branchCacheEntry) { // PinEntry inserts or replaces a pinned cache entry for prefix in its contract's // storage trunk (allocated on demand). Data is copied; safe to mutate the input -// after the call. The adaptive controller calls it only inside a publication. -// Non-storage prefixes (< 64 nibbles) fall through to the tail. +// after the call. Non-storage prefixes (< 64 nibbles) fall through to the tail. func (c *BranchCache) PinEntry(prefix []byte, data []byte, step uint64) { if isCommitmentStateKey(prefix) { return @@ -686,10 +683,9 @@ func (c *BranchCache) PinnedCount() int { return int(c.pinnedEntries.Load()) } -// Get retrieves branch data from the cache. Returns the canonical encoded -// bytes (with the leading 2-byte touch-map prefix) plus the on-disk file -// step the bytes came from (0 if not tracked). Shared database readers use -// BranchReadView.Get so the result is checked against their full generation. +// Get retrieves canonical encoded branch data, including its two-byte touch-map +// prefix, and its source step. Shared database readers use BranchReadView.Get so +// the result is checked against their full generation. func (c *BranchCache) Get(prefix []byte) ([]byte, uint64, bool) { if isCommitmentStateKey(prefix) { return nil, 0, false @@ -748,7 +744,7 @@ func (c *BranchCache) Invalidate(prefix []byte) { } // clear empties the root, trunk, pinned, and tail tiers and resets their stats. -// It holds every writer stripe so a write cannot cross the clear. +// It holds every put stripe so no Put or PinEntry can cross the clear. func (c *BranchCache) clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index b16790049b4..dd6a7729f6a 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -213,7 +213,7 @@ func TestBranchCache_ResetFencesStartedPut(t *testing.T) { }) _, _, ok := c.Get(key) - require.False(t, ok, "Reset must remove a Put that started in the retiring generation") + require.False(t, ok, "Reset must remove a Put that started before the reset") } func TestBranchCache_ResetFencesStartedPinEntry(t *testing.T) { @@ -227,7 +227,7 @@ func TestBranchCache_ResetFencesStartedPinEntry(t *testing.T) { }) _, _, ok := c.Get(key) - require.False(t, ok, "Reset must remove a PinEntry that started in the retiring generation") + require.False(t, ok, "Reset must remove a PinEntry that started before the reset") } // TestBranchCache_Stats verifies the format of the stats string is diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 082d76a2891..39d1806c46f 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -26,7 +26,8 @@ type BranchReadView struct { generation cache.GenerationView } -// View returns an inert handle unless generation is currently published. +// View returns an inert handle unless the requested generation is currently +// published. func (c *BranchCache) View(generation cache.Generation) BranchReadView { if c == nil { return BranchReadView{} @@ -60,8 +61,9 @@ func (v BranchReadView) Fill(prefix, value []byte, step uint64) { }) } -// BranchUpdate is one committed commitment-domain value. TxNum records process -// write coverage for detecting files downloaded outside this publication path. +// BranchUpdate is one committed commitment-domain value. Step is returned with +// cache hits for bounded reads. TxNum records process write coverage for +// detecting files downloaded outside this publication path. type BranchUpdate struct { Key []byte Value []byte diff --git a/execution/commitment/hex_patricia_hashed.go b/execution/commitment/hex_patricia_hashed.go index 3853cb0b8a8..e972937f1d3 100644 --- a/execution/commitment/hex_patricia_hashed.go +++ b/execution/commitment/hex_patricia_hashed.go @@ -1452,8 +1452,9 @@ func (hph *HexPatriciaHashed) unfoldBranchNode(row int, depth int16, deleted boo return err } - // depthsToTxNum is used for per-file metrics; step is no longer available - // from the cache-or-DB helper (cache never had a meaningful step anyway). + // depthsToTxNum is used for per-file metrics. This path intentionally drops + // the source step because a branch may come from memory or cache, not one + // identifiable file. hph.depthsToTxNum[depth] = 0 if len(branchData) >= 2 { diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 6a13d5defce..3a17cf1d0d4 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -36,12 +36,8 @@ type BlockReadAheader struct { warming atomic.Bool // only one warmBody can run at a time warmWg sync.WaitGroup - // stateCache is the process-global state cache that SharedDomains.GetLatest - // consults on the EVM hot path. When set, warmBody routes its prefetches - // through a cache-populating getter so the same hashmap the EVM probes is - // pre-warmed. Without it, prefetches only warm OS page cache + RoTx - // cursors — disconnected from the cache layer the EVM actually reads. - // Mirrors reth's CachedReads / ExecutionCache "same hashmap" property. + // stateCache lets warmBody fill the same process-global cache used by + // execution instead of warming only the backing files and cursors. stateCache *cache.StateCache } @@ -70,22 +66,14 @@ func NewBlockReadAheader() *BlockReadAheader { } } -// SetStateCache wires the process-global state cache so warmBody's -// prefetches land in the same hashmap that SharedDomains.GetLatest probes -// on the EVM hot path. Without this, prefetches warm OS page cache only — -// the EVM still pays the file accessor stack on its first per-address read. -// Idempotent; safe to call before the first AddHeaderAndBody. +// SetStateCache enables read-ahead fills into the process-global state cache. +// Call it before the first warmBody can start. func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { bra.stateCache = sc } -// cachePopulatingGetter wraps a kv.TemporalGetter and fills a StateCache -// ReadView as a side effect. Used by warmBody to make read-ahead prefetches -// populate the same in-process cache layer that SharedDomains.GetLatest -// consults — eliminating the file-accessor stack cost on the EVM's first -// touch of any prefetched address. -// -// Code reads also populate the content-addressed and size-cache layers. +// cachePopulatingGetter admits read-ahead results through a generation-bound +// StateCache view. Code reads also fill the content-addressed and size layers. type cachePopulatingGetter struct { kv.TemporalGetter view cache.ReadView diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index c1e7a3e8afd..15743fcbdc9 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -81,10 +81,9 @@ func TestBlockReadAheaderCarriesBlockAccessList(t *testing.T) { require.Equal(t, bal, block.BlockAccessList()) } -// A warmup read-through must never replace a fresher entry an authoritative -// writer (the FCU flush cache-apply) has already put: the warmup reads a -// pre-flush read view, so a laggard Put landing after the flush would pin -// stale state in the cache and corrupt the next block's execution. +// A read-through fill must not replace an entry already present in the same +// generation. Publication excludes old views; this test pins the lower-level +// PutIfAbsent contract used by read-ahead. func TestCachePopulatingGetterKeepsFresherEntry(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") fresh := []byte("account-record-nonce-5") diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index aaba20a4d6b..95f69451839 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -284,9 +284,7 @@ func NewExecModule( stopNode: stopNode, } - // Wire the process-global state cache into the read-ahead so its - // prefetches populate the same hashmap that SharedDomains.GetLatest - // probes on the EVM hot path. Reth's "same hashmap" pattern. + // Share the execution state cache with read-ahead. if readAheader != nil { readAheader.SetStateCache(domainCache) } @@ -515,14 +513,8 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b }, nil } - // Use the overlay-as-rwTx pattern: the validation pipeline writes through - // a fresh BlockOverlay on a new SharedDomains. This mirrors updateForkChoice - // (forkchoice.go:239-251) and is required by the parallel exec path — - // executeBlocks opens its own roTx in a separate goroutine and reads - // recently-inserted block data via te.doms.BlockOverlay().NewReadView, - // which shares the overlay's mem layer. A plain BeginTemporalRwNosync - // would leave doms with no overlay and the parallel goroutine could not - // see uncommitted block headers/bodies. + // Validation writes through a BlockOverlay because parallel execution opens + // independent read transactions that must still see uncommitted block data. roTx, err := e.db.BeginTemporalRo(ctx) if err != nil { return ValidationResult{}, err @@ -533,9 +525,8 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b if err != nil { return ValidationResult{}, err } - // Do not defer doms.Close(): on the success path ownership transfers to - // forkValidator.sharedDom inside ValidatePayload and later phases close it, - // so we Close explicitly only on the early-return error paths below. + // ValidatePayload may retain doms after success, so early-return errors close + // it explicitly instead of using defer. doms.SetInMemHistoryReads(inMemHistoryReads) if err := doms.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil { @@ -544,23 +535,16 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b } var tx kv.TemporalRwTx = doms.BlockOverlay() - // Chain the validation SD to the canonical generation (e.currentContext) for - // any payload with a parent, not just head-extending ones: head-extending - // payloads read its not-yet-committed domain state instead of stale MDBX, and - // fork payloads reach the canonical generation's pastChangesAccumulator (via - // GetDiffset's parent chain) to build the unwind set — without the link the - // unwind runs empty, leaving the BranchCache unmasked and corrupting the root. + // Link validation to the canonical overlay. Head extensions need its + // uncommitted state, while fork validation needs its accumulated changes to + // construct the unwind. if e.currentContext != nil { doms.SetParent(e.currentContext) } - // Flush block overlay data (headers, bodies, TDs from InsertBlocks) into - // the validation overlay so unwindToCommonCanonical and ValidatePayload — - // and the parallel exec goroutine via NewReadView — see this block data. - // The InsertBlocks overlay on e.currentContext retains its data unchanged. - // Do NOT UpdateTxn on e.currentContext.BlockOverlay() here — that would - // reassign its backing db to our soon-to-be-rolled-back roTx and leave - // e.currentContext in an inconsistent state for UpdateForkChoice. + // Copy pending block data into the validation overlay so all validation + // readers see it. Do not retarget the canonical overlay: this read transaction + // is rolled back after validation. if e.currentContext != nil && e.currentContext.BlockOverlay() != nil { if err := e.currentContext.BlockOverlay().Flush(ctx, tx); err != nil { doms.Close() @@ -568,7 +552,8 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b } } - // Set state cache in SharedDomains for use during state reading + // Attach reader-only StateCache access; validation receives no StateCache + // publication authority. doms.SetStateCacheReader(e.stateCache) doms.SetCodeStore(e.codeStore) if err = e.unwindToCommonCanonical(doms, tx, header); err != nil { @@ -581,15 +566,13 @@ func (e *ExecModule) ValidateChain(ctx context.Context, blockHash common.Hash, b return ValidationResult{}, criticalError } - // No cache invalidation needed on an invalid payload: the state cache is - // populated only at flush (committed, fork-agnostic state) and this - // validation path never flushes, so a rejected payload leaves nothing - // fork-specific in the cache. Reads during validation only add canonical - // committed bytes. (Cache invalidation happens solely on unwind.) + // An invalid payload needs no cache rollback. Its uncommitted writes remain + // in the memory overlay, and a speculative unwind detaches both shared cache + // readers. Read-through fills can therefore contain only state from the + // transaction's published generation, never fork-local writes. - // Validation tx is the SD's BlockOverlay; defer doms.Close() above handles - // its rollback. By design we do not persist validation-run writes — there - // is no Flush/Commit on this path. + // Validation writes remain in the BlockOverlay. This path does not commit + // them; closing the owning SharedDomains rolls the overlay back. validationStatus := ExecutionStatusSuccess if status == engine_types.AcceptedStatus { diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 9b28542e103..14bdf4d5dbb 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -291,8 +291,9 @@ func (e *ExecModule) unwindIfNeeded( return nil, err } } - // SD.Unwind (inside RunUnwind) tx-aware-invalidates the BranchCache by - // the unwound txNum, so no whole-cache clear is needed here. + // Unwind detaches both cache readers. If this fork choice commits, Commit + // clears the caches before publishing the rewound generation; a failed + // transaction leaves the durable generation unchanged. if fcuHeader.Number.Sign() > 0 { UpdateForkChoiceDepth(fcuHeader.Number.Uint64() - 1 - unwindTarget) } @@ -396,10 +397,8 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa } if currentContext != nil { currentContext.SetInMemHistoryReads(inMemHistoryReads) - // Wire the state cache so canonical execution reads benefit from - // the per-execution Account/Storage/Code cache. Previously only - // ValidateChain (fork validation, exec_module.go) set this, leaving - // the canonical execution path running uncached against the aggTx. + // Canonical execution both reads the process-global state cache and owns + // publication when Commit makes the overlay durable. currentContext.SetCanonicalStateCache(e.stateCache) currentContext.SetCodeStore(e.codeStore) } @@ -704,8 +703,8 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa }() } - // Flush + commit: pass the outer roTx so it gets released between - // Flush and Commit, so the commit sees openTxs=1 in MDBX. + // Pass the outer roTx so it is released before Commit and the MDBX + // commit observes openTxs=1. commitTimings, err := e.runForkchoiceFlushCommit(currentContext, roTx, finishProgressBefore, isSynced) if err != nil { return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel) @@ -806,13 +805,12 @@ func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, // runForkchoiceFlushCommit opens a brief RwTx, flushes the SharedDomains // (block overlay + domain mem), commits, then updates the sentry head. // -// roTxToCloseBeforeCommit (may be nil) is released between Flush and Commit so -// the commit transaction observes openTxs=1 in MDBX rather than 2. This lets -// MDBX GC reclaim pages freed during the commit window immediately, instead of -// pinning them behind the still-open RO reader until the next commit. SD.Flush -// only writes in-memory state to rwTx and does not read from the RO tx, so -// closing it after Flush is safe. Rollback is idempotent, so callers keep their -// outer `defer roTx.Rollback()` unchanged. +// roTxToCloseBeforeCommit may be nil. Releasing it before Commit lets the write +// transaction observe openTxs=1 in MDBX, so GC can reclaim pages freed during +// the commit instead of pinning them behind the old reader. Commit flushes the +// in-memory overlay into rwTx and does not read from the old transaction, so +// closing that reader first is safe. Rollback is idempotent, so an outer defer +// may still call it. func (e *ExecModule) runForkchoiceFlushCommit(sd *execctx.SharedDomains, roTxToCloseBeforeCommit kv.TemporalTx, finishProgressBefore uint64, isSynced bool) ([]any, error) { timings := make([]any, 0, 2) diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 4afdb6c09ff..514557998af 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -148,8 +148,8 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { return fmt.Errorf("failed to save block hashes stage progress: %w", err) } - // sd.Commit flushes + commits as one unit and applies the BranchCache only - // after the commit succeeds, so a failed commit can't leave it poisoned. + // Commit makes the rewind durable before publishing the matching StateCache + // and BranchCache generations, so a failed commit cannot expose rewound data. if err := sd.Commit(ctx, tx); err != nil { return fmt.Errorf("failed to commit shared domains: %w", err) } From 11c86e35273e28a263f3bc454f35f57e90d04dad Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:26:43 +0200 Subject: [PATCH 23/50] execution/cache, db/state: clarify publication invariants --- db/state/execctx/branch_cache_flush_test.go | 86 +++++++++++++++++++++ execution/cache/generation_gate.go | 7 +- execution/cache/state_cache.go | 6 +- execution/commitment/branch_cache.go | 6 +- 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index dd53528ec3e..9d86772d4c9 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -44,6 +44,92 @@ func branchGenerationForTx(t *testing.T, tx kv.TemporalTx) cache.Generation { return cache.BranchGeneration(stateVersion, tx.Debug().TxNumsInFiles(kv.CommitmentDomain)) } +// Flush changes only the write transaction. The retained memory batch must +// continue to shadow the still-published durable cache for existing getters. +func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { + accountKey := make([]byte, 20) + accountKey[0] = 0xaa + + for _, tc := range []struct { + name string + domain kv.Domain + key, old, new []byte + }{ + {"state", kv.AccountsDomain, accountKey, encAccount(1), encAccount(2)}, + {"branch", kv.CommitmentDomain, []byte{0x0a, 0x0b}, []byte("old-branch"), []byte("new-branch")}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + + seedTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer seedTx.Rollback() + seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) + require.NoError(t, err) + seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetTxNum(1) + require.NoError(t, seedDomains.DomainPut(tc.domain, seedTx, tc.key, tc.old, 1, nil)) + require.NoError(t, seedDomains.Commit(ctx, seedTx)) + seedDomains.Close() + + rwTx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer rwTx.Rollback() + domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) + require.NoError(t, err) + defer domains.Close() + domains.SetStateCacheForTest(stateCache) + + getter := domains.AsGetter(rwTx) + got, _, err := getter.GetLatest(tc.domain, tc.key) + require.NoError(t, err) + require.Equal(t, tc.old, got) + + stateVersion, err := rawdb.GetStateVersion(rwTx) + require.NoError(t, err) + var cachedValue func() ([]byte, bool) + switch tc.domain { + case kv.AccountsDomain: + debug := rwTx.Debug() + view := stateCache.View(cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + )) + cachedValue = func() ([]byte, bool) { return view.Get(tc.domain, tc.key) } + case kv.CommitmentDomain: + provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + view := provider.BranchCache().View(branchGenerationForTx(t, rwTx)) + cachedValue = func() ([]byte, bool) { + value, _, ok := view.Get(tc.key) + return value, ok + } + } + + got, ok := cachedValue() + require.True(t, ok) + require.Equal(t, tc.old, got) + + domains.SetTxNum(2) + require.NoError(t, domains.DomainPut(tc.domain, rwTx, tc.key, tc.new, 2, tc.old)) + require.NoError(t, domains.Flush(ctx, rwTx)) + + got, ok = cachedValue() + require.True(t, ok, "bare Flush must not publish an uncommitted cache generation") + require.Equal(t, tc.old, got) + + got, _, err = getter.GetLatest(tc.domain, tc.key) + require.NoError(t, err) + require.Equal(t, tc.new, got, "retained memory must shadow the old durable cache view") + }) + } +} + // Commit, unlike Flush, publishes the rebuilt branch after the database commit. func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { stepSize := uint64(100) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 7bd1d793aca..2b16acae80e 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -270,9 +270,10 @@ type BackingChange struct { // BeginBackingChange runs reconcile while publications and fills are blocked. // It always revokes an active generation when its files identity changes, but -// clears entries only when reconcile reports foreign state. The returned -// handle keeps publication blocked until Finish makes both the new files and -// their matching cache generation observable. +// clears entries only when reconcile cannot prove that the cache's publication +// history covers the new files. The returned handle keeps publication blocked +// until Finish makes both the new files and their matching cache generation +// observable. func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func() bool, clear func()) *BackingChange { if p.gate == nil { return nil diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 23a758bdd06..7e01f63c3fd 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -93,9 +93,9 @@ func NewDefaultStateCache() *StateCache { } // BeginFilesPublication revokes the old files generation. It retains entries -// backed by this process's committed updates and clears them when the new files -// contain foreign state. Finish publishes the new identity after the files -// become visible. +// when this process's committed updates cover the new files and clears them +// when that compatibility cannot be proven. Finish publishes the new identity +// after the files become visible. func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *BackingChange { if c == nil { return nil diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 5c75c9f43f2..fbbc9969564 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -366,9 +366,9 @@ func (c *BranchCache) resetProvenanceAndClear() { } // BeginFilesPublication revokes the old files generation. It retains entries -// backed by this process's committed updates and clears them when the new files -// contain foreign state. Finish publishes the new identity after the files -// become visible. +// when this process's committed updates cover the new files and clears them +// when that compatibility cannot be proven. Finish publishes the new identity +// after the files become visible. func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChange { if c == nil { return nil From b5df0e9bf84d9062929db40e37ca21ba39893abf Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:23:15 +0200 Subject: [PATCH 24/50] execution, db/state: make cache publication authority explicit --- cmd/integration/commands/stages.go | 3 +- cmd/integration/commands/stages_test.go | 3 ++ cmd/integration/commands/state_stages.go | 2 + db/state/execctx/branch_cache_flush_test.go | 52 ++++++++++++++++++- db/state/execctx/codehash_routing_test.go | 9 ++-- db/state/execctx/domain_shared.go | 39 ++++++++++---- db/state/execctx/export_test.go | 18 +++---- db/state/execctx/flush_storage_cache_test.go | 2 +- .../execctx/statecache_readfill_bench_test.go | 5 +- db/state/execctx/statecache_readfill_test.go | 13 ++--- .../statecache_rpc_integration_test.go | 35 +++++++------ execution/execmodule/exec_module.go | 2 +- .../from0_genesis_internal_test.go | 2 + execution/execmodule/executor.go | 5 +- execution/execmodule/forkchoice.go | 4 +- execution/execmodule/set_head.go | 2 +- execution/stagedsync/stage_custom_trace.go | 1 + execution/tests/blockgen/chain_makers.go | 1 + 18 files changed, 140 insertions(+), 58 deletions(-) diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index a36bc47b95f..3256c7e2649 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -714,6 +714,7 @@ func stageExec(db kv.TemporalRwDB, ctx context.Context, logger log.Logger) error return err } defer doms.Close() + doms.SetCanonicalCaches(nil) if err := stagedsync.UnwindExecutionStage(u, s, doms, tx, ctx, cfg, logger); err != nil { return err } @@ -844,7 +845,7 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn } defer doms.Close() doms.SetInMemHistoryReads(false) - doms.SetCanonicalStateCache(stateCache) + doms.SetCanonicalCaches(stateCache) doms.SetCodeStore(codeStore) execctx.BindStateCacheToAggregator(db, stateCache) diff --git a/cmd/integration/commands/stages_test.go b/cmd/integration/commands/stages_test.go index a0ffab35d3a..3d86b491931 100644 --- a/cmd/integration/commands/stages_test.go +++ b/cmd/integration/commands/stages_test.go @@ -53,6 +53,7 @@ func TestCommitExecUnwindDoesNotRepublishDiscardedBranches(t *testing.T) { defer seedTx.Rollback() seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, logger) require.NoError(t, err) + seedDomains.SetCanonicalCaches(nil) require.NoError(t, seedDomains.Commit(ctx, seedTx)) seedDomains.Close() @@ -61,6 +62,7 @@ func TestCommitExecUnwindDoesNotRepublishDiscardedBranches(t *testing.T) { defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, logger) require.NoError(t, err) + unwindDomains.SetCanonicalCaches(nil) provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) require.True(t, ok) @@ -82,6 +84,7 @@ func TestCommitExecUnwindDoesNotRepublishDiscardedBranches(t *testing.T) { defer nextTx.Rollback() nextDomains, err := execctx.NewSharedDomains(ctx, nextTx, logger) require.NoError(t, err) + nextDomains.SetCanonicalCaches(nil) require.NoError(t, nextDomains.Commit(ctx, nextTx)) nextDomains.Close() diff --git a/cmd/integration/commands/state_stages.go b/cmd/integration/commands/state_stages.go index d21b19ec187..78f1ea67db6 100644 --- a/cmd/integration/commands/state_stages.go +++ b/cmd/integration/commands/state_stages.go @@ -169,6 +169,7 @@ func syncBySmallSteps(db kv.TemporalRwDB, builderConfig buildercfg.BuilderConfig } defer func() { sd.Close() }() // closes whichever SD is current after the commit loop swaps it sd.SetInMemHistoryReads(false) + sd.SetCanonicalCaches(nil) var batchSize datasize.ByteSize must(batchSize.UnmarshalText([]byte(batchSizeStr))) @@ -296,6 +297,7 @@ func syncBySmallSteps(db kv.TemporalRwDB, builderConfig buildercfg.BuilderConfig return err } sd.SetInMemHistoryReads(false) + sd.SetCanonicalCaches(nil) } //receiptsInDB := rawdb.ReadReceiptsByNumber(tx, progress(tx, stages.Execution)+1) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 9d86772d4c9..878f32a1c09 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -44,6 +44,49 @@ func branchGenerationForTx(t *testing.T, tx kv.TemporalTx) cache.Generation { return cache.BranchGeneration(stateVersion, tx.Debug().TxNumsInFiles(kv.CommitmentDomain)) } +func TestCanonicalCachePublicationRequiresExplicitBinding(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + tx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer sd.Close() + + statePublisher, branchPublisher := sd.CachePublishersEnabledForTest() + require.False(t, statePublisher) + require.False(t, branchPublisher, "construction must not grant authority to publish the process-global branch cache") + + sd.SetCanonicalCaches(nil) + statePublisher, branchPublisher = sd.CachePublishersEnabledForTest() + require.False(t, statePublisher) + require.True(t, branchPublisher, "canonical binding must grant branch publication authority without requiring StateCache") + + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + sd.SetCanonicalCachesForTest(stateCache) + statePublisher, branchPublisher = sd.CachePublishersEnabledForTest() + require.True(t, statePublisher) + require.True(t, branchPublisher) +} + +func TestCommitRequiresCanonicalCacheBinding(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer sd.Close() + + err = sd.Commit(ctx, tx) + require.ErrorContains(t, err, "SetCanonicalCaches") +} + // Flush changes only the write transaction. The retained memory batch must // continue to shadow the still-published durable cache for existing getters. func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { @@ -69,7 +112,7 @@ func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { defer seedTx.Rollback() seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) - seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetCanonicalCachesForTest(stateCache) seedDomains.SetTxNum(1) require.NoError(t, seedDomains.DomainPut(tc.domain, seedTx, tc.key, tc.old, 1, nil)) require.NoError(t, seedDomains.Commit(ctx, seedTx)) @@ -81,7 +124,7 @@ func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { domains, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(t, err) defer domains.Close() - domains.SetStateCacheForTest(stateCache) + domains.SetCanonicalCachesForTest(stateCache) getter := domains.AsGetter(rwTx) got, _, err := getter.GetLatest(tc.domain, tc.key) @@ -146,6 +189,7 @@ func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, rwTx, logger) require.NoError(t, err) defer sd.Close() + sd.SetCanonicalCaches(nil) if readFirst { got, _, err := sd.GetLatest(kv.CommitmentDomain, rwTx, key) @@ -218,6 +262,7 @@ func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { defer seedTx.Rollback() seedSD, err := execctx.NewSharedDomains(ctx, seedTx, logger) require.NoError(t, err) + seedSD.SetCanonicalCaches(nil) require.NoError(t, seedSD.DomainPut(kv.CommitmentDomain, seedTx, key, []byte("durable"), 1, nil)) require.NoError(t, seedSD.Commit(ctx, seedTx)) seedSD.Close() @@ -228,6 +273,7 @@ func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { unwindSD, err := execctx.NewSharedDomains(ctx, unwindTx, logger) require.NoError(t, err) defer unwindSD.Close() + unwindSD.SetCanonicalCaches(nil) provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) require.True(t, ok) @@ -263,6 +309,7 @@ func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { defer seedTx.Rollback() seedSD, err := execctx.NewSharedDomains(ctx, seedTx, logger) require.NoError(t, err) + seedSD.SetCanonicalCaches(nil) require.NoError(t, seedSD.Commit(ctx, seedTx)) seedSD.Close() @@ -272,6 +319,7 @@ func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, rwTx, logger) require.NoError(t, err) defer sd.Close() + sd.SetCanonicalCaches(nil) provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) require.True(t, ok) diff --git a/db/state/execctx/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 3ca7cb5b429..ee8a796fdca 100644 --- a/db/state/execctx/codehash_routing_test.go +++ b/db/state/execctx/codehash_routing_test.go @@ -33,7 +33,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { defer sd.Close() sc := cache.NewDefaultStateCache() - sd.SetStateCacheForTest(sc) // force-enable regardless of USE_STATE_CACHE + sd.SetCanonicalCachesForTest(sc) // force-enable regardless of USE_STATE_CACHE var addr common.Address addr[0] = 0xab @@ -94,7 +94,7 @@ func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { seedSD, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) defer seedSD.Close() - seedSD.SetStateCacheForTest(sc) + seedSD.SetCanonicalCachesForTest(sc) seedSD.SetTxNum(10) require.NoError(t, seedSD.DomainPut(kv.AccountsDomain, seedTx, addr[:], accounts.SerialiseV3(&acc), 10, nil)) require.NoError(t, seedSD.Commit(ctx, seedTx)) @@ -111,7 +111,7 @@ func TestCodeHashForAddr_CacheSourcedRecordSeedsMapping(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) @@ -143,6 +143,7 @@ func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { seedSD, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) defer seedSD.Close() + seedSD.SetCanonicalCaches(nil) seedSD.SetTxNum(10) require.NoError(t, seedSD.DomainPut(kv.AccountsDomain, seedTx, addr[:], accounts.SerialiseV3(&acc), 10, nil)) require.NoError(t, seedSD.Commit(ctx, seedTx)) @@ -157,7 +158,7 @@ func TestCodeHashForAddr_ViewSourcedRecordSeedsMapping(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) got := sd.CodeHashForAddr(roTx, addr[:], 20) require.Equal(t, codeHash[:], got) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b25dcf933b3..2a0c0cca3a4 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -216,6 +216,7 @@ type SharedDomains struct { baseStateVersion uint64 baseCacheGenerations cacheGenerations baseStateVersionKnown bool + hasSharedBranchCache bool txNum uint64 currentStep kv.Step @@ -338,9 +339,9 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, branchCache = p.BranchCache() } sd.branchCache = branchCache + sd.hasSharedBranchCache = branchCache != nil if branchCache != nil { forbidVisibilityLowering(tx.AggTx()) - sd.branchPublisher = branchCache.Publisher() } if p, ok := tx.AggTx().(kvmetrics.MetricsCollectorProvider); ok { sd.collector = p.MetricsCollector() @@ -859,18 +860,32 @@ func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { } } -// SetCanonicalStateCache attaches the same reader and also grants publication -// authority. Use it only for a SharedDomains whose Commit makes state durable: -// Commit may revoke existing views, apply the committed cache updates, and -// publish the resulting database and files generation. A canonical unwind may -// additionally clear all entries before publishing its rewound state. +// SetCanonicalCaches grants publication authority for the aggregator-owned +// BranchCache and the optional StateCache. Call it after construction only for +// a SharedDomains whose Commit makes state durable. Commit may revoke existing +// views, apply committed updates, and publish the resulting database and files +// generation. A canonical unwind may additionally clear both caches. // -// Initialize binds the process-global cache to this SharedDomains' base -// database and files snapshot. Keeping this authority separate from +// Initialization binds each available process-global cache to this SharedDomains' base +// database and files snapshot. Keeping publication authority separate from // SetStateCacheReader prevents speculative rollback or unwind from changing // globally visible cache state. -func (sd *SharedDomains) SetCanonicalStateCache(stateCache *cache.StateCache) { - if !dbg.UseStateCache || stateCache == nil || !sd.baseStateVersionKnown { +func (sd *SharedDomains) SetCanonicalCaches(stateCache *cache.StateCache) { + if !dbg.UseStateCache { + stateCache = nil + } + sd.setCanonicalCaches(stateCache) +} + +func (sd *SharedDomains) setCanonicalCaches(stateCache *cache.StateCache) { + if !sd.baseStateVersionKnown { + return + } + if sd.branchCache != nil { + sd.branchPublisher = sd.branchCache.Publisher() + sd.branchPublisher.Initialize(sd.baseCacheGenerations.branch) + } + if stateCache == nil { return } if !sd.clearExecutionCaches { @@ -1026,9 +1041,13 @@ func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.Fl // Commit flushes and commits tx before publishing either process-global cache. // Cache views are revoked only around the database commit, so they continue to // serve the old durable version while the in-memory batch is being flushed. +// A SharedDomains with a shared BranchCache must call SetCanonicalCaches before Commit. // tx must be dedicated to this operation because Commit consumes it. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) + if sd.hasSharedBranchCache && !sd.branchPublisher.Enabled() { + return errors.New("SharedDomains.Commit requires SetCanonicalCaches when a shared BranchCache is attached") + } runValidate := func() error { for _, v := range validate { diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index bba1d175bb8..09772e46941 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -12,17 +12,11 @@ func (sd *SharedDomains) CodeHashForAddr(tx kv.TemporalTx, addr []byte, txNum ui return sd.codeHashForAddr(tx, sd.cacheViewsFor(tx).state, addr) } -// SetStateCacheForTest attaches canonical cache capability without the -// USE_STATE_CACHE gate used by SetCanonicalStateCache and SetStateCacheReader. +// SetCanonicalCachesForTest attaches canonical cache capability without the +// USE_STATE_CACHE gate used by SetCanonicalCaches and SetStateCacheReader. // It avoids changing the process-wide flag in parallel tests. -func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { - if !sd.clearExecutionCaches { - sd.stateCache = sc - } - if sd.baseStateVersionKnown { - sd.statePublisher = sc.Publisher() - sd.statePublisher.Initialize(sd.baseCacheGenerations.state) - } +func (sd *SharedDomains) SetCanonicalCachesForTest(sc *cache.StateCache) { + sd.setCanonicalCaches(sc) } func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { @@ -30,3 +24,7 @@ func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { sd.stateCache = sc } } + +func (sd *SharedDomains) CachePublishersEnabledForTest() (state, branch bool) { + return sd.statePublisher.Enabled(), sd.branchPublisher.Enabled() +} diff --git a/db/state/execctx/flush_storage_cache_test.go b/db/state/execctx/flush_storage_cache_test.go index a2fef6c61e7..5c85645a967 100644 --- a/db/state/execctx/flush_storage_cache_test.go +++ b/db/state/execctx/flush_storage_cache_test.go @@ -66,7 +66,7 @@ func TestCommit_UpdatesStorageStateCache(t *testing.T) { require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) // force-enable regardless of USE_STATE_CACHE + sd.SetCanonicalCachesForTest(sc) // force-enable regardless of USE_STATE_CACHE sd.SetTxNum(txNum) require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTx, key, val, txNum, prevVal)) diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 47bcf08b3b4..943744a811f 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -41,6 +41,7 @@ func benchSeedDb(b *testing.B) kv.TemporalRwDB { sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(b, err) defer sd.Close() + sd.SetCanonicalCaches(nil) written := make([]byte, 20) written[0] = 0x01 sd.SetTxNum(100) @@ -69,7 +70,7 @@ func benchColdNegativeReads(b *testing.B, withCache, writable bool) { if withCache { stateCache := newSmallStateCache() defer stateCache.Close() - sd.SetStateCacheForTest(stateCache) + sd.SetCanonicalCachesForTest(stateCache) } key := make([]byte, 20) @@ -113,7 +114,7 @@ func benchmarkCacheGetterConstruction(b *testing.B, resolveVisibleEnds bool) { defer sd.Close() stateCache := newSmallStateCache() defer stateCache.Close() - sd.SetStateCacheForTest(stateCache) + sd.SetCanonicalCachesForTest(stateCache) domains := [...]kv.Domain{ kv.AccountsDomain, diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7c89105086e..59d3f790cd9 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -58,7 +58,7 @@ func twoStepRows(t *testing.T, db kv.TemporalRwDB, sc *cache.StateCache) (key, v sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) sd.SetTxNum(5) require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, v1, 5, nil)) @@ -119,7 +119,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwind(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) sd.Unwind(10, &diffs) @@ -159,6 +159,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(t, err) defer sd.Close() + sd.SetCanonicalCachesForTest(sc) sd.SetTxNum(5) require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, v1, 5, nil)) require.NoError(t, sd.Commit(ctx, rwTx)) @@ -174,7 +175,7 @@ func TestAssertStateCache_NoFalsePanicDuringInFlightUnwindStepZero(t *testing.T) sd2, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd2.Close() - sd2.SetStateCacheForTest(sc) + sd2.SetCanonicalCachesForTest(sc) sd2.Unwind(3, &diffs) @@ -212,7 +213,7 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) sd.Unwind(10, &diffs) got, _, err := sd.GetLatest(kv.AccountsDomain, roTx, key) @@ -248,7 +249,7 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { sd, err := execctx.NewSharedDomains(ctx, rwTx, log.New()) require.NoError(t, err) defer sd.Close() - sd.SetStateCacheForTest(sc) + sd.SetCanonicalCachesForTest(sc) sd.SetTxNum(20) require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, value, 20, nil)) require.NoError(t, sd.Commit(ctx, rwTx)) @@ -270,7 +271,7 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { sd2, err := execctx.NewSharedDomains(ctx, roTx, log.New()) require.NoError(t, err) defer sd2.Close() - sd2.SetStateCacheForTest(sc) + sd2.SetCanonicalCachesForTest(sc) sd2.Unwind(10, &diffs) got := sd2.CodeHashForAddr(roTx, key, 20) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index af1e2731950..a7ec257cfb9 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -66,7 +66,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) defer unwindDomains.Close() - unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.SetCanonicalCachesForTest(stateCache) events := shards.NewEvents() events.PublishOverlay(unwindDomains) @@ -92,7 +92,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillUnwoundAccount(t *testing.T) { freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) defer freshDomains.Close() - freshDomains.SetStateCacheForTest(stateCache) + freshDomains.SetCanonicalCachesForTest(stateCache) got, _, err = freshDomains.GetLatest(kv.AccountsDomain, freshTx, key) require.NoError(t, err) @@ -116,7 +116,7 @@ func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) - unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.SetCanonicalCachesForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) unwindDomains.Close() @@ -127,7 +127,7 @@ func TestEmbeddedRPCTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testing.T) freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) defer freshDomains.Close() - freshDomains.SetStateCacheForTest(stateCache) + freshDomains.SetCanonicalCachesForTest(stateCache) events := shards.NewEvents() events.PublishOverlay(freshDomains) @@ -165,7 +165,7 @@ func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testin defer unwindTx.Rollback() unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) - unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.SetCanonicalCachesForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) unwindDomains.Close() @@ -176,7 +176,7 @@ func TestSharedDomainsOldTxBoundAfterUnwindDoesNotRefillUnwoundAccount(t *testin freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) defer freshDomains.Close() - freshDomains.SetStateCacheForTest(stateCache) + freshDomains.SetCanonicalCachesForTest(stateCache) got, _, err := freshDomains.GetLatest(kv.AccountsDomain, oldTx, key) require.NoError(t, err) @@ -207,7 +207,7 @@ func TestSharedDomainsOldFilesTxBoundAfterPublicationDoesNotUseNewCacheGeneratio defer seedTx.Rollback() seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) - seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetCanonicalCachesForTest(stateCache) seedDomains.SetTxNum(1) require.NoError(t, seedDomains.DomainPut(kv.StorageDomain, seedTx, key, v1, 1, nil)) require.NoError(t, seedDomains.Commit(ctx, seedTx)) @@ -306,7 +306,7 @@ func TestSharedDomainsSameDatabaseViewUsesReadTxFilesGeneration(t *testing.T) { defer oldTx.Rollback() oldDomains, err := execctx.NewSharedDomains(ctx, oldTx, log.New()) require.NoError(t, err) - oldDomains.SetStateCacheForTest(stateCache) + oldDomains.SetCanonicalCachesForTest(stateCache) oldDomains.Close() oldFilesEnd := oldTx.Debug().TxNumsInFiles(kv.AccountsDomain) @@ -372,6 +372,7 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) defer seedDomains.Close() + seedDomains.SetCanonicalCaches(nil) seedDomains.SetTxNum(10) require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, contractAddr, account, 10, nil)) require.NoError(t, seedDomains.DomainPut(kv.CodeDomain, seedTx, contractAddr, code, 10, nil)) @@ -389,7 +390,7 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) require.NoError(t, err) defer deleteDomains.Close() - deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetCanonicalCachesForTest(stateCache) deleteDomains.SetTxNum(20) require.NoError(t, deleteDomains.DomainDel(kv.AccountsDomain, deleteTx, deletedAddr, 20, nil)) require.NoError(t, deleteDomains.Commit(ctx, deleteTx)) @@ -407,7 +408,7 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) defer freshDomains.Close() - freshDomains.SetStateCacheForTest(stateCache) + freshDomains.SetCanonicalCachesForTest(stateCache) got, _, err := freshDomains.GetLatest(kv.CodeDomain, freshTx, contractAddr) require.NoError(t, err) require.Equal(t, code, got) @@ -433,7 +434,7 @@ func TestCanonicalUnwindClearsNegativeCacheEntry(t *testing.T) { defer readTx.Rollback() readDomains, err := execctx.NewSharedDomains(ctx, readTx, log.New()) require.NoError(t, err) - readDomains.SetStateCacheForTest(stateCache) + readDomains.SetCanonicalCachesForTest(stateCache) got, _, err := readDomains.GetLatest(kv.AccountsDomain, readTx, missingKey) require.NoError(t, err) require.Empty(t, got) @@ -448,7 +449,7 @@ func TestCanonicalUnwindClearsNegativeCacheEntry(t *testing.T) { unwindDomains, err := execctx.NewSharedDomains(ctx, unwindTx, log.New()) require.NoError(t, err) defer unwindDomains.Close() - unwindDomains.SetStateCacheForTest(stateCache) + unwindDomains.SetCanonicalCachesForTest(stateCache) unwindDomains.Unwind(10, &diffs) require.NoError(t, unwindDomains.Commit(ctx, unwindTx)) @@ -489,7 +490,7 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) defer seedDomains.Close() - seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetCanonicalCachesForTest(stateCache) seedDomains.SetTxNum(10) require.NoError(t, seedDomains.DomainPut(domain, seedTx, key, value, 10, nil)) require.NoError(t, seedDomains.Commit(ctx, seedTx)) @@ -505,7 +506,7 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) require.NoError(t, err) defer deleteDomains.Close() - deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetCanonicalCachesForTest(stateCache) deleteDomains.SetTxNum(20) require.NoError(t, deleteDomains.DomainDel(domain, deleteTx, key, 20, value)) @@ -544,7 +545,7 @@ func testEmbeddedRPCCacheViewDoesNotResurrectDeletedValue(t *testing.T, domain k freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) defer freshDomains.Close() - freshDomains.SetStateCacheForTest(stateCache) + freshDomains.SetCanonicalCachesForTest(stateCache) got, _, err := freshDomains.GetLatest(domain, freshTx, key) require.NoError(t, err) require.Empty(t, got, "the old RPC read view must not repopulate the shared cache after the deletion") @@ -577,7 +578,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { seedDomains, err := execctx.NewSharedDomains(ctx, seedTx, log.New()) require.NoError(t, err) defer seedDomains.Close() - seedDomains.SetStateCacheForTest(stateCache) + seedDomains.SetCanonicalCachesForTest(stateCache) seedDomains.SetTxNum(10) require.NoError(t, seedDomains.DomainPut(kv.AccountsDomain, seedTx, addr, account, 10, nil)) require.NoError(t, seedDomains.DomainPut(kv.CodeDomain, seedTx, addr, code, 10, nil)) @@ -594,7 +595,7 @@ func TestEmbeddedRPCCacheViewDoesNotRefillCodeOfDeletedAccount(t *testing.T) { deleteDomains, err := execctx.NewSharedDomains(ctx, deleteTx, log.New()) require.NoError(t, err) defer deleteDomains.Close() - deleteDomains.SetStateCacheForTest(stateCache) + deleteDomains.SetCanonicalCachesForTest(stateCache) deleteDomains.SetTxNum(20) require.NoError(t, deleteDomains.DomainDel(kv.AccountsDomain, deleteTx, addr, 20, account)) diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 95f69451839..56426536cfa 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -663,7 +663,7 @@ func (e *ExecModule) Start(ctx context.Context, hook *stageloop.Hook) { } defer e.semaphore.Release(1) - if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart); err != nil { + if err := e.pipelineExecutor.ProcessFrozenBlocks(ctx, hook, e.onlySnapDownloadOnStart, e.stateCache); err != nil { if !errors.Is(err, context.Canceled) { e.logger.Error("Could not start execution service", "err", err) } diff --git a/execution/execmodule/execmoduletester/from0_genesis_internal_test.go b/execution/execmodule/execmoduletester/from0_genesis_internal_test.go index 5e6030b2895..6734de0e12b 100644 --- a/execution/execmodule/execmoduletester/from0_genesis_internal_test.go +++ b/execution/execmodule/execmoduletester/from0_genesis_internal_test.go @@ -214,6 +214,7 @@ func execOneBatch(ctx context.Context, emt *ExecModuleTester, cfg stagedsync.Exe } defer doms.Close() doms.SetInMemHistoryReads(false) + doms.SetCanonicalCaches(nil) s, err := emt.Sync.StageState(stages.Execution, tx, true, false) if err != nil { @@ -350,6 +351,7 @@ func TestExec_RestoresCommitmentStateReader(t *testing.T) { require.NoError(t, err) defer doms.Close() doms.SetInMemHistoryReads(false) + doms.SetCanonicalCaches(nil) readerBefore := doms.GetCommitmentContext().StateReader() diff --git a/execution/execmodule/executor.go b/execution/execmodule/executor.go index c172ce51913..216d248e3bb 100644 --- a/execution/execmodule/executor.go +++ b/execution/execmodule/executor.go @@ -28,6 +28,7 @@ import ( "github.com/erigontech/erigon/db/kv" dbstate "github.com/erigontech/erigon/db/state" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/stagedsync" @@ -180,7 +181,7 @@ func (pe *PipelineExecutor) RunLoop(ctx context.Context, sd *execctx.SharedDomai // ProcessFrozenBlocks runs the pipeline over snapshot blocks at startup. // It downloads block files, then executes them in a hasMore loop until // all frozen blocks are processed. -func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stageloop.Hook, onlySnapDownload bool) error { +func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stageloop.Hook, onlySnapDownload bool, stateCache *cache.StateCache) error { sawZeroBlocksTimes := 0 tx, err := pe.db.BeginTemporalRw(ctx) if err != nil { @@ -209,6 +210,7 @@ func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stage } defer func() { doms.Close() }() // RunLoop rotates doms; close whichever is current at exit doms.SetInMemHistoryReads(inMemHistoryReads) + doms.SetCanonicalCaches(stateCache) var finishStageBeforeSync uint64 if hook != nil { @@ -252,6 +254,7 @@ func (pe *PipelineExecutor) ProcessFrozenBlocks(ctx context.Context, hook *stage return nil, nil, err } newSD.SetInMemHistoryReads(inMemHistoryReads) + newSD.SetCanonicalCaches(stateCache) hook.NotifySyncState(newTx) return newTx, newSD, nil }, diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 14bdf4d5dbb..4aaeaa79f6a 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -399,7 +399,7 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa currentContext.SetInMemHistoryReads(inMemHistoryReads) // Canonical execution both reads the process-global state cache and owns // publication when Commit makes the overlay durable. - currentContext.SetCanonicalStateCache(e.stateCache) + currentContext.SetCanonicalCaches(e.stateCache) currentContext.SetCodeStore(e.codeStore) } @@ -585,7 +585,7 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa return nil, nil, fmt.Errorf("updateForkChoice: new sd after hasMore: %w", err) } freshSD.SetInMemHistoryReads(inMemHistoryReads) - freshSD.SetCanonicalStateCache(e.stateCache) + freshSD.SetCanonicalCaches(e.stateCache) freshSD.SetCodeStore(e.codeStore) if err := freshSD.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil { roTx.Rollback() diff --git a/execution/execmodule/set_head.go b/execution/execmodule/set_head.go index 514557998af..fd32f37ab1a 100644 --- a/execution/execmodule/set_head.go +++ b/execution/execmodule/set_head.go @@ -104,7 +104,7 @@ func (e *ExecModule) SetHead(ctx context.Context, targetBlock uint64) error { defer sd.Close() // This path owns the canonical cache publication performed by Commit. - sd.SetCanonicalStateCache(e.stateCache) + sd.SetCanonicalCaches(e.stateCache) sd.SetCodeStore(e.codeStore) // Set the unwind point and run the unwind diff --git a/execution/stagedsync/stage_custom_trace.go b/execution/stagedsync/stage_custom_trace.go index fdd1fcc5f15..f532aabbfa8 100644 --- a/execution/stagedsync/stage_custom_trace.go +++ b/execution/stagedsync/stage_custom_trace.go @@ -262,6 +262,7 @@ func customTraceBatchProduce(ctx context.Context, produce Produce, cfg *exec.Exe return err } defer doms.Close() + doms.SetCanonicalCaches(nil) if err := customTraceBatch(ctx, produce, cfg, tx, doms, fromBlock, toBlock, logPrefix, logger); err != nil { return err diff --git a/execution/tests/blockgen/chain_makers.go b/execution/tests/blockgen/chain_makers.go index 126fcaabb29..021d8185e27 100644 --- a/execution/tests/blockgen/chain_makers.go +++ b/execution/tests/blockgen/chain_makers.go @@ -368,6 +368,7 @@ func InitPraguePreDeploys(db kv.TemporalRwDB, config *chain.Config, logger log.L return err } defer domains.Close() + domains.SetCanonicalCaches(nil) latestTxNum, _, err := domains.SeekCommitment(ctx, tx) if err != nil { return err From 372ade3b5178006d217660ef910cdb0d77970e8f Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:32:28 +0200 Subject: [PATCH 25/50] db/state/execctx: avoid cache locks during database commit --- db/state/execctx/branch_cache_flush_test.go | 80 ++++++++++++++++++++- db/state/execctx/domain_shared.go | 25 ++++--- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 878f32a1c09..2bd407c11eb 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -19,6 +19,7 @@ package execctx_test import ( "errors" "testing" + "time" "github.com/stretchr/testify/require" @@ -37,6 +38,19 @@ type commitErrorTx struct { func (tx *commitErrorTx) Commit() error { return tx.err } +type pausedCommitTx struct { + kv.TemporalRwTx + entered chan struct{} + resume chan struct{} + err error +} + +func (tx *pausedCommitTx) Commit() error { + close(tx.entered) + <-tx.resume + return tx.err +} + func branchGenerationForTx(t *testing.T, tx kv.TemporalTx) cache.Generation { t.Helper() stateVersion, err := rawdb.GetStateVersion(tx) @@ -87,6 +101,68 @@ func TestCommitRequiresCanonicalCacheBinding(t *testing.T) { require.ErrorContains(t, err, "SetCanonicalCaches") } +func TestCommitDoesNotHoldCachePublicationDuringDatabaseCommit(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + 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.SetCanonicalCaches(nil) + + provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + require.NotNil(t, branchCache) + filesEnd := rwTx.Debug().TxNumsInFiles(kv.CommitmentDomain) + 1 + + pausedTx := &pausedCommitTx{ + TemporalRwTx: rwTx, + entered: make(chan struct{}), + resume: make(chan struct{}), + err: errors.New("injected commit failure"), + } + commitDone := make(chan error, 1) + go func() { commitDone <- sd.Commit(ctx, pausedTx) }() + + select { + case <-pausedTx.entered: + case <-time.After(time.Second): + close(pausedTx.resume) + t.Fatal("database commit was not reached") + } + + filesPublished := make(chan struct{}) + go func() { + branchCache.BeginFilesPublication(filesEnd).Finish() + close(filesPublished) + }() + + publicationBlocked := false + select { + case <-filesPublished: + case <-time.After(time.Second): + publicationBlocked = true + } + close(pausedTx.resume) + + select { + case err := <-commitDone: + require.ErrorIs(t, err, pausedTx.err) + case <-time.After(time.Second): + t.Fatal("database commit did not finish") + } + select { + case <-filesPublished: + case <-time.After(time.Second): + t.Fatal("files publication did not finish") + } + require.False(t, publicationBlocked, "database commit must not hold cache publication while transaction close may acquire aggregator locks") +} + // Flush changes only the write transaction. The retained memory batch must // continue to shadow the still-published durable cache for existing getters. func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { @@ -299,7 +375,7 @@ func TestCanonicalUnwindClearsBranchCacheOnlyAfterCommit(t *testing.T) { require.False(t, ok, "the unwound generation must not retain a cache-only discarded branch") } -func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { +func TestFailedCommitKeepsBranchCacheGeneration(t *testing.T) { db := newTestDb(t, 100) ctx := t.Context() logger := log.New() @@ -333,6 +409,6 @@ func TestFailedCommitRestoresBranchCacheGeneration(t *testing.T) { require.ErrorIs(t, err, sentinel) value, _, ok := view.Get(key) - require.True(t, ok, "a failed database commit must restore the previous branch generation") + require.True(t, ok, "a failed database commit must keep the previous branch generation") require.Equal(t, []byte("durable"), value) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 2a0c0cca3a4..b504c6bbdb0 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1144,29 +1144,36 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun nextCacheGenerations = sd.baseCacheGenerations.withStateVersion(stateVersion) } + var adaptivePlan *commitment.AdaptivePinPlan + if branchCacheEnabled { + if !sd.clearExecutionCaches { + adaptivePlan = sd.planAdaptivePins(tx) + } + } + defer func() { adaptivePlan.Abort() }() + + // Do not hold cache publication locks across tx.Commit: transaction cleanup + // may acquire aggregator file locks in the opposite order. The old generation + // remains safe because new transactions have the new state version, while old + // transactions still read the old durable state. + if err := tx.Commit(); err != nil { + return err + } + var statePublication *cache.Publication var branchPublication *commitment.BranchPublication - var adaptivePlan *commitment.AdaptivePinPlan defer func() { statePublication.Abort() branchPublication.Abort() - adaptivePlan.Abort() }() - // Canonical commits and file-view changes both acquire BranchCache before // StateCache. Keeping one order prevents their publications from deadlocking. if branchCacheEnabled { - if !sd.clearExecutionCaches { - adaptivePlan = sd.planAdaptivePins(tx) - } branchPublication = sd.branchPublisher.Begin() } if stateCacheEnabled { statePublication = sd.statePublisher.Begin() } - if err := tx.Commit(); err != nil { - return err - } statePublication.Publish(nextCacheGenerations.state, stateUpdates, sd.clearExecutionCaches) statePublication = nil From 47b9a658fd38a1e4a55dfc14e607fcd16c7ab0be Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:41:48 +0200 Subject: [PATCH 26/50] execution/stagedsync: bind commitment reader cache views --- execution/stagedsync/committer.go | 47 ++++++++++++++------------ execution/stagedsync/committer_test.go | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/execution/stagedsync/committer.go b/execution/stagedsync/committer.go index 6a8587778d7..12c4f3dad27 100644 --- a/execution/stagedsync/committer.go +++ b/execution/stagedsync/committer.go @@ -15,6 +15,7 @@ import ( "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/kvmetrics" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/commitment" "github.com/erigontech/erigon/execution/commitment/commitmentdb" @@ -230,7 +231,7 @@ func newCommitmentCalculator( // methods (fold/unfold sibling reads). Uses GetAsOf for account/storage // (avoids future sd.mem state) and GetLatest for commitment branches // (written sequentially by this calculator). - asOfReader := &asOfStateReader{sd: doms, roTx: roTx, txNum: 0} + asOfReader := newAsOfStateReader(doms, roTx, 0) return &commitmentCalculator{ doms: doms, @@ -608,7 +609,7 @@ func (cc *commitmentCalculator) checkpointStepsFromBAL(ctx context.Context, req // flushes it to a fresh updates buffer, and computes the root at t. Shared by // the block-end compute-ahead and the mid-block step checkpoints so the two can't drift. func (cc *commitmentCalculator) computeRootFromBAL(ctx context.Context, req *blockRequest, maxTxIndex uint32, emptyRemoval bool, eip8246 bool, t commitTarget) ([]byte, error) { - reader := &asOfStateReader{sd: cc.doms, roTx: cc.roTx, txNum: t.lastTxNum + 1} + reader := newAsOfStateReader(cc.doms, cc.roTx, t.lastTxNum+1) balState := newCalcState(reader, cc.logger, cc.logPrefix) balState.LoadFromBALUpTo(req.bal, maxTxIndex, emptyRemoval, cc.chainConfig.Aura != nil, eip8246) if err := balState.LazyLoadErr(); err != nil { @@ -932,14 +933,19 @@ func (cc *commitmentCalculator) computeWithBlockAccumulator(ctx context.Context, // Commitment domain reads use GetLatest since branches are only written // by the calculator sequentially. type asOfStateReader struct { - sd *execctx.SharedDomains - roTx kv.TemporalTx - txNum uint64 - // workerCtx, when non-nil, carries this worker's lock-free metrics - // accumulator; the CommitmentDomain read routes through GetLatestContext so - // a concurrent trie-warmup worker doesn't write the shared main accumulator - // (a race) or take the global metrics lock. Nil on the main reader. - workerCtx context.Context + sd *execctx.SharedDomains + roTx kv.TemporalTx + latestGetter kv.TemporalGetter + txNum uint64 +} + +func newAsOfStateReader(sd *execctx.SharedDomains, tx kv.TemporalTx, txNum uint64) *asOfStateReader { + return &asOfStateReader{ + sd: sd, + roTx: tx, + latestGetter: sd.AsGetter(tx), + txNum: txNum, + } } func (r *asOfStateReader) WithHistory() bool { return false } @@ -951,11 +957,7 @@ func (r *asOfStateReader) CheckDataAvailable(d kv.Domain, step kv.Step) error { func (r *asOfStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64) (enc []byte, step kv.Step, err error) { if d == kv.CommitmentDomain { // Branches: use GetLatest — written only by this calculator, sequential. - if r.workerCtx != nil { - enc, step, err = r.sd.GetLatestContext(r.workerCtx, d, r.roTx, plainKey) - } else { - enc, step, err = r.sd.GetLatest(d, r.roTx, plainKey) - } + enc, step, err = r.latestGetter.GetLatest(d, plainKey) } else { // Account/storage/code: use GetAsOf to avoid reading future state. // Check sd.mem first (in-memory data from current batch), then @@ -983,15 +985,18 @@ func (r *asOfStateReader) Read(d kv.Domain, plainKey []byte, stepSize uint64) (e } func (r *asOfStateReader) Clone(tx kv.TemporalTx) commitmentdb.StateReader { - return &asOfStateReader{sd: r.sd, roTx: tx, txNum: r.txNum} + return newAsOfStateReader(r.sd, tx, r.txNum) } -// CloneForWorker meters the worker's CommitmentDomain reads into the per-worker -// accumulator carried by workerCtx (this reader is used as the commitment -// reader during block assembly, where trie-warmup runs concurrently — so it -// must not write the shared main accumulator). +// CloneForWorker meters CommitmentDomain reads into the worker's lock-free +// accumulator instead of the shared main accumulator. func (r *asOfStateReader) CloneForWorker(workerCtx context.Context, tx kv.TemporalTx) commitmentdb.StateReader { - return &asOfStateReader{sd: r.sd, roTx: tx, txNum: r.txNum, workerCtx: workerCtx} + return &asOfStateReader{ + sd: r.sd, + roTx: tx, + latestGetter: r.sd.AsGetterMetered(tx, kvmetrics.MetricsFromContext(workerCtx)), + txNum: r.txNum, + } } // Keep imports used. diff --git a/execution/stagedsync/committer_test.go b/execution/stagedsync/committer_test.go index 0605da0195a..bc10d70325a 100644 --- a/execution/stagedsync/committer_test.go +++ b/execution/stagedsync/committer_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/common/dbg" + "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/state/execctx" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/state" @@ -32,6 +33,39 @@ import ( "github.com/erigontech/erigon/execution/types/accounts" ) +type stateVersionCountingTemporalTx struct { + kv.TemporalTx + viewID uint64 + stateVersionReads int +} + +func (tx *stateVersionCountingTemporalTx) ViewID() uint64 { + return tx.viewID +} + +func (tx *stateVersionCountingTemporalTx) ReadSequence(table string) (uint64, error) { + if table == string(kv.PlainStateVersion) { + tx.stateVersionReads++ + } + return tx.TemporalTx.ReadSequence(table) +} + +func TestAsOfStateReaderDerivesCacheViewsOncePerTransaction(t *testing.T) { + _, tx, doms := setupStepTest(t) + countingTx := &stateVersionCountingTemporalTx{ + TemporalTx: tx, + viewID: tx.ViewID() ^ (uint64(1) << 63), + } + reader := (&asOfStateReader{sd: doms, roTx: tx}).Clone(countingTx) + + for range 2 { + _, _, err := reader.Read(kv.CommitmentDomain, []byte("missing-branch"), doms.StepSize()) + require.NoError(t, err) + } + + require.Equal(t, 1, countingTx.stateVersionReads) +} + // TestShouldComputeOnRequest_GenesisFirstBatch is the regression test for // the batch-mode genesis-commitment bug: // From 4d84e033156216d8e526160ca263d00bb890dd22 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:53:49 +0200 Subject: [PATCH 27/50] db/state/execctx: require state cache publication authority --- db/state/execctx/branch_cache_flush_test.go | 63 +++++++++++++++++++++ db/state/execctx/domain_shared.go | 36 +++++++++--- db/state/execctx/export_test.go | 4 +- 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 2bd407c11eb..0e8730c761b 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -101,6 +101,69 @@ func TestCommitRequiresCanonicalCacheBinding(t *testing.T) { require.ErrorContains(t, err, "SetCanonicalCaches") } +func TestCommitRequiresCanonicalStateCacheBinding(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutSharedBranchCache()) + require.NoError(t, err) + defer sd.Close() + + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + sd.SetStateCacheReaderForTest(stateCache) + + err = sd.Commit(ctx, tx) + require.ErrorContains(t, err, "SetCanonicalCaches") +} + +func TestCommitRequiresCanonicalStateCacheBindingAfterUnwind(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + sd, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutSharedBranchCache()) + require.NoError(t, err) + defer sd.Close() + + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + sd.SetStateCacheReaderForTest(stateCache) + sd.Unwind(0, nil) + + err = sd.Commit(ctx, tx) + require.ErrorContains(t, err, "SetCanonicalCaches") +} + +func TestCommitRequiresCanonicalStateCacheBindingAfterMerge(t *testing.T) { + ctx := t.Context() + db := newTestDb(t, 100) + tx, err := db.BeginTemporalRw(ctx) + require.NoError(t, err) + defer tx.Rollback() + + parent, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutSharedBranchCache()) + require.NoError(t, err) + defer parent.Close() + child, err := execctx.NewSharedDomains(ctx, tx, log.New(), execctx.WithoutSharedBranchCache()) + require.NoError(t, err) + defer child.Close() + + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + child.SetStateCacheReaderForTest(stateCache) + child.Unwind(0, nil) + require.NoError(t, parent.Merge(ctx, 0, child, 0)) + + err = parent.Commit(ctx, tx) + require.ErrorContains(t, err, "SetCanonicalCaches") +} + func TestCommitDoesNotHoldCachePublicationDuringDatabaseCommit(t *testing.T) { ctx := t.Context() db := newTestDb(t, 100) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b504c6bbdb0..5fef767015c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -242,6 +242,10 @@ type SharedDomains struct { // generation or change its authoritative entries. stateCache *cache.StateCache statePublisher cache.Publisher + + // hasStateCache survives reader detachment so Commit still requires + // publication authority. + hasStateCache bool // Unwind and Merge preserve this flag after detaching both cache readers so // a later canonical Commit clears entries from the discarded state. clearExecutionCaches bool @@ -423,7 +427,7 @@ func (sd *SharedDomains) Merge(ctx context.Context, sdTxNum uint64, other *Share sd.branchCache = nil sd.clearExecutionCaches = true } - + sd.hasStateCache = sd.hasStateCache || other.hasStateCache // Merge block-level metadata from other's overlay into ours by flushing // other's overlay writes directly into our overlay (which implements kv.RwTx). if otherOverlay, sdOverlay := other.blockOverlay.Load(), sd.blockOverlay.Load(); otherOverlay != nil && sdOverlay != nil { @@ -850,11 +854,20 @@ func (sd *SharedDomains) Logger() log.Logger { return sd.logger } // // This restricted capability is safe for speculative execution: its writes // may be discarded, and its local unwind only detaches the reader. It cannot -// change the canonical cache observed by other transactions. +// change the canonical cache observed by other transactions. Commit rejects +// this capability until SetCanonicalCaches grants publication authority. func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } + sd.setStateCacheReader(stateCache) +} + +func (sd *SharedDomains) setStateCacheReader(stateCache *cache.StateCache) { + if stateCache == nil { + return + } + sd.hasStateCache = true if !sd.clearExecutionCaches { sd.stateCache = stateCache } @@ -878,6 +891,9 @@ func (sd *SharedDomains) SetCanonicalCaches(stateCache *cache.StateCache) { } func (sd *SharedDomains) setCanonicalCaches(stateCache *cache.StateCache) { + if stateCache != nil { + sd.hasStateCache = true + } if !sd.baseStateVersionKnown { return } @@ -888,9 +904,7 @@ func (sd *SharedDomains) setCanonicalCaches(stateCache *cache.StateCache) { if stateCache == nil { return } - if !sd.clearExecutionCaches { - sd.stateCache = stateCache - } + sd.setStateCacheReader(stateCache) sd.statePublisher = stateCache.Publisher() sd.statePublisher.Initialize(sd.baseCacheGenerations.state) } @@ -1041,11 +1055,17 @@ func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.Fl // Commit flushes and commits tx before publishing either process-global cache. // Cache views are revoked only around the database commit, so they continue to // serve the old durable version while the in-memory batch is being flushed. -// A SharedDomains with a shared BranchCache must call SetCanonicalCaches before Commit. +// A SharedDomains attached to either process-global cache must call +// SetCanonicalCaches before Commit. // tx must be dedicated to this operation because Commit consumes it. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) - if sd.hasSharedBranchCache && !sd.branchPublisher.Enabled() { + stateCacheEnabled := sd.statePublisher.Enabled() + branchCacheEnabled := sd.branchPublisher.Enabled() + if sd.hasStateCache && !stateCacheEnabled { + return errors.New("SharedDomains.Commit requires SetCanonicalCaches when a StateCache has been attached") + } + if sd.hasSharedBranchCache && !branchCacheEnabled { return errors.New("SharedDomains.Commit requires SetCanonicalCaches when a shared BranchCache is attached") } @@ -1061,8 +1081,6 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun return nil } - stateCacheEnabled := sd.statePublisher.Enabled() - branchCacheEnabled := sd.branchPublisher.Enabled() if !stateCacheEnabled && !branchCacheEnabled && sd.codeStore == nil { if err := sd.flushMem(ctx, tx); err != nil { return err diff --git a/db/state/execctx/export_test.go b/db/state/execctx/export_test.go index 09772e46941..7cdacc6b4f7 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -20,9 +20,7 @@ func (sd *SharedDomains) SetCanonicalCachesForTest(sc *cache.StateCache) { } func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { - if !sd.clearExecutionCaches { - sd.stateCache = sc - } + sd.setStateCacheReader(sc) } func (sd *SharedDomains) CachePublishersEnabledForTest() (state, branch bool) { From 02d926d33da20bafe18717b781ac79e8d6845b4d Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:02:29 +0200 Subject: [PATCH 28/50] execution/commitment: skip stale adaptive pin plans --- execution/commitment/adaptive_pin.go | 9 ++++++- execution/commitment/adaptive_pin_test.go | 33 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index b6d76e4670f..059a74aa1f5 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -305,6 +305,8 @@ func (c *AdaptivePinController) onCacheMiss(prefix []byte) { // uncommitted transaction without changing BranchCache. The returned plan // keeps controller updates serialized until Commit or Abort. Publication // discards it if sourceGeneration or the cache clear epoch changed meanwhile. +// An already-stale source returns no plan and leaves its misses for a fresh +// transaction instead of doing work that cannot be published. func (c *AdaptivePinController) PlanBlock( txNum uint64, sourceGeneration cache.Generation, @@ -314,6 +316,11 @@ func (c *AdaptivePinController) PlanBlock( ) *AdaptivePinPlan { c.mu.Lock() c.syncCacheClearLocked() + source := c.cache.generation.View(sourceGeneration) + if !source.Current() { + c.mu.Unlock() + return nil + } previousStates := c.states c.states = cloneAdaptiveStateHeaders(previousStates) misses := c.snapshotMisses() @@ -323,7 +330,7 @@ func (c *AdaptivePinController) PlanBlock( controller: c, previousStates: previousStates, observedMisses: observedMisses, - source: c.cache.generation.View(sourceGeneration), + source: source, cacheClearEpoch: c.cacheClearEpoch, txNum: txNum, } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index 5218967eef5..26cd5d57f61 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -138,6 +138,39 @@ func TestAdaptivePinPlanIsDiscardedAfterFilesPublication(t *testing.T) { require.Empty(t, controller.states, "discarding the stale plan must also discard its residency state") } +func TestAdaptivePinPlanSkipsStaleSourceAfterFilesPublication(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(testBranchGeneration(1)) + + cfg := DefaultAdaptivePinControllerConfig() + cfg.PromoteThresholdMisses = 1 + cfg.MaxPromotedContracts = 1 + controller := NewAdaptivePinController(branchCache, cfg, log.Root()) + + change := branchCache.BeginFilesPublication(100) + require.NotNil(t, change) + change.Finish() + + var contractHash [32]byte + contractHash[0] = 1 + prefix := nibbles.HexToCompact(ContractNibbles(contractHash[:])) + controller.onCacheMiss(prefix) + readerCalls := 0 + reader := func(key []byte) ([]byte, uint64, bool, error) { + readerCalls++ + if !bytes.Equal(key, prefix) { + return nil, 0, false, nil + } + return []byte{0, 0, 0, 0}, 1, true, nil + } + + plan := controller.PlanBlock(1, testBranchGeneration(1), reader, nil, nil) + require.Nil(t, plan, "a transaction pinned to the old files cannot prepare a plan for the new cache generation") + require.Zero(t, readerCalls, "a plan that cannot be published must not scan branches") +} + func TestAdaptivePinControllerForgetsPinsClearedByFilesPublication(t *testing.T) { branchCache := NewBranchCache(64) t.Cleanup(branchCache.Close) From 3344d41e2d3040330d57c01fba7152fad54021a3 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:17:47 +0200 Subject: [PATCH 29/50] db/state/execctx: keep bounded reads out of latest caches --- db/state/execctx/branch_cache_flush_test.go | 99 ++++++++++++++++++++ db/state/execctx/domain_shared.go | 18 ++-- db/state/execctx/statecache_readfill_test.go | 43 +++++++++ 3 files changed, 153 insertions(+), 7 deletions(-) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 0e8730c761b..c86f5a193d4 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -17,6 +17,8 @@ package execctx_test import ( + "bytes" + "encoding/binary" "errors" "testing" "time" @@ -27,10 +29,49 @@ import ( "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" + "github.com/erigontech/erigon/db/state/kvmetrics" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/commitment" ) +type temporalTxWithAgg struct { + kv.TemporalTx + agg any + debug kv.TemporalDebugTx +} + +func (tx *temporalTxWithAgg) AggTx() any { return tx.agg } +func (tx *temporalTxWithAgg) Debug() kv.TemporalDebugTx { + if tx.debug != nil { + return tx.debug + } + return tx.TemporalTx.Debug() +} + +type exactVisibleDebug struct{ kv.TemporalDebugTx } + +func (*exactVisibleDebug) HasExactDomainVisibleEnd(kv.Domain) bool { return true } + +type boundedLatestAgg struct { + branchCache *commitment.BranchCache + domain kv.Domain + key []byte + value []byte + step kv.Step + maxStep kv.Step +} + +func (a *boundedLatestAgg) BranchCache() *commitment.BranchCache { return a.branchCache } +func (a *boundedLatestAgg) ForbidVisibilityLowering() {} + +func (a *boundedLatestAgg) MeteredGetLatest(domain kv.Domain, key []byte, _ kv.Tx, maxStep kv.Step, _ *kvmetrics.DomainMetrics, _ time.Time) ([]byte, kv.Step, bool, error) { + if domain != a.domain || !bytes.Equal(key, a.key) { + return nil, 0, false, nil + } + a.maxStep = maxStep + return a.value, a.step, true, nil +} + type commitErrorTx struct { kv.TemporalRwTx err error @@ -312,6 +353,64 @@ func TestBareFlushRetainsMemoryAsAuthorityOverCacheViews(t *testing.T) { } } +func TestBoundedReadDoesNotFillBranchCache(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + branchCache := commitment.NewBranchCache(64) + t.Cleanup(branchCache.Close) + key := []byte{0x0a, 0x0b} + value := []byte("bounded-branch") + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + generation := branchGenerationForTx(t, roTx) + branchCache.Publisher().Initialize(generation) + view := branchCache.View(generation) + agg := &boundedLatestAgg{ + branchCache: branchCache, + domain: kv.CommitmentDomain, + key: key, + value: value, + step: 1, + } + tx := &temporalTxWithAgg{ + TemporalTx: roTx, + agg: agg, + debug: &exactVisibleDebug{TemporalDebugTx: roTx.Debug()}, + } + parent, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer parent.Close() + child, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer child.Close() + got, step, err := child.GetLatest(kv.CommitmentDomain, tx, key) + require.NoError(t, err) + require.Equal(t, value, got) + require.Equal(t, kv.Step(1), step) + _, _, ok := view.Get(key) + require.True(t, ok, "the setup must admit an unbounded read-through fill") + branchCache.Invalidate(key) + child.SetParent(parent) + + stepBytes := make([]byte, 8) + binary.BigEndian.PutUint64(stepBytes, ^uint64(1)) + var diffs [kv.DomainLen][]kv.DomainEntryDiff + diffs[kv.CommitmentDomain] = []kv.DomainEntryDiff{{Key: string(key) + string(stepBytes)}} + parent.Unwind(0, &diffs) + + got, step, err = child.GetLatest(kv.CommitmentDomain, tx, key) + require.NoError(t, err) + require.Equal(t, value, got) + require.Equal(t, kv.Step(1), step) + require.Equal(t, kv.Step(1), agg.maxStep) + _, _, ok = view.Get(key) + require.False(t, ok, "a bounded historical branch must not enter the latest-branch cache") +} + // Commit, unlike Flush, publishes the rebuilt branch after the database commit. func TestBranchCacheCommitRefreshesAfterReadThrough(t *testing.T) { stepSize := uint64(100) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 5fef767015c..180594f951f 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1406,13 +1406,17 @@ 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 cache - // publication. - if sd.stateCache != nil && sd.stateCache.Caches(domain) { - views.state.Fill(domain, k, v, step) - } - if domain == kv.CommitmentDomain && sd.branchCache != nil { - views.branch.Fill(k, v, uint64(step)) + // A bounded fall-through may intentionally return historical state. Even + // when its step satisfies the bound, it must not enter a latest-state cache. + if maxStep == kv.NoStepBound { + // View freshness is rechecked while the fill is serialized against cache + // publication. + if sd.stateCache != nil && sd.stateCache.Caches(domain) { + views.state.Fill(domain, k, v, step) + } + if domain == kv.CommitmentDomain && sd.branchCache != nil { + views.branch.Fill(k, v, uint64(step)) + } } return v, step, nil diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 59d3f790cd9..7fb23b60501 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -226,6 +226,49 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { require.False(t, ok, "the detached SharedDomains must not fill from its rewound database view") } +func TestBoundedReadDoesNotFillStateCache(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + key := make([]byte, 20) + key[0] = 0xaa + value := encAccount(1) + generation := currentStateCacheGeneration(t, db) + stateCache.Publisher().Initialize(generation) + view := stateCache.View(generation) + + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + agg := &boundedLatestAgg{domain: kv.AccountsDomain, key: key, value: value, step: 1} + tx := &temporalTxWithAgg{TemporalTx: roTx, agg: agg} + parent, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer parent.Close() + child, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer child.Close() + child.SetStateCacheReaderForTest(stateCache) + child.SetParent(parent) + + 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)}} + parent.Unwind(0, &diffs) + + got, step, err := child.GetLatest(kv.AccountsDomain, tx, key) + require.NoError(t, err) + require.Equal(t, value, got) + require.Equal(t, kv.Step(1), step) + require.Equal(t, kv.Step(1), agg.maxStep) + _, ok := view.Get(kv.AccountsDomain, key) + require.False(t, ok, "a bounded historical value must not enter the latest-state cache") +} + func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { t.Parallel() From cb74b08c41ebf59c9db87da32852335b4a04dba9 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:37:52 +0200 Subject: [PATCH 30/50] execution/cache, commitment, db/state: handle file-view lowering --- db/state/aggregator.go | 30 +++----------- db/state/aggregator_align_test.go | 39 ++++++++++++++----- db/state/execctx/branch_cache_flush_test.go | 30 +++++++++++++- db/state/execctx/domain_shared.go | 11 ------ db/state/execctx/options.go | 4 +- execution/cache/generation_gate.go | 21 ++++++---- execution/cache/state_cache.go | 6 ++- execution/commitment/branch_cache.go | 6 ++- .../commitment/branch_cache_absorb_test.go | 39 +++++++++++++++++++ 9 files changed, 129 insertions(+), 57 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index acb561c2e20..e2b7301ebce 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -91,9 +91,9 @@ type Aggregator struct { // regenerates them. Guarded by dirtyFilesLock. unalignedDomain [kv.DomainLen]bool unalignedIdx [kv.StandaloneIdxLen]bool - // Cache provenance and exact-view eligibility assume that visible values and - // history-II ends only advance. Close clears this guard because shutdown is - // not a cache-read window. + // StateCache provenance and exact-view eligibility assume that visible state + // values and history-II ends only advance. Close clears this guard because + // shutdown is not a cache-read window. visibilityLoweringForbidden atomic.Bool // boundStateCache is reconciled before a new files view becomes visible. // Guarded by dirtyFilesLock. @@ -551,20 +551,6 @@ func (a *Aggregator) UnalignIdx(name kv.InvertedIdx) (realign func()) { return func() {} } -// ForbidVisibilityLowering marks this aggregator as backing shared latest-state -// caches. It rejects lowering values-file ends because cache provenance only -// advances, and history-II ends because they determine exact cache-view -// eligibility. dirtyFilesLock orders the guard with a recalculation already in -// progress. -func (a *Aggregator) ForbidVisibilityLowering() { - if a.visibilityLoweringForbidden.Load() { - return - } - a.dirtyFilesLock.Lock() - defer a.dirtyFilesLock.Unlock() - a.visibilityLoweringForbidden.Store(true) -} - // BindStateCache prevents visibility lowering and reconciles the cache with // files that are already visible. Future file publications are reconciled by // recalcVisibleFiles before readers can observe their new backing view. @@ -1990,14 +1976,14 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { if a.visibilityLoweringForbidden.Load() { prev := a.visible.Load() - for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain, kv.CommitmentDomain} { + for _, d := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { if prev.d[d] == nil || next.d[d] == nil { continue } prevEnd := visibleFiles(prev.d[d].files).EndTxNum() nextEnd := visibleFiles(next.d[d].files).EndTxNum() if nextEnd < prevEnd { - panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while a shared cache is wired — file-provenance watermarks only advance", d, prevEnd, nextEnd)) + panic(fmt.Sprintf("assert: %s visible end lowered %d -> %d while StateCache is wired — file-provenance watermarks only advance", d, prevEnd, nextEnd)) } if prev.dhii[d] == nil || next.dhii[d] == nil { continue @@ -2005,7 +1991,7 @@ func (a *Aggregator) recalcVisibleFiles(retired retiredFiles) { prevII := prev.dhii[d].files.EndTxNum() nextII := next.dhii[d].files.EndTxNum() if nextII < prevII { - panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while a shared cache is wired — exact cache-view eligibility depends on history-II coverage", d, prevII, nextII)) + panic(fmt.Sprintf("assert: %s history-II visible end lowered %d -> %d while StateCache is wired — exact cache-view eligibility depends on history-II coverage", d, prevII, nextII)) } } } @@ -2768,10 +2754,6 @@ func (at *AggregatorRoTx) MetricsCollector() *kvmetrics.Collector { return at.a.metricsCollector } -func (at *AggregatorRoTx) ForbidVisibilityLowering() { - at.a.ForbidVisibilityLowering() -} - func (at *AggregatorRoTx) BindStateCache(stateCache *cache.StateCache) { at.a.BindStateCache(stateCache) } diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 94849a33854..86c5b0c70d9 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -62,6 +62,13 @@ type cacheAggregatorHolder struct{ agg *Aggregator } func (h cacheAggregatorHolder) Agg() any { return h.agg } +func bindTestStateCache(t *testing.T, agg *Aggregator) { + t.Helper() + stateCache := cache.NewStateCache(1<<20, 1<<20, 1<<20, 1<<20) + t.Cleanup(stateCache.Close) + agg.BindStateCache(stateCache) +} + // state visible past commitment's files = state no commitment covers func TestVisibleFilesAligned_LaggingCommitmentClampsEveryone(t *testing.T) { t.Parallel() @@ -173,7 +180,7 @@ func TestUnalign_RejectsStateDomain(t *testing.T) { // aggregator; the transition that lowers a cached state domain's visible end // (here: realigning while receipt still lags, which drops the shared ceiling) // must panic, whichever entry point caused it. -func TestVisibilityLowering_ForbiddenAggregatorPanicsOnLoweringOnly(t *testing.T) { +func TestVisibilityLowering_StateCachePanicsOnStateLoweringOnly(t *testing.T) { t.Parallel() _, agg := testDbAndAggregatorv3(t, alignStepSize) @@ -183,7 +190,7 @@ func TestVisibilityLowering_ForbiddenAggregatorPanicsOnLoweringOnly(t *testing.T generateDomainFiles(t, "receipt", agg.Dirs(), []testFileRange{{0, 1}}) require.NoError(t, agg.OpenFolder()) - agg.ForbidVisibilityLowering() + bindTestStateCache(t, agg) realign := agg.Unalign(kv.ReceiptDomain) // raises the ceiling: allowed require.Panics(t, func() { realign() }, "realigning a still-lagging receipt lowers the state domains' ends") } @@ -195,7 +202,7 @@ func TestCloseDirtyFilesNoReopenRestoresVisibilityLoweringGuard(t *testing.T) { t.Run(fmt.Sprintf("initially_forbidden_%t", initiallyForbidden), func(t *testing.T) { _, agg := testDbAndAggregatorv3(t, alignStepSize) if initiallyForbidden { - agg.ForbidVisibilityLowering() + bindTestStateCache(t, agg) } agg.closeDirtyFilesNoReopen() @@ -255,7 +262,7 @@ func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { // The forbid assert must also watch the history-II ends: they are the base of // what DomainVisibleEnd reports, and with values dependency-clamped below the // ceiling they can lower while every values end stays put. -func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { +func TestVisibilityLowering_StateCacheGuardsHistoryIIEnd(t *testing.T) { t.Parallel() _, agg := testDbAndAggregatorv3(t, alignStepSize) @@ -265,7 +272,7 @@ func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { require.NoError(t, agg.OpenFolder()) craftedClampedVisible(t, agg) - agg.ForbidVisibilityLowering() + bindTestStateCache(t, agg) // Drop the accounts history-II {1,2} segment in memory rather than from // disk (Windows forbids removing a mapped file): the recalculation lowers @@ -286,7 +293,7 @@ func TestVisibilityLowering_GuardsHistoryIIEnd(t *testing.T) { "lowering a history-II end while values ends stay put must trip the forbid assert") } -func TestVisibilityLowering_GuardsCommitmentDomain(t *testing.T) { +func TestVisibilityLowering_StateCacheGuardAllowsCommitmentDomain(t *testing.T) { t.Parallel() _, agg := testDbAndAggregatorv3(t, alignStepSize) @@ -295,8 +302,18 @@ func TestVisibilityLowering_GuardsCommitmentDomain(t *testing.T) { generateStandaloneIIFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) require.NoError(t, agg.OpenFolder()) + agg.DisableAllDependencies() agg.Unalign(kv.CommitmentDomain) - agg.ForbidVisibilityLowering() + bindTestStateCache(t, agg) + branchCache := agg.d[kv.CommitmentDomain].branchCache + require.NotNil(t, branchCache) + publisher := branchCache.Publisher() + publisher.Initialize(cache.BranchGeneration(1, 2*alignStepSize)) + key := []byte{0x01} + view := branchCache.View(cache.BranchGeneration(1, 2*alignStepSize)) + view.Fill(key, []byte{0xbb}, 1) + _, _, ok := view.Get(key) + require.True(t, ok) agg.dirtyFilesLock.Lock() defer agg.dirtyFilesLock.Unlock() @@ -310,8 +327,12 @@ func TestVisibilityLowering_GuardsCommitmentDomain(t *testing.T) { }) require.Equal(t, 1, dropped) - require.Panics(t, func() { agg.recalcVisibleFiles(nil) }, - "BranchCache validity requires the commitment frontier to remain monotonic") + require.NotPanics(t, func() { agg.recalcVisibleFiles(nil) }, + "the StateCache guard must not reject a safe BranchCache reset") + _, _, ok = view.Get(key) + require.False(t, ok, "the old commitment-files generation must be revoked") + _, _, ok = branchCache.View(cache.BranchGeneration(1, alignStepSize)).Get(key) + require.False(t, ok, "branches from the removed commitment file must be cleared") } func TestFilePublicationRevokesCacheGenerations(t *testing.T) { diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index c86f5a193d4..a537c05773e 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -52,6 +52,12 @@ type exactVisibleDebug struct{ kv.TemporalDebugTx } func (*exactVisibleDebug) HasExactDomainVisibleEnd(kv.Domain) bool { return true } +type branchCacheOnlyAgg struct { + branchCache *commitment.BranchCache +} + +func (a *branchCacheOnlyAgg) BranchCache() *commitment.BranchCache { return a.branchCache } + type boundedLatestAgg struct { branchCache *commitment.BranchCache domain kv.Domain @@ -62,7 +68,6 @@ type boundedLatestAgg struct { } func (a *boundedLatestAgg) BranchCache() *commitment.BranchCache { return a.branchCache } -func (a *boundedLatestAgg) ForbidVisibilityLowering() {} func (a *boundedLatestAgg) MeteredGetLatest(domain kv.Domain, key []byte, _ kv.Tx, maxStep kv.Step, _ *kvmetrics.DomainMetrics, _ time.Time) ([]byte, kv.Step, bool, error) { if domain != a.domain || !bytes.Equal(key, a.key) { @@ -72,6 +77,29 @@ func (a *boundedLatestAgg) MeteredGetLatest(domain kv.Domain, key []byte, _ kv.T return a.value, a.step, true, nil } +func TestSharedBranchCacheDoesNotRequireVisibilityGuard(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + branchCache := commitment.NewBranchCache(64) + t.Cleanup(branchCache.Close) + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + tx := &temporalTxWithAgg{ + TemporalTx: roTx, + agg: &branchCacheOnlyAgg{branchCache: branchCache}, + } + + var sd *execctx.SharedDomains + require.NotPanics(t, func() { + sd, err = execctx.NewSharedDomains(ctx, tx, log.New()) + }) + require.NoError(t, err) + sd.Close() +} + type commitErrorTx struct { kv.TemporalRwTx err error diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 180594f951f..8475651b896 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -344,9 +344,6 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, } sd.branchCache = branchCache sd.hasSharedBranchCache = branchCache != nil - if branchCache != nil { - forbidVisibilityLowering(tx.AggTx()) - } if p, ok := tx.AggTx().(kvmetrics.MetricsCollectorProvider); ok { sd.collector = p.MetricsCollector() } @@ -933,14 +930,6 @@ func BindStateCacheToAggregator(db any, sc *cache.StateCache) { b.BindStateCache(sc) } -func forbidVisibilityLowering(agg any) { - f, ok := agg.(interface{ ForbidVisibilityLowering() }) - if !ok { - panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) - } - f.ForbidVisibilityLowering() -} - // SetCodeStore sets the persistent codehash-keyed code cache. func (sd *SharedDomains) SetCodeStore(codeStore *cache.CodeStore) { sd.codeStore = codeStore diff --git a/db/state/execctx/options.go b/db/state/execctx/options.go index 850d05e1562..853ad487c54 100644 --- a/db/state/execctx/options.go +++ b/db/state/execctx/options.go @@ -36,9 +36,7 @@ func WithoutDeferredBranchUpdates() SharedDomainOption { return func(o *sharedDomainOptions) { o.trieCfg.DeferBranchUpdates = false } } -// WithoutSharedBranchCache disables BranchCache use and the monotonic -// visibility guard it requires. Use it when tooling intentionally lowers or -// rebuilds the commitment-file frontier. +// WithoutSharedBranchCache keeps commitment reads within the transaction snapshot. func WithoutSharedBranchCache() SharedDomainOption { return func(o *sharedDomainOptions) { o.useSharedBranchCache = false } } diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 2b16acae80e..fd7203d89e9 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -31,6 +31,13 @@ type FilesView struct { commitmentEnd uint64 } +func (v FilesView) lowerThan(previous FilesView) bool { + return v.accountsEnd < previous.accountsEnd || + v.storageEnd < previous.storageEnd || + v.codeEnd < previous.codeEnd || + v.commitmentEnd < previous.commitmentEnd +} + func stateFilesView(accountsEnd, storageEnd, codeEnd uint64) FilesView { return FilesView{accountsEnd: accountsEnd, storageEnd: storageEnd, codeEnd: codeEnd} } @@ -269,12 +276,11 @@ type BackingChange struct { } // BeginBackingChange runs reconcile while publications and fills are blocked. -// It always revokes an active generation when its files identity changes, but -// clears entries only when reconcile cannot prove that the cache's publication -// history covers the new files. The returned handle keeps publication blocked -// until Finish makes both the new files and their matching cache generation -// observable. -func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func() bool, clear func()) *BackingChange { +// It revokes the active generation when the files identity changes and clears +// entries when reconcile reports incompatibility. The callback receives whether +// a file end moved backwards, which also requires resetting forward provenance. +// Finish publishes the matching generation after the new files become visible. +func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func(lowered bool) bool, clear func()) *BackingChange { if p.gate == nil { return nil } @@ -289,7 +295,8 @@ func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func( } }() - incompatible := reconcile != nil && reconcile() + lowered := gate.filesKnown && files.lowerThan(gate.files) + incompatible := reconcile != nil && reconcile(lowered) current := gate.current.Load() gate.files = files gate.filesKnown = true diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 7e01f63c3fd..8483cc1340d 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -105,7 +105,11 @@ func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *Backi filesEnd[kv.StorageDomain], filesEnd[kv.CodeDomain], ) - return c.generation.Publisher().BeginBackingChange(files, func() bool { + return c.generation.Publisher().BeginBackingChange(files, func(lowered bool) bool { + if lowered { + c.committedTxNumEnd = filesEnd + return true + } extended := false for domain, cache := range c.caches { if cache == nil || filesEnd[domain] <= c.committedTxNumEnd[domain] { diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index fbbc9969564..b6a085f8b8c 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -373,7 +373,11 @@ func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChang if c == nil { return nil } - return c.generation.Publisher().BeginBackingChange(cache.BranchFilesView(filesEnd), func() bool { + return c.generation.Publisher().BeginBackingChange(cache.BranchFilesView(filesEnd), func(lowered bool) bool { + if lowered { + c.committedTxNumEnd = filesEnd + return true + } if filesEnd <= c.committedTxNumEnd { return false } diff --git a/execution/commitment/branch_cache_absorb_test.go b/execution/commitment/branch_cache_absorb_test.go index 0e4f767bf9c..048198c393d 100644 --- a/execution/commitment/branch_cache_absorb_test.go +++ b/execution/commitment/branch_cache_absorb_test.go @@ -73,6 +73,45 @@ func TestBranchCacheFilesPublication(t *testing.T) { require.True(t, ok, "an already absorbed files view must not clear again") } +func TestBranchCacheFilesPublicationClearsOnLowerEnd(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + publisher := branchCache.Publisher() + publisher.Initialize(testBranchGeneration(1)) + key := []byte{0x01} + value := []byte{0xbb} + + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ + Key: key, + Value: value, + Step: 1, + TxNum: 100, + }}, false, nil) + + change := branchCache.BeginFilesPublication(100) + require.NotNil(t, change) + change.Finish() + view := branchCache.View(cache.BranchGeneration(2, 100)) + got, _, ok := view.Get(key) + require.True(t, ok) + require.Equal(t, value, got) + + change = branchCache.BeginFilesPublication(50) + require.NotNil(t, change) + change.Finish() + lowered := branchCache.View(cache.BranchGeneration(2, 50)) + _, _, ok = lowered.Get(key) + require.False(t, ok, "branches from files that are no longer visible must be cleared") + + lowered.Fill(key, []byte{0xcc}, 2) + change = branchCache.BeginFilesPublication(75) + require.NotNil(t, change) + change.Finish() + _, _, ok = branchCache.View(cache.BranchGeneration(2, 75)).Get(key) + require.False(t, ok, "an extension after lowering must not reuse the old forward-coverage watermark") +} + func TestBranchCacheCanonicalClearResetsFileProvenance(t *testing.T) { branchCache, publisher, key := branchCacheWithPublishedCoverage(t) publication := publisher.Begin() From e01be8f509ee8a0224361609e0d10458b5991774 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:27:38 +0200 Subject: [PATCH 31/50] execution/commitment: make storage trunk creation atomic --- execution/commitment/branch_cache.go | 2 +- execution/commitment/branch_cache_test.go | 45 +++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index b6a085f8b8c..bd054c26b24 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -481,7 +481,7 @@ func (c *BranchCache) storageRoute(prefix []byte, create bool) (st *trunk, acct return nil, packed, stor, false } st = newStorageTrunk(c.maxDepth) - c.pinnedForWrite().Set(packed, st) + st, _ = c.pinnedForWrite().LoadOrStore(packed, st) return st, packed, stor, true } diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index dd6a7729f6a..9c440081dc0 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/require" "github.com/erigontech/erigon/execution/cache" + "github.com/erigontech/erigon/execution/commitment/nibbles" ) func testBranchGeneration(stateVersion uint64) cache.Generation { @@ -77,6 +78,50 @@ func TestBranchCache_StorageTrunkPin(t *testing.T) { require.Equal(t, uint64(1), c.pinnedHits.Load()) } +func TestBranchCache_ConcurrentFirstPinsShareStorageTrunk(t *testing.T) { + c := NewBranchCache(100) + defer c.Close() + + path := make([]byte, 65) + path[64] = 1 + prefixA := nibbles.HexToCompact(path) + path[64] = 2 + prefixB := nibbles.HexToCompact(path) + + previousProcs := runtime.GOMAXPROCS(1) + defer runtime.GOMAXPROCS(previousProcs) + + c.pinnedMu.Lock() + var writers sync.WaitGroup + pin := func(started chan<- struct{}, prefix, value []byte) { + defer writers.Done() + close(started) + c.PinEntry(prefix, value, 0) + } + + started := make(chan struct{}) + writers.Add(1) + go pin(started, prefixA, []byte("a")) + <-started + runtime.Gosched() + + started = make(chan struct{}) + writers.Add(1) + go pin(started, prefixB, []byte("b")) + <-started + runtime.Gosched() + + c.pinnedMu.Unlock() + writers.Wait() + + got, _, ok := c.Get(prefixA) + require.True(t, ok) + require.Equal(t, []byte("a"), got) + got, _, ok = c.Get(prefixB) + require.True(t, ok) + require.Equal(t, []byte("b"), got) +} + // TestBranchCache_RootPinning verifies the root branch lands in the pinned // slot (counted as root-hit) and tail entries land in the LRU tier // (counted as tail-hit). From e7171461a5fea867a55b4ac0d34499f407f19081 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:43:15 +0200 Subject: [PATCH 32/50] execution/cache, commitment: make generation publication panic-safe --- execution/cache/generation_gate.go | 17 ++++-- execution/cache/generation_gate_test.go | 72 +++++++++++++++++++++++ execution/cache/state_cache.go | 2 +- execution/commitment/branch_cache_view.go | 2 +- 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 execution/cache/generation_gate_test.go diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index fd7203d89e9..e9f4b15de21 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -212,6 +212,7 @@ func (p *GenerationPublication) Abort() { return } gate := p.gate + defer func() { p.gate = nil }() gate.admissionMu.Lock() defer gate.publicationMu.Unlock() defer gate.admissionMu.Unlock() @@ -219,24 +220,30 @@ func (p *GenerationPublication) Abort() { panic("cache generation publication changed before abort") } gate.current.Store(p.previous) - p.gate = nil } // Publish applies the committed cache transition before exposing identity. The // state version comes from identity, while the files view is replaced by the -// newest backing view known to the gate. This prevents a transaction opened -// before a files publication from restoring its older files identity. -func (p *GenerationPublication) Publish(identity Generation, apply func()) { +// newest backing view known to the gate. If publication fails, clear runs while +// fills remain blocked and the gate stays unpublished. +func (p *GenerationPublication) Publish(identity Generation, apply, clear func()) { if p == nil || p.gate == nil { return } gate := p.gate + defer func() { p.gate = nil }() gate.admissionMu.Lock() defer gate.publicationMu.Unlock() defer gate.admissionMu.Unlock() if gate.current.Load() != nil { panic("cache generation publication changed before publish") } + completed := false + defer func() { + if !completed && clear != nil { + clear() + } + }() if apply != nil { apply() } @@ -247,7 +254,7 @@ func (p *GenerationPublication) Publish(identity Generation, apply func()) { gate.filesKnown = true } gate.current.Store(&publishedGeneration{identity: identity}) - p.gate = nil + completed = true } // Reset revokes all views, clears the cache, and leaves it unpublished. The diff --git a/execution/cache/generation_gate_test.go b/execution/cache/generation_gate_test.go new file mode 100644 index 00000000000..d233b27942e --- /dev/null +++ b/execution/cache/generation_gate_test.go @@ -0,0 +1,72 @@ +// 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 cache + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerationPublicationApplyPanicConsumesPublication(t *testing.T) { + var gate GenerationGate + publisher := gate.Publisher() + initial := StateGeneration(1, 0, 0, 0) + publisher.Initialize(initial, nil) + view := gate.View(initial) + require.True(t, view.Current()) + + publication := publisher.Begin() + var recovered any + func() { + defer func() { recovered = recover() }() + publication.Publish(StateGeneration(2, 0, 0, 0), func() { + panic("apply failed") + }, nil) + }() + + require.Equal(t, "apply failed", recovered) + require.Nil(t, publication.gate) + require.False(t, view.Current()) + + publication.Abort() + next := publisher.Begin() + next.Abort() +} + +func TestGenerationPublicationApplyPanicClearsPartialChanges(t *testing.T) { + var gate GenerationGate + publisher := gate.Publisher() + publisher.Initialize(StateGeneration(1, 0, 0, 0), nil) + publication := publisher.Begin() + + entries := []string{"old"} + var recovered any + func() { + defer func() { recovered = recover() }() + publication.Publish(StateGeneration(2, 0, 0, 0), func() { + entries = append(entries, "partial") + panic("apply failed") + }, func() { + entries = nil + }) + }() + + require.Equal(t, "apply failed", recovered) + require.Empty(t, entries) + require.False(t, gate.View(StateGeneration(2, 0, 0, 0)).Current()) +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index d621f557175..99013cad1d8 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -412,6 +412,6 @@ func (p *Publication) Publish(generation Generation, updates []Update, clear boo for i := range updates { p.c.applyLocked(updates[i]) } - }) + }, p.c.resetProvenanceAndClearLocked) p.c = nil } diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 39d1806c46f..3e5a61aae81 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -141,6 +141,6 @@ func (p *BranchPublication) Publish(generation cache.Generation, updates []Branc } p.c.Put(update.Key, update.Value, update.Step) } - }) + }, p.c.resetProvenanceAndClear) p.c = nil } From 342043536974b514e6f1e826dcb4d7fe4d1a2561 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:06:40 +0200 Subject: [PATCH 33/50] execution/cache, commitment: make cache closure permanent --- execution/cache/cache_test.go | 14 ++++++ execution/cache/generation_gate.go | 35 +++++++++++-- execution/cache/state_cache.go | 14 ++++-- execution/commitment/branch_cache.go | 20 ++++---- execution/commitment/branch_cache_test.go | 60 +++++++++++++++++++++++ execution/commitment/branch_cache_view.go | 6 ++- 6 files changed, 128 insertions(+), 21 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 5d982603bd8..368b53ff349 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -456,6 +456,20 @@ func TestStateCache_NewDefaultStateCache(t *testing.T) { assert.NotNil(t, c.getCache(kv.CodeDomain)) } +func TestStateCache_ClosePreventsPublication(t *testing.T) { + c, publisher := readyStateCache(t, 1) + c.Close() + + publisher.Initialize(testStateGeneration(2)) + require.False(t, c.View(testStateGeneration(2)).current()) + publication := publisher.Begin() + publication.Abort() + require.Nil(t, publication) + change := c.BeginFilesPublication([kv.DomainLen]uint64{kv.AccountsDomain: 1}) + change.Finish() + require.Nil(t, change) +} + func TestStateCache_GetPut_Account(t *testing.T) { c, _ := readyStateCache(t, 1) view := c.View(testStateGeneration(1)) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index e9f4b15de21..925b72deacc 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -86,6 +86,9 @@ type GenerationGate struct { // publicationMu orders durable cache publication with independent changes // to the backing-file view. Begin holds it until Publish or Abort. publicationMu sync.Mutex + // closed is permanent and protected by publicationMu. Publisher operations + // become inert after Close, including through handles created beforehand. + closed bool // files remembers the latest publication even while no durable generation // is active, so a later commit cannot restore an older transaction's view. files FilesView @@ -150,6 +153,9 @@ func (p GenerationPublisher) Initialize(identity Generation, clear func()) { gate := p.gate gate.publicationMu.Lock() defer gate.publicationMu.Unlock() + if gate.closed { + return + } gate.admissionMu.Lock() defer gate.admissionMu.Unlock() @@ -186,6 +192,10 @@ func (p GenerationPublisher) Begin() *GenerationPublication { } gate := p.gate gate.publicationMu.Lock() + if gate.closed { + gate.publicationMu.Unlock() + return nil + } gate.admissionMu.Lock() defer gate.admissionMu.Unlock() @@ -265,6 +275,9 @@ func (g *GenerationGate) Reset(clear func()) { } g.publicationMu.Lock() defer g.publicationMu.Unlock() + if g.closed { + return + } g.admissionMu.Lock() defer g.admissionMu.Unlock() g.current.Store(nil) @@ -293,6 +306,10 @@ func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func( } gate := p.gate gate.publicationMu.Lock() + if gate.closed { + gate.publicationMu.Unlock() + return nil + } gate.admissionMu.Lock() keepPublicationLocked := false defer func() { @@ -340,15 +357,23 @@ func (c *BackingChange) Finish() { c.gate = nil } -// Close waits for in-flight fills and revokes current views before the owner -// closes cache storage. -func (g *GenerationGate) Close() { +// Close permanently revokes publication and runs clear while publications and +// fills remain blocked. It reports whether this call closed the gate. +func (g *GenerationGate) Close(clear func()) bool { if g == nil { - return + return false } g.publicationMu.Lock() defer g.publicationMu.Unlock() + if g.closed { + return false + } g.admissionMu.Lock() + defer g.admissionMu.Unlock() + g.closed = true g.current.Store(nil) - g.admissionMu.Unlock() + if clear != nil { + clear() + } + return true } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 99013cad1d8..ebd648506c3 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -287,10 +287,12 @@ func (c *StateCache) Reset() { c.generation.Reset(c.resetProvenanceAndClearLocked) } -// Close revokes current views and releases the sub-caches' shared-envelope -// reservations. It is idempotent. +// Close permanently revokes publication, clears entries, and releases the +// sub-caches' shared-envelope reservations. It is idempotent. func (c *StateCache) Close() { - c.generation.Close() + if !c.generation.Close(c.resetProvenanceAndClearLocked) { + return + } for _, cache := range c.caches { if cache != nil { cache.Close() @@ -378,7 +380,11 @@ func (p Publisher) Begin() *Publication { if p.c == nil { return nil } - return &Publication{c: p.c, generation: p.c.generation.Publisher().Begin()} + generation := p.c.generation.Publisher().Begin() + if generation == nil { + return nil + } + return &Publication{c: p.c, generation: generation} } // Abort restores the previous generation after a failed or abandoned database diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index bd054c26b24..ebbe320a1a5 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -95,9 +95,8 @@ type BranchCache struct { // maxDepth is the resident trunk depth for this cache (both the account trunk // and every pinned storage trunk), chosen from the active-instance count at - // construction. closed guards the single paired active-count decrement. + // construction. maxDepth uint8 - closed atomic.Bool // trunkDisabled (env BRANCH_CACHE_TRUNK_DISABLE) routes depth-1-4 account // branches back to the LRU tail instead of the resident account trunk — a @@ -341,17 +340,16 @@ func NewBranchCache(tailCapacity int) *BranchCache { return bc } -// Close revokes all views, releases cached entries, and drops this cache from -// the active-instance count. Idempotent. +// Close permanently revokes publication, clears entries, and drops this cache +// from the active-instance count. It is idempotent. func (c *BranchCache) Close() { - c.generation.Close() - if c.closed.CompareAndSwap(false, true) { - c.resetProvenanceAndClear() - if t := c.tail.Load(); t != nil { - t.Close() - } - activeBranchCaches.Add(-1) + if !c.generation.Close(c.resetProvenanceAndClear) { + return + } + if t := c.tail.Load(); t != nil { + t.Close() } + activeBranchCaches.Add(-1) } // Reset clears cached branches and revokes all views until the next durable diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index 9c440081dc0..c0c174452d7 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -219,6 +219,66 @@ func TestBranchCache_CloseClearsEntries(t *testing.T) { require.False(t, ok) } +func TestBranchCache_ClosePreventsPublication(t *testing.T) { + c := NewBranchCache(100) + publisher := c.Publisher() + publisher.Initialize(testBranchGeneration(1)) + c.Close() + + publisher.Initialize(testBranchGeneration(2)) + require.False(t, c.View(testBranchGeneration(2)).current()) + publication := publisher.Begin() + publication.Abort() + require.Nil(t, publication) + change := c.BeginFilesPublication(1) + change.Finish() + require.Nil(t, change) +} + +func TestBranchCache_CloseKeepsPublicationLockedWhileClearing(t *testing.T) { + c := NewBranchCache(100) + publisher := c.Publisher() + publisher.Initialize(testBranchGeneration(1)) + + previousProcs := runtime.GOMAXPROCS(1) + defer runtime.GOMAXPROCS(previousProcs) + + c.putStripes[0].Lock() + closeStarted := make(chan struct{}) + closeDone := make(chan struct{}) + go func() { + close(closeStarted) + c.Close() + close(closeDone) + }() + <-closeStarted + runtime.Gosched() + + beginStarted := make(chan struct{}) + beginResult := make(chan *BranchPublication, 1) + go func() { + close(beginStarted) + beginResult <- publisher.Begin() + }() + <-beginStarted + runtime.Gosched() + + publicationBeganDuringClear := false + select { + case publication := <-beginResult: + publicationBeganDuringClear = true + publication.Abort() + default: + } + + c.putStripes[0].Unlock() + <-closeDone + if !publicationBeganDuringClear { + require.Nil(t, <-beginResult) + } + require.False(t, publicationBeganDuringClear) +} + func resetDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func()) { // Limit Go execution to one logical processor. Each runtime.Gosched call // yields to the queued goroutine, which runs until it reaches the blocked lock. diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 3e5a61aae81..e26a7d1bda0 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -105,7 +105,11 @@ func (p BranchPublisher) Begin() *BranchPublication { if p.c == nil { return nil } - return &BranchPublication{c: p.c, generation: p.c.generation.Publisher().Begin()} + generation := p.c.generation.Publisher().Begin() + if generation == nil { + return nil + } + return &BranchPublication{c: p.c, generation: generation} } // Abort restores the previous branch generation after database rollback. From cf5a0e43d96c4b302389ef851fa7a54a5dc85a88 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:16:19 +0200 Subject: [PATCH 34/50] execution/commitment: recover adaptive planning after panic --- execution/commitment/adaptive_pin.go | 44 ++++++++++++++++------- execution/commitment/adaptive_pin_test.go | 27 ++++++++++++++ 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index 059a74aa1f5..c86a8fca1d5 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -306,34 +306,45 @@ func (c *AdaptivePinController) onCacheMiss(prefix []byte) { // keeps controller updates serialized until Commit or Abort. Publication // discards it if sourceGeneration or the cache clear epoch changed meanwhile. // An already-stale source returns no plan and leaves its misses for a fresh -// transaction instead of doing work that cannot be published. +// transaction instead of doing work that cannot be published. A planning panic +// restores the controller before it continues unwinding. func (c *AdaptivePinController) PlanBlock( txNum uint64, sourceGeneration cache.Generation, reader CommitmentReader, factory ParallelResolverFactory, provider DbBranchesProvider, -) *AdaptivePinPlan { +) (plan *AdaptivePinPlan) { c.mu.Lock() + lockTransferred := false + defer func() { + if lockTransferred { + return + } + if plan != nil { + plan.discard() + return + } + c.mu.Unlock() + }() c.syncCacheClearLocked() source := c.cache.generation.View(sourceGeneration) if !source.Current() { - c.mu.Unlock() return nil } previousStates := c.states - c.states = cloneAdaptiveStateHeaders(previousStates) - misses := c.snapshotMisses() - observedMisses := make(map[[32]byte]uint64, len(misses)) - maps.Copy(observedMisses, misses) - plan := &AdaptivePinPlan{ + plan = &AdaptivePinPlan{ controller: c, previousStates: previousStates, - observedMisses: observedMisses, source: source, cacheClearEpoch: c.cacheClearEpoch, txNum: txNum, } + c.states = cloneAdaptiveStateHeaders(previousStates) + misses := c.snapshotMisses() + observedMisses := make(map[[32]byte]uint64, len(misses)) + maps.Copy(observedMisses, misses) + plan.observedMisses = observedMisses // One factory call per block, shared across all contracts. nil falls back to serial. var parallelResolve BatchBranchResolver @@ -347,9 +358,17 @@ func (c *AdaptivePinController) PlanBlock( releaseParallel = release } } - if releaseParallel != nil { - defer releaseParallel() - } + // The returned plan takes ownership of c.mu only after resolver cleanup + // succeeds. Every earlier exit is handled by the discard defer above. + planningComplete := false + defer func() { + if releaseParallel != nil { + releaseParallel() + } + if planningComplete { + lockTransferred = true + } + }() for hash, state := range c.states { n, hadMisses := misses[hash] @@ -388,6 +407,7 @@ func (c *AdaptivePinController) PlanBlock( } } + planningComplete = true return plan } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index 26cd5d57f61..beb2b238cea 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -171,6 +171,33 @@ func TestAdaptivePinPlanSkipsStaleSourceAfterFilesPublication(t *testing.T) { require.Zero(t, readerCalls, "a plan that cannot be published must not scan branches") } +func TestAdaptivePinPlanPanicRestoresController(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + branchCache.Publisher().Initialize(testBranchGeneration(1)) + controller := NewAdaptivePinController(branchCache, DefaultAdaptivePinControllerConfig(), log.Root()) + + var contractHash [32]byte + contractHash[0] = 1 + previousState := &adaptiveContractState{contractHash: contractHash} + controller.states[contractHash] = previousState + controller.onCacheMiss(nibbles.HexToCompact(ContractNibbles(contractHash[:]))) + + var recovered any + func() { + defer func() { recovered = recover() }() + controller.PlanBlock(1, testBranchGeneration(1), nil, func() (BatchBranchResolver, func(), error) { + panic("factory failed") + }, nil) + }() + + require.Equal(t, "factory failed", recovered) + require.True(t, controller.mu.TryLock(), "planning panic must release the controller") + controller.mu.Unlock() + require.Same(t, previousState, controller.states[contractHash]) + require.Equal(t, uint64(1), controller.snapshotMisses()[contractHash]) +} + func TestAdaptivePinControllerForgetsPinsClearedByFilesPublication(t *testing.T) { branchCache := NewBranchCache(64) t.Cleanup(branchCache.Close) From 526407ae698331c80062d33c757051a61552b7de Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:57:49 +0200 Subject: [PATCH 35/50] execution/cache, db/state: cover every domain at canonical commit --- db/state/execctx/domain_shared.go | 8 ++-- db/state/execctx/statecache_readfill_test.go | 4 +- .../statecache_rpc_integration_test.go | 2 +- execution/cache/cache_test.go | 26 +++++----- execution/cache/files_publication_test.go | 43 ++++++++++++++--- execution/cache/state_cache.go | 47 ++++++++++--------- execution/exec/blocks_read_ahead_test.go | 4 +- .../rawdbreset/reset_stages_test.go | 4 +- 8 files changed, 86 insertions(+), 52 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 2490bb8c186..b6c94c47738 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1097,13 +1097,12 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun var stateUpdates []cache.Update stashState := func(domain kv.Domain) kv.FlushOption { - return kv.WithFlushCallback(domain, func(key, value []byte, step kv.Step, txNum uint64) { + return kv.WithFlushCallback(domain, func(key, value []byte, step kv.Step, _ uint64) { stateUpdates = append(stateUpdates, cache.Update{ Domain: domain, Key: bytes.Clone(key), Value: bytes.Clone(value), Step: step, - TxNum: txNum, }) }) } @@ -1130,7 +1129,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun // corrupts it (reorg/unwind wrong root). var codeStoreWrites [][2][]byte if stateCacheEnabled || sd.codeStore != nil { - opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(key, value []byte, step kv.Step, txNum uint64) { + opts = append(opts, kv.WithFlushCallback(kv.CodeDomain, func(key, value []byte, step kv.Step, _ uint64) { if sd.codeStore != nil && len(value) > 0 { codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(value), bytes.Clone(value)}) } @@ -1140,7 +1139,6 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun Key: bytes.Clone(key), Value: bytes.Clone(value), Step: step, - TxNum: txNum, }) } })) @@ -1197,7 +1195,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun statePublication = sd.statePublisher.Begin() } - statePublication.Publish(nextCacheGenerations.state, stateUpdates, sd.clearExecutionCaches) + statePublication.Publish(nextCacheGenerations.state, sd.txNum+1, stateUpdates, sd.clearExecutionCaches) statePublication = nil branchPublication.Publish(nextCacheGenerations.branch, branchUpdates, sd.clearExecutionCaches, adaptivePlan) branchPublication = nil diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7fb23b60501..b921ad3d0c1 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -200,7 +200,7 @@ func TestReadFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { sc := newSmallStateCache() key, _, v2, diffs := twoStepRows(t, db, sc) generation := currentStateCacheGeneration(t, db) - sc.Publisher().Begin().Publish(generation, nil, true) + sc.Publisher().Begin().Publish(generation, 0, nil, true) durableView := sc.View(generation) sentinelKey := make([]byte, 20) sentinelKey[0] = 0xdd @@ -297,7 +297,7 @@ func TestCodeHashFill_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { require.NoError(t, sd.DomainPut(kv.AccountsDomain, rwTx, key, value, 20, nil)) require.NoError(t, sd.Commit(ctx, rwTx)) generation := currentStateCacheGeneration(t, db) - sc.Publisher().Begin().Publish(generation, nil, true) + sc.Publisher().Begin().Publish(generation, 0, nil, true) durableView := sc.View(generation) sentinelKey := make([]byte, 20) sentinelKey[0] = 0xee diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index a7ec257cfb9..53ff926c65f 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -328,7 +328,7 @@ func TestSharedDomainsSameDatabaseViewUsesReadTxFilesGeneration(t *testing.T) { freshDebug.TxNumsInFiles(kv.StorageDomain), freshDebug.TxNumsInFiles(kv.CodeDomain), ) - stateCache.Publisher().Begin().Publish(freshGeneration, nil, true) + stateCache.Publisher().Begin().Publish(freshGeneration, 0, nil, true) cacheOnlyValue := []byte{0xff} freshCacheView := stateCache.View(freshGeneration) freshCacheView.Fill(kv.AccountsDomain, key, cacheOnlyValue, 0) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index 368b53ff349..ae18a77a2e4 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -534,7 +534,7 @@ func TestStateCache_Delete(t *testing.T) { addr := makeAddr(1) c.View(testStateGeneration(1)).Fill(kv.AccountsDomain, addr, makeValue(1), 0) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(2), 0, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) _, ok := c.View(testStateGeneration(2)).Get(kv.AccountsDomain, addr) assert.False(t, ok) @@ -579,7 +579,7 @@ func TestStateCache_Delete_UnsupportedDomain(t *testing.T) { require.NotPanics(t, func() { publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{Domain: kv.ReceiptDomain, Key: makeAddr(1)}}, false) + publication.Publish(testStateGeneration(2), 0, []Update{{Domain: kv.ReceiptDomain, Key: makeAddr(1)}}, false) }) } @@ -592,7 +592,7 @@ func TestStateCache_Clear(t *testing.T) { view.Fill(kv.CodeDomain, makeAddr(3), makeCode(3), 0) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), nil, true) + publication.Publish(testStateGeneration(2), 0, nil, true) view = c.View(testStateGeneration(2)) _, ok1 := view.Get(kv.AccountsDomain, makeAddr(1)) @@ -794,7 +794,7 @@ func TestStateCache_UnwindRejectsPreReorgFill(t *testing.T) { preReorg.Fill(kv.AccountsDomain, key, fork, 10) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), nil, true) + publication.Publish(testStateGeneration(2), 0, nil, true) preReorg.Fill(kv.AccountsDomain, key, fork, 10) _, ok := sc.View(testStateGeneration(2)).Get(kv.AccountsDomain, key) @@ -812,7 +812,7 @@ func TestStateCache_PublicationIsOneGeneration(t *testing.T) { _, ok := oldView.Get(kv.AccountsDomain, oldKey) require.False(t, ok, "the old generation must be unavailable during publication") - publication.Publish(testStateGeneration(11), []Update{{ + publication.Publish(testStateGeneration(11), 0, []Update{{ Domain: kv.AccountsDomain, Key: changedKey, Value: makeValue(3), @@ -868,7 +868,7 @@ func TestStateCache_PublishDeleteAtomicWithOldFill(t *testing.T) { }) wg.Go(func() { publication := publisher.Begin() - publication.Publish(testStateGeneration(stateVersion+1), []Update{{ + publication.Publish(testStateGeneration(stateVersion+1), 0, []Update{{ Domain: kv.AccountsDomain, Key: key, Step: 2, @@ -891,7 +891,7 @@ func TestStateCache_ApplyCodeDeleteDropsAddrCodeHash(t *testing.T) { require.True(t, ok) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{Domain: kv.CodeDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(2), 0, []Update{{Domain: kv.CodeDomain, Key: addr}}, false) _, ok = sc.View(testStateGeneration(2)).GetAddrCodeHash(addr) require.False(t, ok, "a code deletion must drop the derived addr→codeHash mapping") } @@ -901,7 +901,7 @@ func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { addr := makeAddr(1) code := makeCode(1) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{ + publication.Publish(testStateGeneration(2), 0, []Update{{ Domain: kv.CodeDomain, Key: addr, Value: code, @@ -910,7 +910,7 @@ func TestStateCache_AccountDeleteDropsCodeBinding(t *testing.T) { require.True(t, ok) publication = publisher.Begin() - publication.Publish(testStateGeneration(3), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(3), 0, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) _, ok = sc.View(testStateGeneration(3)).Get(kv.CodeDomain, addr) require.False(t, ok, "an account deletion must drop the addr→code binding") } @@ -980,7 +980,7 @@ func TestStateCacheFillsSwitchDisablesReadFills(t *testing.T) { require.False(t, ok, "content-addressed fills must be disabled too: the switch means no reader writes at all") publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{ + publication.Publish(testStateGeneration(2), 0, []Update{{ Domain: kv.AccountsDomain, Key: key, Value: []byte("applied"), @@ -995,7 +995,7 @@ func TestStateCache_StaleViewCannotFillAfterClear(t *testing.T) { key := makeAddr(1) oldView := sc.View(testStateGeneration(1)) publication := publisher.Begin() - publication.Publish(testStateGeneration(1), nil, true) + publication.Publish(testStateGeneration(1), 0, nil, true) oldView.Fill(kv.AccountsDomain, key, []byte("pre-delete"), 10) freshView := sc.View(testStateGeneration(1)) @@ -1014,11 +1014,11 @@ func TestStateCache_AccountDeletionGatesStaleCodeFill(t *testing.T) { other, otherCode := makeAddr(2), makeCode(2) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) + publication.Publish(testStateGeneration(2), 0, []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) stale := c.View(testStateGeneration(2)) publication = publisher.Begin() - publication.Publish(testStateGeneration(3), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) + publication.Publish(testStateGeneration(3), 0, []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) stale.Fill(kv.CodeDomain, addr, code, 1) fresh := c.View(testStateGeneration(3)) _, ok := fresh.Get(kv.CodeDomain, addr) diff --git a/execution/cache/files_publication_test.go b/execution/cache/files_publication_test.go index 68ebb70e910..53aead3a9fc 100644 --- a/execution/cache/files_publication_test.go +++ b/execution/cache/files_publication_test.go @@ -30,12 +30,11 @@ func TestStateCacheFilesPublication(t *testing.T) { value := makeValue(1) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{ + publication.Publish(testStateGeneration(2), 101, []Update{{ Domain: kv.AccountsDomain, Key: key, Value: value, Step: 1, - TxNum: 100, }}, false) view := stateCache.View(testStateGeneration(2)) got, ok := view.Get(kv.AccountsDomain, key) @@ -63,7 +62,7 @@ func TestStateCacheFilesPublication(t *testing.T) { change.Finish() publication = publisher.Begin() - publication.Publish(StateGeneration(3, 150, 0, 0), nil, false) + publication.Publish(StateGeneration(3, 150, 0, 0), 150, nil, false) current := stateCache.View(StateGeneration(3, 150, 0, 0)) _, ok = current.Get(kv.AccountsDomain, key) require.False(t, ok, "the next commit must not reactivate entries from the old backing view") @@ -74,10 +73,43 @@ func TestStateCacheFilesPublication(t *testing.T) { require.True(t, ok, "an already absorbed files view must not clear again") } +func TestStateCacheFilesPublicationRetainsSparseDomainsCoveredByCommit(t *testing.T) { + stateCache, publisher := readyStateCache(t, 1) + accountKey := makeAddr(1) + accountValue := makeValue(1) + storageKey := append(makeAddr(2), make([]byte, 32)...) + storageValue := makeValue(2) + stateCache.View(testStateGeneration(1)).Fill(kv.StorageDomain, storageKey, storageValue, 0) + + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), 101, []Update{{ + Domain: kv.AccountsDomain, + Key: accountKey, + Value: accountValue, + Step: 1, + }}, false) + + var filesEnd [kv.DomainLen]uint64 + filesEnd[kv.AccountsDomain] = 101 + filesEnd[kv.StorageDomain] = 101 + filesEnd[kv.CodeDomain] = 101 + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + change.Finish() + + view := stateCache.View(StateGeneration(2, 101, 101, 101)) + got, ok := view.Get(kv.AccountsDomain, accountKey) + require.True(t, ok) + require.Equal(t, accountValue, got) + got, ok = view.Get(kv.StorageDomain, storageKey) + require.True(t, ok) + require.Equal(t, storageValue, got) +} + func TestStateCacheCanonicalClearResetsFileProvenance(t *testing.T) { stateCache, publisher, key := stateCacheWithPublishedCoverage(t) publication := publisher.Begin() - publication.Publish(testStateGeneration(3), nil, true) + publication.Publish(testStateGeneration(3), 0, nil, true) requireStateCacheForeignFilesClear(t, stateCache, key, 3) } @@ -92,12 +124,11 @@ func stateCacheWithPublishedCoverage(t *testing.T) (*StateCache, Publisher, []by stateCache, publisher := readyStateCache(t, 1) key := makeAddr(1) publication := publisher.Begin() - publication.Publish(testStateGeneration(2), []Update{{ + publication.Publish(testStateGeneration(2), 101, []Update{{ Domain: kv.AccountsDomain, Key: key, Value: makeValue(1), Step: 1, - TxNum: 100, }}, false) return stateCache, publisher, key } diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index ebd648506c3..b470ab4e370 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -44,12 +44,13 @@ const ( type StateCache struct { generation GenerationGate - // committedTxNumEnd is only a file-provenance watermark. Cache validity is - // decided by Generation; these ends distinguish files covered by published - // updates in the current canonical lineage from files downloaded outside it. - committedTxNumEnd [kv.DomainLen]uint64 - caches [kv.DomainLen]Cache - disableFills bool + // coveredTxNumEnd is only a file-provenance watermark. Cache validity is + // decided by Generation; canonical commits advance every cached domain, + // including domains with no writes, while incompatible file changes reset + // each end to the newly visible view. + coveredTxNumEnd [kv.DomainLen]uint64 + caches [kv.DomainLen]Cache + disableFills bool } func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { @@ -117,15 +118,15 @@ func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *Backi ) return c.generation.Publisher().BeginBackingChange(files, func(lowered bool) bool { if lowered { - c.committedTxNumEnd = filesEnd + c.coveredTxNumEnd = filesEnd return true } extended := false for domain, cache := range c.caches { - if cache == nil || filesEnd[domain] <= c.committedTxNumEnd[domain] { + if cache == nil || filesEnd[domain] <= c.coveredTxNumEnd[domain] { continue } - c.committedTxNumEnd[domain] = filesEnd[domain] + c.coveredTxNumEnd[domain] = filesEnd[domain] extended = true } return extended @@ -229,9 +230,6 @@ func (c *StateCache) applyLocked(update Update) { if cache == nil { return } - if committedEnd := update.TxNum + 1; committedEnd > c.committedTxNumEnd[update.Domain] { - c.committedTxNumEnd[update.Domain] = committedEnd - } switch update.Domain { case kv.AccountsDomain: @@ -257,6 +255,14 @@ func (c *StateCache) applyLocked(update Update) { } } +func (c *StateCache) coverCanonicalStateLocked(txNumEnd uint64) { + for domain, stateCache := range c.caches { + if stateCache != nil && txNumEnd > c.coveredTxNumEnd[domain] { + c.coveredTxNumEnd[domain] = txNumEnd + } + } +} + func putOrDelete(cache Cache, key, value []byte, step kv.Step) { if len(value) == 0 { cache.Delete(key) @@ -274,7 +280,7 @@ func (c *StateCache) clearLocked() { } func (c *StateCache) resetProvenanceAndClearLocked() { - c.committedTxNumEnd = [kv.DomainLen]uint64{} + c.coveredTxNumEnd = [kv.DomainLen]uint64{} c.clearLocked() } @@ -328,15 +334,12 @@ func (c *StateCache) PrintStatsAndReset() { // Update is one value written by the database transaction being published. // Step is the source step returned on cache hits, preserving bounded-read -// semantics. TxNum records how far this process's committed writes cover the -// domain, allowing file publication to detect downloaded state that never -// passed through this publisher. +// semantics. type Update struct { Domain kv.Domain Key []byte Value []byte Step kv.Step - TxNum uint64 } // Publisher is the mutation capability for canonical state. Normal readers @@ -399,15 +402,16 @@ func (p *Publication) Abort() { } // Publish applies updates from a successful database transaction and exposes -// generation as one complete cache snapshot. The caller must invoke it -// only after the database commit, so a visible cache generation is never ahead -// of durable state. +// generation as one complete cache snapshot. txNumEnd is the exclusive end of +// canonical execution covered by that commit across every state domain. The +// caller must invoke Publish only after the database commit, so a visible cache +// generation is never ahead of durable state. // // A forward commit can retain entries that were not updated because they still // have the same value in the new state. Canonical unwind sets clear because its // callbacks do not enumerate every value or file-coverage claim that may belong // to the discarded fork. -func (p *Publication) Publish(generation Generation, updates []Update, clear bool) { +func (p *Publication) Publish(generation Generation, txNumEnd uint64, updates []Update, clear bool) { if p == nil || p.c == nil { return } @@ -415,6 +419,7 @@ func (p *Publication) Publish(generation Generation, updates []Update, clear boo if clear { p.c.resetProvenanceAndClearLocked() } + p.c.coverCanonicalStateLocked(txNumEnd) for i := range updates { p.c.applyLocked(updates[i]) } diff --git a/execution/exec/blocks_read_ahead_test.go b/execution/exec/blocks_read_ahead_test.go index 15743fcbdc9..24a3db14251 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -170,7 +170,7 @@ func TestCachePopulatingGetterNegativeClearedByPublication(t *testing.T) { _, ok := cacheView(sc, 1).Get(kv.AccountsDomain, key) require.True(t, ok) - sc.Publisher().Begin().Publish(cache.StateGeneration(2, 0, 0, 0), nil, true) + sc.Publisher().Begin().Publish(cache.StateGeneration(2, 0, 0, 0), 0, nil, true) _, ok = cacheView(sc, 2).Get(kv.AccountsDomain, key) require.False(t, ok) } @@ -196,7 +196,7 @@ func TestCachePopulatingGetterStaleViewDoesNotFill(t *testing.T) { view: cacheView(sc, 1), } publication := sc.Publisher().Begin() - publication.Publish(cache.StateGeneration(2, 0, 0, 0), nil, false) + publication.Publish(cache.StateGeneration(2, 0, 0, 0), 0, nil, false) _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) diff --git a/execution/stagedsync/rawdbreset/reset_stages_test.go b/execution/stagedsync/rawdbreset/reset_stages_test.go index 8c6c922da54..67b27829b3c 100644 --- a/execution/stagedsync/rawdbreset/reset_stages_test.go +++ b/execution/stagedsync/rawdbreset/reset_stages_test.go @@ -107,7 +107,7 @@ func TestResetExecResetsBoundStateCache(t *testing.T) { publisher.Initialize(stateGeneration) publication := publisher.Begin() key := []byte{0x01} - publication.Publish(stateGeneration, []cache.Update{{ + publication.Publish(stateGeneration, 0, []cache.Update{{ Domain: kv.AccountsDomain, Key: key, Value: []byte{0xaa}, @@ -122,7 +122,7 @@ func TestResetExecResetsBoundStateCache(t *testing.T) { require.False(t, ok, "reset must revoke views of the pre-reset state") oldView.Fill(kv.AccountsDomain, key, []byte{0xbb}, 0) publication = publisher.Begin() - publication.Publish(stateGeneration, nil, false) + publication.Publish(stateGeneration, 0, nil, false) _, ok = stateCache.View(stateGeneration).Get(kv.AccountsDomain, key) require.False(t, ok, "the same numeric generation must not expose or accept pre-reset state") } From f567f709022130fdb6a37de86c949e5ec25d2ce7 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:39:01 +0200 Subject: [PATCH 36/50] execution/cache, commitment: share publication lifecycle --- execution/cache/canonical_publication.go | 86 +++++++++++++++++++++++ execution/cache/state_cache.go | 64 ++++++----------- execution/commitment/branch_cache_view.go | 57 ++++++--------- 3 files changed, 129 insertions(+), 78 deletions(-) create mode 100644 execution/cache/canonical_publication.go diff --git a/execution/cache/canonical_publication.go b/execution/cache/canonical_publication.go new file mode 100644 index 00000000000..fca33a5fd54 --- /dev/null +++ b/execution/cache/canonical_publication.go @@ -0,0 +1,86 @@ +// 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 cache + +// CanonicalPublisher owns the common lifecycle for a generation-bound cache. +// Cache-specific publishers retain their typed update APIs while delegating +// initialization, publication locking, abort, and failure cleanup here. +type CanonicalPublisher struct { + generation GenerationPublisher + clear func() +} + +// NewCanonicalPublisher binds a cache's lifecycle to its generation gate. +func NewCanonicalPublisher(gate *GenerationGate, clear func()) CanonicalPublisher { + return CanonicalPublisher{generation: gate.Publisher(), clear: clear} +} + +func (p CanonicalPublisher) Enabled() bool { + return p.generation.gate != nil +} + +// Initialize binds the cache to generation. A mismatch clears entries because +// their origin cannot be proven compatible with the requested snapshot. +func (p CanonicalPublisher) Initialize(generation Generation) { + p.generation.Initialize(generation, p.clear) +} + +// CanonicalPublication is one pending durable cache transition. +type CanonicalPublication struct { + generation *GenerationPublication + clear func() +} + +// Begin revokes current read views without changing entries, allowing Abort to +// restore the previous generation if the database transaction fails. +func (p CanonicalPublisher) Begin() *CanonicalPublication { + generation := p.generation.Begin() + if generation == nil { + return nil + } + return &CanonicalPublication{generation: generation, clear: p.clear} +} + +// Abort restores the generation revoked by Begin. +func (p *CanonicalPublication) Abort() { + if p == nil || p.generation == nil { + return + } + generation := p.generation + p.generation = nil + generation.Abort() +} + +// Publish applies a transition after its database commit and exposes the new +// generation. clear is required when retained entries cannot be proven to +// belong to the new canonical lineage. If apply panics, the cache is cleared +// and remains unpublished. +func (p *CanonicalPublication) Publish(generation Generation, clear bool, apply func(*GenerationPublication)) { + if p == nil || p.generation == nil { + return + } + publication := p.generation + defer func() { p.generation = nil }() + publication.Publish(generation, func() { + if clear && p.clear != nil { + p.clear() + } + if apply != nil { + apply(publication) + } + }, p.clear) +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index b470ab4e370..d7d39ce855a 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -342,87 +342,65 @@ type Update struct { Step kv.Step } +type canonicalPublisher = CanonicalPublisher + // Publisher is the mutation capability for canonical state. Normal readers // receive only ReadView, while code that makes database state durable uses a // Publisher to move every cache layer to the same Generation. type Publisher struct { + canonicalPublisher c *StateCache } // Publisher returns a handle that can change the cache's canonical generation. // It must not be given to speculative execution whose writes may be discarded. func (c *StateCache) Publisher() Publisher { - return Publisher{c: c} -} - -func (p Publisher) Enabled() bool { return p.c != nil } - -// Initialize binds the cache to the database and files generation seen by its -// canonical owner. A mismatch clears entries and their file provenance because -// neither can be proven compatible with that snapshot. -func (p Publisher) Initialize(generation Generation) { - if p.c == nil { - return + if c == nil { + return Publisher{} + } + return Publisher{ + canonicalPublisher: NewCanonicalPublisher(&c.generation, c.resetProvenanceAndClearLocked), + c: c, } - p.c.generation.Publisher().Initialize(generation, p.c.resetProvenanceAndClearLocked) } // Publication represents one pending transition of the durable database // state. Begin makes the cache unavailable without changing its entries, so // Abort can restore the previous generation if the transaction rolls back. -// Publish consumes the transition after the database commit succeeds. type Publication struct { - c *StateCache - generation *GenerationPublication + lifecycle *CanonicalPublication + c *StateCache } -// Begin revokes every existing ReadView and prevents creation of a new live -// view. It does not alter cache entries; they remain available for Abort until -// the canonical database transaction either commits or rolls back. func (p Publisher) Begin() *Publication { - if p.c == nil { - return nil - } - generation := p.c.generation.Publisher().Begin() - if generation == nil { + lifecycle := p.canonicalPublisher.Begin() + if lifecycle == nil { return nil } - return &Publication{c: p.c, generation: generation} + return &Publication{lifecycle: lifecycle, c: p.c} } -// Abort restores the previous generation after a failed or abandoned database -// transaction. The entries were not changed during the transition, so the old -// ReadViews become valid again together with their database version. func (p *Publication) Abort() { if p == nil || p.c == nil { return } - p.generation.Abort() + p.lifecycle.Abort() p.c = nil } -// Publish applies updates from a successful database transaction and exposes -// generation as one complete cache snapshot. txNumEnd is the exclusive end of -// canonical execution covered by that commit across every state domain. The -// caller must invoke Publish only after the database commit, so a visible cache -// generation is never ahead of durable state. -// -// A forward commit can retain entries that were not updated because they still -// have the same value in the new state. Canonical unwind sets clear because its -// callbacks do not enumerate every value or file-coverage claim that may belong -// to the discarded fork. +// Publish applies updates after a successful database commit. txNumEnd is the +// exclusive canonical boundary covered across every state domain. Forward +// commits retain unchanged entries. A lineage replacement sets clear because +// its updates do not enumerate every entry from the discarded state. func (p *Publication) Publish(generation Generation, txNumEnd uint64, updates []Update, clear bool) { if p == nil || p.c == nil { return } - p.generation.Publish(generation, func() { - if clear { - p.c.resetProvenanceAndClearLocked() - } + p.lifecycle.Publish(generation, clear, func(_ *GenerationPublication) { p.c.coverCanonicalStateLocked(txNumEnd) for i := range updates { p.c.applyLocked(updates[i]) } - }, p.c.resetProvenanceAndClearLocked) + }) p.c = nil } diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index e26a7d1bda0..0babf09d44b 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -71,69 +71,56 @@ type BranchUpdate struct { TxNum uint64 } +type canonicalPublisher = cache.CanonicalPublisher + // BranchPublisher is the canonical mutation handle for BranchCache. type BranchPublisher struct { + canonicalPublisher c *BranchCache } // Publisher returns a handle that can publish durable branch generations. func (c *BranchCache) Publisher() BranchPublisher { - return BranchPublisher{c: c} -} - -func (p BranchPublisher) Enabled() bool { - return p.c != nil -} - -// Initialize binds the cache to generation. A mismatch clears branches and -// their file provenance because neither can be attributed to that snapshot. -func (p BranchPublisher) Initialize(generation cache.Generation) { - if p.c == nil { - return + if c == nil { + return BranchPublisher{} + } + return BranchPublisher{ + canonicalPublisher: cache.NewCanonicalPublisher(&c.generation, c.resetProvenanceAndClear), + c: c, } - p.c.generation.Publisher().Initialize(generation, p.c.resetProvenanceAndClear) } -// BranchPublication represents one pending durable branch transition. +// BranchPublication is one pending durable branch transition. type BranchPublication struct { - c *BranchCache - generation *cache.GenerationPublication + lifecycle *cache.CanonicalPublication + c *BranchCache } -// Begin revokes current BranchReadViews without changing branch entries. func (p BranchPublisher) Begin() *BranchPublication { - if p.c == nil { - return nil - } - generation := p.c.generation.Publisher().Begin() - if generation == nil { + lifecycle := p.canonicalPublisher.Begin() + if lifecycle == nil { return nil } - return &BranchPublication{c: p.c, generation: generation} + return &BranchPublication{lifecycle: lifecycle, c: p.c} } -// Abort restores the previous branch generation after database rollback. func (p *BranchPublication) Abort() { if p == nil || p.c == nil { return } - p.generation.Abort() + p.lifecycle.Abort() p.c = nil } -// Publish applies staged pin changes and committed branch updates before it -// exposes generation. clear is required after canonical unwind because its -// diffset is not a complete list of branches or file-coverage claims from the -// discarded fork. +// Publish applies staged pin changes and committed branch updates. Forward +// commits retain unchanged branches. A lineage replacement sets clear because +// its updates do not enumerate every branch from the discarded state. func (p *BranchPublication) Publish(generation cache.Generation, updates []BranchUpdate, clear bool, adaptive *AdaptivePinPlan) { if p == nil || p.c == nil { return } - p.generation.Publish(generation, func() { - if clear { - p.c.resetProvenanceAndClear() - } - adaptive.apply(p.generation) + p.lifecycle.Publish(generation, clear, func(publication *cache.GenerationPublication) { + adaptive.apply(publication) for i := range updates { update := &updates[i] if committedEnd := update.TxNum + 1; committedEnd > p.c.committedTxNumEnd { @@ -145,6 +132,6 @@ func (p *BranchPublication) Publish(generation cache.Generation, updates []Branc } p.c.Put(update.Key, update.Value, update.Step) } - }, p.c.resetProvenanceAndClear) + }) p.c = nil } From 5b9534b9bb58938078ac9a8d5333f94ff1185aa3 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:55:22 +0200 Subject: [PATCH 37/50] execution/cache, commitment: share generation read guard --- execution/cache/generation_gate.go | 32 +++++++++++++++- execution/cache/view.go | 46 +++++++---------------- execution/commitment/branch_cache_view.go | 11 ++---- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 925b72deacc..aeee216aace 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -95,7 +95,8 @@ type GenerationGate struct { filesKnown bool } -// GenerationView is the immutable validity token held by one cache view. +// GenerationView is the immutable validity token held by one cache view. View +// constructs it with both fields set or returns the inert zero value. type GenerationView struct { gate *GenerationGate generation *publishedGeneration @@ -118,6 +119,35 @@ func (v GenerationView) Current() bool { return v.gate != nil && v.generation != nil && v.gate.current.Load() == v.generation } +// ReadCurrent returns a cache lookup only if the view remains published for +// the whole read. A concurrent publication therefore turns the result into a +// miss instead of exposing an entry from a mixed generation. +func ReadCurrent[T any](view GenerationView, read func() (T, bool)) (T, bool) { + var zero T + if view.gate == nil || view.gate.current.Load() != view.generation { + return zero, false + } + value, ok := read() + if view.gate.current.Load() != view.generation { + return zero, false + } + return value, ok +} + +// ReadCurrentWithStep applies the same guard to a lookup that also returns +// source metadata such as a state step. +func ReadCurrentWithStep[T any](view GenerationView, read func() (T, uint64, bool)) (T, uint64, bool) { + var zeroValue T + if view.gate == nil || view.gate.current.Load() != view.generation { + return zeroValue, 0, false + } + value, step, ok := read() + if view.gate.current.Load() != view.generation { + return zeroValue, 0, false + } + return value, step, ok +} + // Admit runs fill only if the view remains current while serialized against // publication. The early check avoids taking the read lock for stale views. func (v GenerationView) Admit(fill func()) bool { diff --git a/execution/cache/view.go b/execution/cache/view.go index 6be18cef922..f34bdc10b91 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -52,47 +52,29 @@ func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { } func (v ReadView) GetWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { - if !v.current() { - return nil, 0, false - } - value, step, ok := v.c.getWithStep(domain, key) - if !v.current() { - return nil, 0, false - } - return value, step, ok + value, step, ok := ReadCurrentWithStep(v.generation, func() ([]byte, uint64, bool) { + value, step, ok := v.c.getWithStep(domain, key) + return value, uint64(step), ok + }) + return value, kv.Step(step), ok } func (v ReadView) GetCodeByHash(codeHash []byte) ([]byte, bool) { - if !v.current() { - return nil, false - } - value, ok := v.c.getCodeByHash(codeHash) - if !v.current() { - return nil, false - } - return value, ok + return ReadCurrent(v.generation, func() ([]byte, bool) { + return v.c.getCodeByHash(codeHash) + }) } func (v ReadView) GetCodeSizeByHash(codeHash []byte) (int, bool) { - if !v.current() { - return 0, false - } - size, ok := v.c.getCodeSizeByHash(codeHash) - if !v.current() { - return 0, false - } - return size, ok + return ReadCurrent(v.generation, func() (int, bool) { + return v.c.getCodeSizeByHash(codeHash) + }) } func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { - if !v.current() { - return [32]byte{}, false - } - hash, ok := v.c.getAddrCodeHash(addr) - if !v.current() { - return [32]byte{}, false - } - return hash, ok + return ReadCurrent(v.generation, func() ([32]byte, bool) { + return v.c.getAddrCodeHash(addr) + }) } func (v ReadView) canFill() bool { diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 0babf09d44b..8c969d39eac 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -41,14 +41,9 @@ func (v BranchReadView) current() bool { // Get returns a branch only while the view remains current. func (v BranchReadView) Get(prefix []byte) ([]byte, uint64, bool) { - if !v.current() { - return nil, 0, false - } - value, step, ok := v.c.Get(prefix) - if !v.current() { - return nil, 0, false - } - return value, step, ok + return cache.ReadCurrentWithStep(v.generation, func() ([]byte, uint64, bool) { + return v.c.Get(prefix) + }) } // Fill admits a branch read from the view's database snapshot. From 4df6bba8d58a228b5d26125ca66abae9453f7be2 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:14:24 +0200 Subject: [PATCH 38/50] execution/commitment: preserve synchronized adaptive pins --- db/state/execctx/domain_shared.go | 2 +- execution/commitment/adaptive_pin.go | 11 +++++++---- execution/commitment/adaptive_pin_test.go | 19 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index b6c94c47738..14803101c32 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1202,7 +1202,7 @@ func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...fun adaptivePlan.Commit() adaptivePlan = nil if sd.clearExecutionCaches && sd.adaptivePinController != nil { - sd.adaptivePinController.Reset() + sd.adaptivePinController.ResetAfterCacheClear() } sd.clearExecutionCaches = false return nil diff --git a/execution/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index c86a8fca1d5..1cf4b4043c4 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -256,15 +256,18 @@ func (c *AdaptivePinController) PerContractBudgetBytes() int { return c.cfg.PerContractMaxBudgetBytes } -// Reset forgets residency state after BranchCache is cleared. Without this, -// the controller would treat removed pins as live and wait for their normal -// demotion before promoting them again. -func (c *AdaptivePinController) Reset() { +// ResetAfterCacheClear forgets residency state if BranchCache was cleared +// since this controller last synchronized. A newer plan may synchronize first; +// its state already describes the cleared cache and must remain live. +func (c *AdaptivePinController) ResetAfterCacheClear() { if c == nil { return } c.mu.Lock() defer c.mu.Unlock() + if c.cacheClearEpoch == c.cache.clearEpoch.Load() { + return + } c.resetLocked() } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index beb2b238cea..4b0ffc51fa8 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -240,6 +240,25 @@ func TestAdaptivePinControllerForgetsPinsClearedByFilesPublication(t *testing.T) require.Empty(t, controller.states, "residency state must not outlive the BranchCache entries it describes") } +func TestAdaptivePinControllerDelayedResetPreservesSynchronizedState(t *testing.T) { + branchCache := NewBranchCache(64) + t.Cleanup(branchCache.Close) + controller := NewAdaptivePinController(branchCache, DefaultAdaptivePinControllerConfig(), log.Root()) + + branchCache.Reset() + controller.mu.Lock() + controller.syncCacheClearLocked() + var contractHash [32]byte + contractHash[0] = 1 + state := &adaptiveContractState{contractHash: contractHash} + controller.states[contractHash] = state + controller.mu.Unlock() + + controller.ResetAfterCacheClear() + + require.Same(t, state, controller.states[contractHash], "a delayed reset must not discard state built after the cache clear") +} + // The trunk-preload counters are the only signal for how much work the adaptive // pin controller is doing, so promotion must feed them. Asserting the byte // counter is enough to prove recordPreload ran: nothing else writes it. From f96335c631d9f922ee1e1f7c4d84a52a98920aae Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:00:05 +0200 Subject: [PATCH 39/50] execution/cache, db/state: correct publication ordering comments --- db/state/execctx/domain_shared.go | 2 -- execution/cache/canonical_publication.go | 4 ++-- execution/cache/state_cache.go | 7 ++++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 14803101c32..79cf0671801 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -1057,8 +1057,6 @@ func (sd *SharedDomains) flushMem(ctx context.Context, tx kv.RwTx, opts ...kv.Fl } // Commit flushes and commits tx before publishing either process-global cache. -// Cache views are revoked only around the database commit, so they continue to -// serve the old durable version while the in-memory batch is being flushed. // A SharedDomains attached to either process-global cache must call // SetCanonicalCaches before Commit. // tx must be dedicated to this operation because Commit consumes it. diff --git a/execution/cache/canonical_publication.go b/execution/cache/canonical_publication.go index fca33a5fd54..b8f3cba59ab 100644 --- a/execution/cache/canonical_publication.go +++ b/execution/cache/canonical_publication.go @@ -45,8 +45,8 @@ type CanonicalPublication struct { clear func() } -// Begin revokes current read views without changing entries, allowing Abort to -// restore the previous generation if the database transaction fails. +// Begin revokes current read views without changing entries. Abort can restore +// the previous generation while no cache changes have been applied. func (p CanonicalPublisher) Begin() *CanonicalPublication { generation := p.generation.Begin() if generation == nil { diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index d7d39ce855a..f84625d2a3a 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -364,9 +364,10 @@ func (c *StateCache) Publisher() Publisher { } } -// Publication represents one pending transition of the durable database -// state. Begin makes the cache unavailable without changing its entries, so -// Abort can restore the previous generation if the transaction rolls back. +// Publication represents one pending cache transition after durable state has +// committed. Begin makes the cache unavailable without changing its entries; +// Abort restores the previous generation if publication is abandoned before +// applying changes. type Publication struct { lifecycle *CanonicalPublication c *StateCache From 8c75e5edb319c0c6ba2681d6ed17547a5d912232 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:21:13 +0200 Subject: [PATCH 40/50] execution/commitment: reuse generation fill admission --- execution/commitment/branch_cache_test.go | 5 ++++- execution/commitment/branch_cache_view.go | 6 +----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index c0c174452d7..1ca89a4da62 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -226,7 +226,10 @@ func TestBranchCache_ClosePreventsPublication(t *testing.T) { c.Close() publisher.Initialize(testBranchGeneration(2)) - require.False(t, c.View(testBranchGeneration(2)).current()) + key := []byte{0x00} + c.View(testBranchGeneration(2)).Fill(key, []byte("root"), 0) + _, _, ok := c.Get(key) + require.False(t, ok) publication := publisher.Begin() publication.Abort() require.Nil(t, publication) diff --git a/execution/commitment/branch_cache_view.go b/execution/commitment/branch_cache_view.go index 8c969d39eac..7abd08e33da 100644 --- a/execution/commitment/branch_cache_view.go +++ b/execution/commitment/branch_cache_view.go @@ -35,10 +35,6 @@ func (c *BranchCache) View(generation cache.Generation) BranchReadView { return BranchReadView{c: c, generation: c.generation.View(generation)} } -func (v BranchReadView) current() bool { - return v.c != nil && v.generation.Current() -} - // Get returns a branch only while the view remains current. func (v BranchReadView) Get(prefix []byte) ([]byte, uint64, bool) { return cache.ReadCurrentWithStep(v.generation, func() ([]byte, uint64, bool) { @@ -48,7 +44,7 @@ func (v BranchReadView) Get(prefix []byte) ([]byte, uint64, bool) { // Fill admits a branch read from the view's database snapshot. func (v BranchReadView) Fill(prefix, value []byte, step uint64) { - if !v.current() || len(value) == 0 { + if len(value) == 0 { return } v.generation.Admit(func() { From d17cd9ff18e2ae464de4ebe1da500c4b93312f1c Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:33:35 +0200 Subject: [PATCH 41/50] execution/cache: inline state fill eligibility --- execution/cache/cache_test.go | 5 ++++- execution/cache/view.go | 6 +----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/execution/cache/cache_test.go b/execution/cache/cache_test.go index ae18a77a2e4..a710f90ddcb 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -461,7 +461,10 @@ func TestStateCache_ClosePreventsPublication(t *testing.T) { c.Close() publisher.Initialize(testStateGeneration(2)) - require.False(t, c.View(testStateGeneration(2)).current()) + key := makeAddr(1) + c.View(testStateGeneration(2)).Fill(kv.AccountsDomain, key, makeValue(1), 0) + _, _, ok := c.getWithStep(kv.AccountsDomain, key) + require.False(t, ok) publication := publisher.Begin() publication.Abort() require.Nil(t, publication) diff --git a/execution/cache/view.go b/execution/cache/view.go index f34bdc10b91..2fd07233478 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -42,10 +42,6 @@ func (c *StateCache) View(generation Generation) ReadView { return ReadView{c: c, generation: c.generation.View(generation)} } -func (v ReadView) current() bool { - return v.c != nil && v.generation.Current() -} - func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { value, _, ok := v.GetWithStep(domain, key) return value, ok @@ -78,7 +74,7 @@ func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { } func (v ReadView) canFill() bool { - return v.current() && !v.c.disableFills + return v.c != nil && !v.c.disableFills && v.generation.Current() } func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { From cc3279b78b9f7f94d5e462a35dd51a456891f855 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:40:18 +0200 Subject: [PATCH 42/50] execution/cache, commitment: clarify files publication contract --- execution/cache/state_cache.go | 8 ++++---- execution/commitment/branch_cache.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index f84625d2a3a..be4cdca87dc 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -103,10 +103,10 @@ func NewDefaultStateCache() *StateCache { ) } -// BeginFilesPublication revokes the old files generation. It retains entries -// when this process's committed updates cover the new files and clears them -// when that compatibility cannot be proven. Finish publishes the new identity -// after the files become visible. +// BeginFilesPublication prepares StateCache for new accounts, storage, and code +// files. It retains values covered by this process's committed updates and +// clears the cache when compatibility cannot be proven. A non-nil result keeps +// cache publication blocked until Finish is called after the files are visible. func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *BackingChange { if c == nil { return nil diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index ebbe320a1a5..e4cb5e5b4b7 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -363,10 +363,10 @@ func (c *BranchCache) resetProvenanceAndClear() { c.clear() } -// BeginFilesPublication revokes the old files generation. It retains entries -// when this process's committed updates cover the new files and clears them -// when that compatibility cannot be proven. Finish publishes the new identity -// after the files become visible. +// BeginFilesPublication prepares BranchCache for new commitment files. It +// retains branches covered by this process's committed updates and clears the +// cache when compatibility cannot be proven. A non-nil result keeps cache +// publication blocked until Finish is called after the files are visible. func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChange { if c == nil { return nil From 542766a019205af272a2e667b2bca5a72a2d23cf Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:45:26 +0200 Subject: [PATCH 43/50] execution/cache: clarify cache comments --- execution/cache/generic_cache.go | 16 ++++++++-------- execution/cache/grow_lru.go | 12 ++++++------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/execution/cache/generic_cache.go b/execution/cache/generic_cache.go index da0ba02e11f..02c826f9669 100644 --- a/execution/cache/generic_cache.go +++ b/execution/cache/generic_cache.go @@ -134,9 +134,9 @@ func NewGenericCache[T any](capacityBytes datasize.ByteSize, sizeFunc func(T) in // NewGenericCacheWithAvg is NewGenericCache with an explicit per-domain average // entry size, so the byte-budget ceiling and the envelope accounting reflect the -// domain's real entry cost (accounts ≈ 96 B, storage ≈ 88 B) rather than the -// generic default. It starts small and jump-grows toward the ceiling on demand, -// funding each step from the shared envelope. +// domain's expected entry cost rather than the generic default. It starts small +// and jump-grows toward the ceiling on demand, funding each step from the shared +// envelope. func NewGenericCacheWithAvg[T any](capacityBytes datasize.ByteSize, avgBytes uint32, sizeFunc func(T) int, mode Mode) *GenericCache[T] { if avgBytes == 0 { avgBytes = avgBytesPerEntry @@ -389,13 +389,13 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, overwrite bool) bool { // In ModeEvictLRU the byte budget is enforced through the entry-count cap, // not a separate currentSize check: capacityEntries is derived from - // capacityB (capacityB/avgBytesPerEntry, see NewGenericCache / + // capacityB (capacityB/avgEntryBytes, see NewGenericCache / // newDomainCacheBytes), so once the slot cap is reached the per-shard LRU // evicts the oldest entry inside freelru.Add and currentSize settles at - // ≈ capacityEntries × avg ≈ capacityB. For the near-fixed-size domains this - // caches (account ~96 B, storage ~88 B) the variance against avg is small, so - // currentSize tracks capacityB closely rather than running away — freelru - // exposes no evict-until-bytes-fit primitive to enforce it more tightly. + // approximately capacityEntries × avgEntryBytes ≈ capacityB. Entry sizes in + // the domains cached here have low variance, so currentSize tracks capacityB + // closely rather than running away — freelru exposes no + // evict-until-bytes-fit primitive to enforce it more tightly. // Eviction is per-shard, not globally-LRU — same trade-off code_cache.go / // balcache.go / db/state/cache.go accept. diff --git a/execution/cache/grow_lru.go b/execution/cache/grow_lru.go index b0e3725028d..5246efa7aa3 100644 --- a/execution/cache/grow_lru.go +++ b/execution/cache/grow_lru.go @@ -35,12 +35,12 @@ import ( // // Generation swaps (maybeGrow, Purge) are not fenced against writers — safe // only for content-addressed layers, where a key's payload never changes: a -// write lost in a retired generation is a benign miss, and an entry whose -// removal a racing copy undid serves correct bytes until its stale stamp -// drops it on the next read. Do not reuse for mutable-per-key values — those -// need GenericCache's fenced swap. The onEvict-maintained counters are -// approximate across grow windows (a lost write is counted but never -// evicted; a raced removal can subtract twice). +// write lost in a retired generation is a benign miss, and a racing copy that +// restores a removed entry can only restore the same immutable bytes. Do not +// reuse this cache for mutable-per-key values — those need GenericCache's +// fenced swap. The onEvict-maintained counters are approximate across grow +// windows (a lost write is counted but never evicted; a raced removal can +// subtract twice). type growLRU[V any] struct { cur atomic.Pointer[freelru.ShardedLRU[uint64, V]] onEvict func(uint64, V) From 6509a61d4e45843c65dae32a0aa2fe2646d6cf72 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:24:56 +0200 Subject: [PATCH 44/50] execution, db: restore cache contract comments --- db/state/execctx/domain_shared.go | 21 ++++++++++------ execution/cache/cache.go | 4 +++ execution/cache/state_cache.go | 38 ++++++++++++++++++++++++++--- execution/cache/view.go | 14 +++++++++++ execution/exec/blocks_read_ahead.go | 23 ++++++++++++----- execution/execmodule/exec_module.go | 4 ++- execution/execmodule/forkchoice.go | 17 +++++++------ 7 files changed, 94 insertions(+), 27 deletions(-) diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 79cf0671801..611f604e691 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -232,13 +232,17 @@ type SharedDomains struct { mem kv.TemporalMemBatch metrics kvmetrics.DomainMetrics - // blockOverlay accumulates block metadata while execution holds only a read - // transaction. It is flushed atomically with domain state. The pointer is - // atomic because concurrent readers may load it while Close clears it. + // blockOverlay is an in-memory overlay for block-level metadata writes (headers, bodies, + // canonical hashes, TD, stage progress, forkchoice markers). It allows execution to + // operate without holding an RwTx — writes accumulate here and are flushed atomically + // alongside domain state via Flush(). + // Atomic because concurrent readers may load the pointer while Close clears it. blockOverlay atomic.Pointer[membatchwithdb.MemoryMutation] - // parent is an optional read-through chain for uncommitted domain state and - // accumulated diffsets. A child reads but never writes its parent. + // parent is an optional parent SD for read-through chaining. When set, + // domain reads that miss in the local mem batch fall through to the parent's + // mem batch before consulting the underlying tx. Accumulated diffsets follow + // the same chain, and a child never writes its parent. parent *SharedDomains // stateCache provides generation-bound reads and fills. statePublisher is set @@ -1032,9 +1036,10 @@ func (sd *SharedDomains) Close() { sd.sdCtx = nil } -// Flush writes the in-memory batch without committing or publishing cache -// updates. A SharedDomains with canonical publication authority must use Commit -// so the database and cache become visible in that order. +// Flush writes the in-memory batch into tx without committing. It deliberately +// does not publish cache updates because the caller may still roll the +// transaction back. A SharedDomains with canonical publication authority must +// use Commit so the database and cache become visible in that order. func (sd *SharedDomains) Flush(ctx context.Context, tx kv.RwTx) error { defer mxFlushTook.ObserveDuration(time.Now()) return sd.flushMem(ctx, tx) diff --git a/execution/cache/cache.go b/execution/cache/cache.go index 0a7939a467d..0447ecd38e6 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -33,9 +33,13 @@ import "github.com/erigontech/erigon/db/kv" // Cache is the interface for domain caches. // Implementations: DomainCache (for Account/Storage), CodeCache (for Code). type Cache interface { + // Get returns ok=true for a cached value, including a cached negative with a + // nil value. ok=false means the caller must read the backing store. Get(key []byte) (value []byte, ok bool) + // GetWithStep also returns the source step used to enforce bounded reads. GetWithStep(key []byte) (value []byte, step kv.Step, ok bool) Put(key, value []byte, step kv.Step) + // PutIfAbsent is the reader-fill path; an existing authoritative value wins. PutIfAbsent(key, value []byte, step kv.Step) Delete(key []byte) diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index be4cdca87dc..9b4a2db9bef 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -38,9 +38,13 @@ const ( avgStorageEntryBytes = 80 ) -// StateCache holds account, storage, and code values for one durable database -// state over one compatible files view. Publication revokes a reader's -// generation before changing entries, so the reader cannot observe mixed state. +// StateCache is a unified cache for domain data (Account, Storage, Code). +// Uses an array indexed by kv.Domain. Only Account, Storage, and Code domains +// are supported; other indices are nil. +// +// It holds values for one durable database state over one compatible files +// view. Publication revokes a reader's generation before changing entries, so +// the reader cannot observe mixed state. type StateCache struct { generation GenerationGate @@ -50,9 +54,17 @@ type StateCache struct { // each end to the newly visible view. coveredTxNumEnd [kv.DomainLen]uint64 caches [kv.DomainLen]Cache - disableFills bool + // disableFills (STATE_CACHE_FILLS=false) turns off every reader fill + // (including the content-addressed ones), leaving committed publications as + // the only population path ("apply-only" mode) — an A/B lever and an + // operational kill switch. + disableFills bool } +// NewStateCache creates a new StateCache with the specified byte capacities. +// Mode for the byte-budget DomainCaches (Account/Storage) is read once from +// STATE_CACHE_MODE (evict|noop, default evict). CodeCache has its own LRU and +// is not gated by this knob. func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.ByteSize) *StateCache { mode := stateCacheModeFromEnv() sc := &StateCache{} @@ -63,9 +75,15 @@ func NewStateCache(accountBytes, storageBytes, codeBytes, addrBytes datasize.Byt sc.caches[kv.AccountsDomain] = newDomainCacheBytes(accountBytes, avgAccountEntryBytes, mode) sc.caches[kv.StorageDomain] = newDomainCacheBytes(storageBytes, avgStorageEntryBytes, mode) sc.caches[kv.CodeDomain] = NewCodeCache(codeBytes, addrBytes) + // CommitmentDomain deliberately gets no cache: commitment data lives in the + // BranchCache, and the nil slot short-circuits every StateCache path for it + // (including writes of commitmentdb.KeyCommitmentState). return sc } +// stateCacheModeFromEnv reads STATE_CACHE_MODE (once per NewStateCache). Unset +// or unrecognised returns ModeEvictLRU. Recognised values: "evict", "noop". The +// noop and unrecognised cases log; the default evict path is silent. func stateCacheModeFromEnv() Mode { v := strings.ToLower(strings.TrimSpace(dbg.EnvString("STATE_CACHE_MODE", ""))) switch v { @@ -80,6 +98,10 @@ func stateCacheModeFromEnv() Mode { } } +// newDomainCacheBytes constructs a DomainCache whose growth ceiling is derived +// from the byte budget using the supplied per-domain avg. It jump-grows from a +// small start into the shared envelope on demand, so a domain with a small +// working set (a test fixture) never pre-commits the full budget. func newDomainCacheBytes(capacityBytes datasize.ByteSize, avgBytes uint32, mode Mode) *DomainCache { return &DomainCache{ GenericCache: NewGenericCacheWithAvg(capacityBytes, avgBytes, func(v domainEntry) int { return len(v.value) }, mode), @@ -141,6 +163,9 @@ func (c *StateCache) getWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, return cache.GetWithStep(key) } +// getCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), +// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss or +// when the code domain cache is not a CodeCache (defensive fallback). func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -149,6 +174,9 @@ func (c *StateCache) getCodeByHash(codeHash []byte) ([]byte, bool) { return cc.GetByCodeHash(codeHash) } +// getCodeSizeByHash returns the size of code by its Ethereum codeHash +// without loading the bytes. Returns (0, false) when the size-only layer +// is not populated for this hash. func (c *StateCache) getCodeSizeByHash(codeHash []byte) (int, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { @@ -157,6 +185,8 @@ func (c *StateCache) getCodeSizeByHash(codeHash []byte) (int, bool) { return cc.GetCodeSizeByCodeHash(codeHash) } +// getAddrCodeHash returns the Ethereum codeHash for addr without an +// account-domain round-trip. The hash is zero when ok is false. func (c *StateCache) getAddrCodeHash(addr []byte) ([32]byte, bool) { cc, ok := c.caches[kv.CodeDomain].(*CodeCache) if !ok { diff --git a/execution/cache/view.go b/execution/cache/view.go index 2fd07233478..53987a6b7e6 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -42,11 +42,16 @@ func (c *StateCache) View(generation Generation) ReadView { return ReadView{c: c, generation: c.generation.View(generation)} } +// Get retrieves data for the given domain and key. +// Returns (value, true) on cache hit — including (nil, true) for cached negatives — +// and (nil, false) on cache miss. func (v ReadView) Get(domain kv.Domain, key []byte) ([]byte, bool) { value, _, ok := v.GetWithStep(domain, key) return value, ok } +// GetWithStep also returns the entry's source step so an in-flight unwind can +// reject a hit above its per-key bound. func (v ReadView) GetWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bool) { value, step, ok := ReadCurrentWithStep(v.generation, func() ([]byte, uint64, bool) { value, step, ok := v.c.getWithStep(domain, key) @@ -55,18 +60,23 @@ func (v ReadView) GetWithStep(domain kv.Domain, key []byte) ([]byte, kv.Step, bo return value, kv.Step(step), ok } +// GetCodeByHash retrieves code bytes by their Ethereum codeHash (keccak256), +// bypassing the addr-keyed CodeDomain lookup. Returns (nil, false) on miss. func (v ReadView) GetCodeByHash(codeHash []byte) ([]byte, bool) { return ReadCurrent(v.generation, func() ([]byte, bool) { return v.c.getCodeByHash(codeHash) }) } +// GetCodeSizeByHash returns the cached code length for codeHash. func (v ReadView) GetCodeSizeByHash(codeHash []byte) (int, bool) { return ReadCurrent(v.generation, func() (int, bool) { return v.c.getCodeSizeByHash(codeHash) }) } +// GetAddrCodeHash returns the Ethereum codeHash for addr without an +// account-domain round-trip. The hash is zero when ok is false. func (v ReadView) GetAddrCodeHash(addr []byte) ([32]byte, bool) { return ReadCurrent(v.generation, func() ([32]byte, bool) { return v.c.getAddrCodeHash(addr) @@ -77,6 +87,8 @@ func (v ReadView) canFill() bool { return v.c != nil && !v.c.disableFills && v.generation.Current() } +// Fill offers a value read from this view. It never overwrites an authoritative +// entry, and final admission rejects a view revoked by concurrent publication. func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { if !v.canFill() { return @@ -88,6 +100,7 @@ func (v ReadView) Fill(domain kv.Domain, key, value []byte, step kv.Step) { v.c.fill(v.generation, domain, key, value, step) } +// SeedAddrCodeHash offers a binding derived from an account read in this view. func (v ReadView) SeedAddrCodeHash(addr []byte, hash [32]byte) { if !v.canFill() { return @@ -95,6 +108,7 @@ func (v ReadView) SeedAddrCodeHash(addr []byte, hash [32]byte) { v.c.seedAddrCodeHash(v.generation, addr, hash) } +// FillCodeSize records a derived size only while this generation is current. func (v ReadView) FillCodeSize(codeHash []byte, size int) { if !v.canFill() { return diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 3a17cf1d0d4..f99d383ced2 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -36,8 +36,11 @@ type BlockReadAheader struct { warming atomic.Bool // only one warmBody can run at a time warmWg sync.WaitGroup - // stateCache lets warmBody fill the same process-global cache used by - // execution instead of warming only the backing files and cursors. + // stateCache is the process-global state cache that SharedDomains.GetLatest + // consults on the EVM hot path. When set, warmBody routes its prefetches + // through a cache-populating getter so the same StateCache the EVM reads is + // pre-warmed. Without it, prefetches only warm OS page cache + RoTx + // cursors — disconnected from the cache layer the EVM actually reads. stateCache *cache.StateCache } @@ -66,14 +69,22 @@ func NewBlockReadAheader() *BlockReadAheader { } } -// SetStateCache enables read-ahead fills into the process-global state cache. -// Call it before the first warmBody can start. +// SetStateCache wires the process-global state cache so warmBody's +// prefetches land in the same StateCache that SharedDomains.GetLatest probes +// on the EVM hot path. Without this, prefetches warm OS page cache only — +// the EVM still pays the file accessor stack on its first per-address read. +// Call it before the first AddHeaderAndBody. func (bra *BlockReadAheader) SetStateCache(sc *cache.StateCache) { bra.stateCache = sc } -// cachePopulatingGetter admits read-ahead results through a generation-bound -// StateCache view. Code reads also fill the content-addressed and size layers. +// cachePopulatingGetter wraps a kv.TemporalGetter and fills a StateCache +// ReadView as a side effect. Used by warmBody to make read-ahead prefetches +// populate the same in-process StateCache that SharedDomains.GetLatest +// consults — eliminating the file-accessor stack cost on the EVM's first +// touch of any prefetched address. +// +// Code reads also populate the content-addressed and size-cache layers. type cachePopulatingGetter struct { kv.TemporalGetter view cache.ReadView diff --git a/execution/execmodule/exec_module.go b/execution/execmodule/exec_module.go index 56426536cfa..d5d6eb7ff66 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -284,7 +284,9 @@ func NewExecModule( stopNode: stopNode, } - // Share the execution state cache with read-ahead. + // Wire the process-global state cache into the read-ahead so its + // prefetches populate the same StateCache that SharedDomains.GetLatest + // probes on the EVM hot path. if readAheader != nil { readAheader.SetStateCache(domainCache) } diff --git a/execution/execmodule/forkchoice.go b/execution/execmodule/forkchoice.go index 4aaeaa79f6a..39af1180dec 100644 --- a/execution/execmodule/forkchoice.go +++ b/execution/execmodule/forkchoice.go @@ -703,8 +703,8 @@ func (e *ExecModule) updateForkChoice(ctx context.Context, originalBlockHash, sa }() } - // Pass the outer roTx so it is released before Commit and the MDBX - // commit observes openTxs=1. + // Flush + commit: pass the outer roTx so it gets released between + // Flush and Commit, so the commit sees openTxs=1 in MDBX. commitTimings, err := e.runForkchoiceFlushCommit(currentContext, roTx, finishProgressBefore, isSynced) if err != nil { return sendForkchoiceErrorWithoutWaiting(e.logger, outcomeCh, err, stateFlushingInParallel) @@ -805,12 +805,13 @@ func (e *ExecModule) dispatchNotificationsFromOverlay(sd *execctx.SharedDomains, // runForkchoiceFlushCommit opens a brief RwTx, flushes the SharedDomains // (block overlay + domain mem), commits, then updates the sentry head. // -// roTxToCloseBeforeCommit may be nil. Releasing it before Commit lets the write -// transaction observe openTxs=1 in MDBX, so GC can reclaim pages freed during -// the commit instead of pinning them behind the old reader. Commit flushes the -// in-memory overlay into rwTx and does not read from the old transaction, so -// closing that reader first is safe. Rollback is idempotent, so an outer defer -// may still call it. +// roTxToCloseBeforeCommit (may be nil) is released between Flush and Commit so +// the commit transaction observes openTxs=1 in MDBX rather than 2. This lets +// MDBX GC reclaim pages freed during the commit window immediately, instead of +// pinning them behind the still-open RO reader until the next commit. SD.Flush +// only writes in-memory state to rwTx and does not read from the RO tx, so +// closing it after Flush is safe. Rollback is idempotent, so callers keep their +// outer `defer roTx.Rollback()` unchanged. func (e *ExecModule) runForkchoiceFlushCommit(sd *execctx.SharedDomains, roTxToCloseBeforeCommit kv.TemporalTx, finishProgressBefore uint64, isSynced bool) ([]any, error) { timings := make([]any, 0, 2) From 0d25828d33aaf46418517acb81bbabd8e307ab6d Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:37:57 +0200 Subject: [PATCH 45/50] execution/cache: reject stale generation publications --- execution/cache/generation_gate.go | 7 +++++++ execution/cache/generation_gate_test.go | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index aeee216aace..0ab9a4cfac9 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -278,6 +278,13 @@ func (p *GenerationPublication) Publish(identity Generation, apply, clear func() if gate.current.Load() != nil { panic("cache generation publication changed before publish") } + // Database commits are serialized, but their post-commit cache publications + // may arrive out of order. Keep a newer generation instead of applying an + // older transaction's partial update set to it. + if p.previous != nil && identity.stateVersion < p.previous.identity.stateVersion { + gate.current.Store(p.previous) + return + } completed := false defer func() { if !completed && clear != nil { diff --git a/execution/cache/generation_gate_test.go b/execution/cache/generation_gate_test.go index d233b27942e..b7b40fc9aaa 100644 --- a/execution/cache/generation_gate_test.go +++ b/execution/cache/generation_gate_test.go @@ -70,3 +70,22 @@ func TestGenerationPublicationApplyPanicClearsPartialChanges(t *testing.T) { require.Empty(t, entries) require.False(t, gate.View(StateGeneration(2, 0, 0, 0)).Current()) } + +func TestGenerationPublicationRejectsOlderStateVersion(t *testing.T) { + var gate GenerationGate + publisher := gate.Publisher() + newer := StateGeneration(3, 0, 0, 0) + publisher.Initialize(newer, nil) + newerView := gate.View(newer) + require.True(t, newerView.Current()) + + applied := false + publication := publisher.Begin() + publication.Publish(StateGeneration(2, 0, 0, 0), func() { + applied = true + }, nil) + + require.False(t, applied, "an older publication must not apply its updates") + require.True(t, newerView.Current(), "an older publication must restore the newer token") + require.False(t, gate.View(StateGeneration(2, 0, 0, 0)).Current()) +} From 81bc20342829d4e2c040958daaa739fb8897770a Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:59:02 +0200 Subject: [PATCH 46/50] db: keep branch cache active without history --- db/kv/kv_interface.go | 4 +++ db/kv/remotedb/kv_remote.go | 1 + db/kv/temporal/kv_temporal.go | 3 ++ db/state/aggregator.go | 6 ++++ db/state/aggregator_align_test.go | 2 ++ db/state/execctx/branch_cache_flush_test.go | 35 +++++++++++++++++++ .../execctx/cache_view_eligibility_test.go | 6 ++-- db/state/execctx/domain_shared.go | 10 +++--- 8 files changed, 60 insertions(+), 7 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 91d549231a1..bdcb2db1769 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -528,6 +528,10 @@ type TemporalDebugTx interface { // HasExactDomainVisibleEnd reports DomainVisibleEnd's ok result without // resolving the bound, which may require a database cursor. HasExactDomainVisibleEnd(domain Domain) bool + // HasCacheableLatestView reports whether the state version and visible value + // files fully identify GetLatest results. Unlike DomainVisibleEnd, this may be + // true when history is disabled. + HasCacheableLatestView(domain Domain) bool IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 // Retire retires frozen history files entirely below their diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index 4c6b620c0f7..db422bd3d79 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -259,6 +259,7 @@ func (tx *tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return 0, false } func (tx *tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { return false } +func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return false } func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index dba6c96f22d..2214c765e0e 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -798,6 +798,9 @@ func (tx *Tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { func (tx *RwTx) HasExactDomainVisibleEnd(domain kv.Domain) bool { return tx.aggtx.HasExactDomainVisibleEnd(domain) } +func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { + return tx.aggtx.HasCacheableLatestView(domain) +} func (tx *Tx) IIProgress(domain kv.InvertedIdx) uint64 { return tx.aggtx.IIProgress(domain, tx.Tx) } diff --git a/db/state/aggregator.go b/db/state/aggregator.go index e2b7301ebce..e530651a238 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2779,6 +2779,12 @@ func (at *AggregatorRoTx) HasExactDomainVisibleEnd(name kv.Domain) bool { return !d.d.HistoryDisabled && d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() } +// HasCacheableLatestView accepts history-disabled latest state and otherwise +// requires the value files to cover history-II. +func (at *AggregatorRoTx) HasCacheableLatestView(name kv.Domain) bool { + return at.d[name].d.HistoryDisabled || at.HasExactDomainVisibleEnd(name) +} + // DomainVisibleEnd returns the exact combined frontier after verifying that // the values files cover history-II. func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 86c5b0c70d9..dd57d6c92e5 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -257,6 +257,8 @@ func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) require.False(t, ok, "a dependency-clamped values view has no exact frontier") require.False(t, at.HasExactDomainVisibleEnd(kv.AccountsDomain)) + require.False(t, at.HasCacheableLatestView(kv.AccountsDomain), + "history-enabled views remain uncacheable while values files lag history-II") } // The forbid assert must also watch the history-II ends: they are the base of diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index a537c05773e..ab5776ade65 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -100,6 +100,41 @@ func TestSharedBranchCacheDoesNotRequireVisibilityGuard(t *testing.T) { sd.Close() } +func TestBranchCacheReadsWithoutCommitmentHistory(t *testing.T) { + t.Parallel() + + ctx := t.Context() + db := newTestDb(t, 16) + branchCache := commitment.NewBranchCache(64) + t.Cleanup(branchCache.Close) + roTx, err := db.BeginTemporalRo(ctx) + require.NoError(t, err) + defer roTx.Rollback() + require.False(t, roTx.Debug().HasExactDomainVisibleEnd(kv.CommitmentDomain), + "the default commitment domain has no historical frontier") + require.True(t, roTx.Debug().HasCacheableLatestView(kv.CommitmentDomain), + "latest commitment state remains cacheable without history") + + tx := &temporalTxWithAgg{ + TemporalTx: roTx, + agg: &branchCacheOnlyAgg{branchCache: branchCache}, + } + sd, err := execctx.NewSharedDomains(ctx, tx, log.New()) + require.NoError(t, err) + defer sd.Close() + + generation := branchGenerationForTx(t, tx) + branchCache.Publisher().Initialize(generation) + key := []byte{0x0a, 0x0b} + value := []byte("cache-only branch") + branchCache.View(generation).Fill(key, value, 1) + + got, step, err := sd.GetLatest(kv.CommitmentDomain, tx, key) + require.NoError(t, err) + require.Equal(t, value, got) + require.Equal(t, kv.Step(1), step) +} + type commitErrorTx struct { kv.TemporalRwTx err error diff --git a/db/state/execctx/cache_view_eligibility_test.go b/db/state/execctx/cache_view_eligibility_test.go index 6375f5b70a0..72a6d2ffd4f 100644 --- a/db/state/execctx/cache_view_eligibility_test.go +++ b/db/state/execctx/cache_view_eligibility_test.go @@ -34,18 +34,18 @@ func (s *exactDomainViewStub) HasExactDomainVisibleEnd(domain kv.Domain) bool { return s.exact[domain] } -func TestCacheViewEligibleUsesExactViewAvailability(t *testing.T) { +func TestHasExactVisibleEndsUsesViewAvailability(t *testing.T) { exact := map[kv.Domain]bool{ kv.AccountsDomain: true, kv.StorageDomain: true, kv.CodeDomain: true, } debug := &exactDomainViewStub{exact: exact} - require.True(t, cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) + require.True(t, hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain}, debug.checked) exact[kv.StorageDomain] = false debug.checked = nil - require.False(t, cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) + require.False(t, hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain}, debug.checked) } diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 611f604e691..68887192145 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -123,7 +123,9 @@ func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { if stateView, identityKnown := stateCacheReadViewFor(debug, stateVersion, sd.stateCache); identityKnown { views.state = stateView } - if sd.branchCache != nil && cacheViewEligible(debug, kv.CommitmentDomain) { + // BranchCache serves only latest state, so disabling history does not make + // its pinned database-and-files identity ambiguous. + if sd.branchCache != nil && debug.HasCacheableLatestView(kv.CommitmentDomain) { views.branch = sd.branchCache.View(branchCacheGenerationFor(debug, stateVersion)) } return views @@ -148,7 +150,7 @@ func stateCacheReadViewFor( stateVersion uint64, stateCache *cache.StateCache, ) (cache.ReadView, bool) { - if stateCache == nil || !cacheViewEligible(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) { + if stateCache == nil || !hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) { return cache.ReadView{}, false } return stateCache.View(stateCacheGenerationFor(debug, stateVersion)), true @@ -178,10 +180,10 @@ type exactDomainVisibleEnd interface { HasExactDomainVisibleEnd(domain kv.Domain) bool } -// cacheViewEligible checks only whether exact ends are available. Resolving the +// hasExactVisibleEnds checks only whether exact ends are available. Resolving the // ends would open database cursors, but their numeric frontier values are not // part of cache identity. -func cacheViewEligible(debug exactDomainVisibleEnd, domains ...kv.Domain) bool { +func hasExactVisibleEnds(debug exactDomainVisibleEnd, domains ...kv.Domain) bool { for _, domain := range domains { if !debug.HasExactDomainVisibleEnd(domain) { return false From 094f054658771689fa1d3367cda8ce2ca5bd56d1 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:21:06 +0200 Subject: [PATCH 47/50] db/kv/temporal: remove unused visible-end memo --- db/kv/temporal/kv_temporal.go | 67 ++---------------------------- db/kv/temporal/kv_temporal_test.go | 53 +++++++---------------- 2 files changed, 18 insertions(+), 102 deletions(-) diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 2214c765e0e..81781cd5fa4 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "sync" - "sync/atomic" "time" "github.com/erigontech/erigon/db/datadir" @@ -268,7 +267,6 @@ type tx struct { type Tx struct { kv.Tx tx - visibleEnds domainVisibleEnds } type RwTx struct { @@ -276,59 +274,9 @@ type RwTx struct { tx } -type domainVisibleEnds struct { - // ends is atomic because a lock-free read may overlap reset after the files - // transaction reopens. state publishes a slot only after its end is stored; - // reset takes mu so an in-flight load cannot republish old data. - ends [kv.DomainLen]atomic.Uint64 - mu sync.Mutex - state atomic.Uint32 -} - -// state packs two bits per domain into one word so a single atomic load -// returns a consistent (loaded, ok) pair: loadedBit says ends[domain] is -// memoized, okBit is the memoized ok answer of DomainVisibleEnd. The array -// size asserts at compile time that both halves fit in uint32. -var _ [32 - 2*int(kv.DomainLen)]struct{} - -func visibleEndBits(domain kv.Domain) (loadedBit, okBit uint32) { - loadedBit = uint32(1) << uint32(domain) - return loadedBit, loadedBit << uint32(kv.DomainLen) -} - -func (v *domainVisibleEnds) get(tx *Tx, domain kv.Domain) (uint64, bool) { - loadedBit, okBit := visibleEndBits(domain) - state := v.state.Load() - if state&loadedBit != 0 { - return v.ends[domain].Load(), state&okBit != 0 - } - return v.load(tx, domain, loadedBit, okBit) -} - -func (v *domainVisibleEnds) load(tx *Tx, domain kv.Domain, loadedBit, okBit uint32) (uint64, bool) { - v.mu.Lock() - defer v.mu.Unlock() - - state := v.state.Load() - if state&loadedBit == 0 { - end, ok := tx.aggtx.DomainVisibleEnd(domain, tx.Tx) - v.ends[domain].Store(end) - state |= loadedBit - if ok { - state |= okBit - } - v.state.Store(state) - } - return v.ends[domain].Load(), state&okBit != 0 -} - -// reset takes mu so an in-flight load can't re-store pre-reset bits. -func (v *domainVisibleEnds) reset() { - v.mu.Lock() - defer v.mu.Unlock() - v.state.Store(0) -} - +// ForceReopenUnderlyingFilesTx replaces the transaction's pinned block and +// state file views. It leaves the database transaction unchanged, so database +// reads keep their original MVCC view while file-backed reads may see newer files. func (tx *tx) ForceReopenUnderlyingFilesTx() { if tx.blocktx != nil { tx.blocktx.Close() @@ -339,13 +287,6 @@ func (tx *tx) ForceReopenUnderlyingFilesTx() { } tx.aggtx = tx.Agg().BeginFilesRo() } - -// ForceReopenUnderlyingFilesTx swaps in a fresh files view, which can extend -// the visible frontier — drop the memoized ends so they are re-derived. -func (tx *Tx) ForceReopenUnderlyingFilesTx() { - tx.tx.ForceReopenUnderlyingFilesTx() - tx.visibleEnds.reset() -} func (tx *tx) FreezeInfo() kv.FreezeInfo { return tx.aggtx } func (tx *tx) AggTx() any { return tx.aggtx } @@ -787,7 +728,7 @@ func (tx *RwTx) DomainProgress(domain kv.Domain) uint64 { return tx.aggtx.DomainProgress(domain, tx.RwTx) } func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return tx.visibleEnds.get(tx, domain) + return tx.aggtx.DomainVisibleEnd(domain, tx.Tx) } func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index abedcd823de..92d7de04e0b 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -2,7 +2,6 @@ package temporal import ( "encoding/binary" - "sync" "testing" "time" @@ -258,10 +257,9 @@ func TestTemporalTx_PinsBlockFilesView(t *testing.T) { require.NotNil(t, roTx2.(*Tx).blocktx) } -// DomainVisibleEnd's memo serves repeat readers lock-free while first loads -// run under the memo mutex. Fresh txs each round make the two paths -// interleave across goroutines; results must stay stable (run with -race). -func TestTemporalTx_DomainVisibleEndConcurrent(t *testing.T) { +// HasExactDomainVisibleEnd avoids resolving the numeric frontier, but its +// answer must match DomainVisibleEnd's authoritative availability result. +func TestTemporalTx_HasExactDomainVisibleEndMatchesDomainVisibleEnd(t *testing.T) { t.Parallel() ctx := t.Context() @@ -287,43 +285,20 @@ func TestTemporalTx_DomainVisibleEndConcurrent(t *testing.T) { require.NoError(t, sd.Flush(ctx, rwTtx)) require.NoError(t, rwTtx.Commit()) - var expectedEnd [kv.DomainLen]uint64 - var expectedOk [kv.DomainLen]bool - baseTtx, err := temporalDb.BeginTemporalRo(ctx) + roTtx, err := temporalDb.BeginTemporalRo(ctx) require.NoError(t, err) - defer baseTtx.Rollback() - for d := range kv.DomainLen { - expectedEnd[d], expectedOk[d] = baseTtx.Debug().DomainVisibleEnd(d) - require.Equal(t, expectedOk[d], baseTtx.Debug().HasExactDomainVisibleEnd(d)) - } - baseTtx.Rollback() - require.Equal(t, uint64(2), expectedEnd[kv.StorageDomain]) - require.True(t, expectedOk[kv.StorageDomain]) - - for range 25 { - require.NoError(t, temporalDb.ViewTemporal(ctx, func(roTtx kv.TemporalTx) error { - var wg sync.WaitGroup - for range 8 { - wg.Go(func() { - for range 4 { - for d := range kv.DomainLen { - end, ok := roTtx.Debug().DomainVisibleEnd(d) - if end != expectedEnd[d] || ok != expectedOk[d] { - t.Errorf("domain %v: got (%d, %t), want (%d, %t)", d, end, ok, expectedEnd[d], expectedOk[d]) - } - } - } - }) - } - wg.Wait() - return nil - })) + defer roTtx.Rollback() + for domain := range kv.DomainLen { + _, exact := roTtx.Debug().DomainVisibleEnd(domain) + require.Equal(t, exact, roTtx.Debug().HasExactDomainVisibleEnd(domain)) } + end, exact := roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) + require.True(t, exact) + require.Equal(t, uint64(2), end) } -// A read-only temporal tx memoizes DomainVisibleEnd, while -// ForceReopenUnderlyingFilesTx swaps in a fresh files view that can extend the -// frontier — the memo must be re-derived after the swap. +// ForceReopenUnderlyingFilesTx swaps in a fresh files view, so the visible +// frontier can advance even though the database read view remains unchanged. func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { t.Parallel() ctx := t.Context() @@ -375,7 +350,7 @@ func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { require.NoError(t, err) defer freshRoTtx.Rollback() filesEnd := freshRoTtx.Debug().TxNumsInFiles(kv.StorageDomain) - require.Greater(t, filesEnd, uint64(2), "the new files must extend past the memoized frontier") + require.Greater(t, filesEnd, uint64(2), "the new files must extend past the pinned frontier") end, ok = roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) require.True(t, ok) From ad8069836a36053aa5873ff90e60bc43741a9e2b Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:04:42 +0200 Subject: [PATCH 48/50] db, execution: unify cache view eligibility --- db/kv/kv_interface.go | 3 -- db/kv/remotedb/kv_remote.go | 3 +- db/kv/temporal/kv_temporal.go | 6 --- db/kv/temporal/kv_temporal_test.go | 8 +-- db/state/aggregator.go | 9 ++-- db/state/aggregator_align_test.go | 1 - db/state/execctx/branch_cache_flush_test.go | 17 ++----- .../execctx/cache_view_eligibility_test.go | 51 ------------------- db/state/execctx/domain_shared.go | 26 +++------- db/state/execctx/domain_shared_test.go | 16 +++++- db/state/execctx/statecache_readfill_test.go | 42 +++++++++++++++ execution/exec/blocks_read_ahead.go | 5 +- 12 files changed, 75 insertions(+), 112 deletions(-) delete mode 100644 db/state/execctx/cache_view_eligibility_test.go diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index bdcb2db1769..283498a94fb 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -525,9 +525,6 @@ type TemporalDebugTx interface { // DomainVisibleEnd returns the exact exclusive txNum bound of the tx's // domain read view. ok is false when the backend cannot provide an exact bound. DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) - // HasExactDomainVisibleEnd reports DomainVisibleEnd's ok result without - // resolving the bound, which may require a database cursor. - HasExactDomainVisibleEnd(domain Domain) bool // HasCacheableLatestView reports whether the state version and visible value // files fully identify GetLatest results. Unlike DomainVisibleEnd, this may be // true when history is disabled. diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index db422bd3d79..4d30e16514c 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -258,8 +258,7 @@ func (tx *tx) DomainProgress(domain kv.Domain) uint64 { panic("not impl func (tx *tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return 0, false } -func (tx *tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { return false } -func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return false } +func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return false } func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index 81781cd5fa4..b0d5e11b093 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -733,12 +733,6 @@ func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) } -func (tx *Tx) HasExactDomainVisibleEnd(domain kv.Domain) bool { - return tx.aggtx.HasExactDomainVisibleEnd(domain) -} -func (tx *RwTx) HasExactDomainVisibleEnd(domain kv.Domain) bool { - return tx.aggtx.HasExactDomainVisibleEnd(domain) -} func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return tx.aggtx.HasCacheableLatestView(domain) } diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index 92d7de04e0b..ef7d09033d8 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -257,9 +257,7 @@ func TestTemporalTx_PinsBlockFilesView(t *testing.T) { require.NotNil(t, roTx2.(*Tx).blocktx) } -// HasExactDomainVisibleEnd avoids resolving the numeric frontier, but its -// answer must match DomainVisibleEnd's authoritative availability result. -func TestTemporalTx_HasExactDomainVisibleEndMatchesDomainVisibleEnd(t *testing.T) { +func TestTemporalTx_DomainVisibleEnd(t *testing.T) { t.Parallel() ctx := t.Context() @@ -288,10 +286,6 @@ func TestTemporalTx_HasExactDomainVisibleEndMatchesDomainVisibleEnd(t *testing.T roTtx, err := temporalDb.BeginTemporalRo(ctx) require.NoError(t, err) defer roTtx.Rollback() - for domain := range kv.DomainLen { - _, exact := roTtx.Debug().DomainVisibleEnd(domain) - require.Equal(t, exact, roTtx.Debug().HasExactDomainVisibleEnd(domain)) - } end, exact := roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) require.True(t, exact) require.Equal(t, uint64(2), end) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index e530651a238..c06ef29d5d4 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2771,10 +2771,7 @@ func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { return at.d[name].ht.iit.Progress(tx) } -// HasExactDomainVisibleEnd reports whether an exact combined frontier exists. -// History must be enabled and values files must cover the history-II frontier; -// otherwise reads mix newer database keys with older file values. -func (at *AggregatorRoTx) HasExactDomainVisibleEnd(name kv.Domain) bool { +func (at *AggregatorRoTx) hasExactDomainVisibleEnd(name kv.Domain) bool { d := at.d[name] return !d.d.HistoryDisabled && d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() } @@ -2782,13 +2779,13 @@ func (at *AggregatorRoTx) HasExactDomainVisibleEnd(name kv.Domain) bool { // HasCacheableLatestView accepts history-disabled latest state and otherwise // requires the value files to cover history-II. func (at *AggregatorRoTx) HasCacheableLatestView(name kv.Domain) bool { - return at.d[name].d.HistoryDisabled || at.HasExactDomainVisibleEnd(name) + return at.d[name].d.HistoryDisabled || at.hasExactDomainVisibleEnd(name) } // DomainVisibleEnd returns the exact combined frontier after verifying that // the values files cover history-II. func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { - if !at.HasExactDomainVisibleEnd(name) { + if !at.hasExactDomainVisibleEnd(name) { return 0, false } d := at.d[name] diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index dd57d6c92e5..6b1140ee5a7 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -256,7 +256,6 @@ func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) require.False(t, ok, "a dependency-clamped values view has no exact frontier") - require.False(t, at.HasExactDomainVisibleEnd(kv.AccountsDomain)) require.False(t, at.HasCacheableLatestView(kv.AccountsDomain), "history-enabled views remain uncacheable while values files lag history-II") } diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index ab5776ade65..8256c5b13ed 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -36,21 +36,10 @@ import ( type temporalTxWithAgg struct { kv.TemporalTx - agg any - debug kv.TemporalDebugTx + agg any } func (tx *temporalTxWithAgg) AggTx() any { return tx.agg } -func (tx *temporalTxWithAgg) Debug() kv.TemporalDebugTx { - if tx.debug != nil { - return tx.debug - } - return tx.TemporalTx.Debug() -} - -type exactVisibleDebug struct{ kv.TemporalDebugTx } - -func (*exactVisibleDebug) HasExactDomainVisibleEnd(kv.Domain) bool { return true } type branchCacheOnlyAgg struct { branchCache *commitment.BranchCache @@ -110,7 +99,8 @@ func TestBranchCacheReadsWithoutCommitmentHistory(t *testing.T) { roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer roTx.Rollback() - require.False(t, roTx.Debug().HasExactDomainVisibleEnd(kv.CommitmentDomain), + _, exact := roTx.Debug().DomainVisibleEnd(kv.CommitmentDomain) + require.False(t, exact, "the default commitment domain has no historical frontier") require.True(t, roTx.Debug().HasCacheableLatestView(kv.CommitmentDomain), "latest commitment state remains cacheable without history") @@ -442,7 +432,6 @@ func TestBoundedReadDoesNotFillBranchCache(t *testing.T) { tx := &temporalTxWithAgg{ TemporalTx: roTx, agg: agg, - debug: &exactVisibleDebug{TemporalDebugTx: roTx.Debug()}, } parent, err := execctx.NewSharedDomains(ctx, tx, log.New()) require.NoError(t, err) diff --git a/db/state/execctx/cache_view_eligibility_test.go b/db/state/execctx/cache_view_eligibility_test.go deleted file mode 100644 index 72a6d2ffd4f..00000000000 --- a/db/state/execctx/cache_view_eligibility_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/erigontech/erigon/db/kv" -) - -type exactDomainViewStub struct { - exact map[kv.Domain]bool - checked []kv.Domain -} - -func (s *exactDomainViewStub) HasExactDomainVisibleEnd(domain kv.Domain) bool { - s.checked = append(s.checked, domain) - return s.exact[domain] -} - -func TestHasExactVisibleEndsUsesViewAvailability(t *testing.T) { - exact := map[kv.Domain]bool{ - kv.AccountsDomain: true, - kv.StorageDomain: true, - kv.CodeDomain: true, - } - debug := &exactDomainViewStub{exact: exact} - require.True(t, hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) - require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain}, debug.checked) - - exact[kv.StorageDomain] = false - debug.checked = nil - require.False(t, hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain)) - require.Equal(t, []kv.Domain{kv.AccountsDomain, kv.StorageDomain}, debug.checked) -} diff --git a/db/state/execctx/domain_shared.go b/db/state/execctx/domain_shared.go index 68887192145..1624430746c 100644 --- a/db/state/execctx/domain_shared.go +++ b/db/state/execctx/domain_shared.go @@ -132,8 +132,9 @@ func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { } // StateCacheReadView binds stateCache to the state version and file ends pinned -// by tx. It returns false if the cache is nil or that exact identity cannot be -// derived. Even when true, the view is inert if the identity is not published. +// by tx. It returns false if the cache is nil or those values do not fully +// identify the transaction's latest-state view. Even when true, the view is +// inert if the identity is not published. func StateCacheReadView(tx kv.TemporalTx, stateCache *cache.StateCache) (view cache.ReadView, identityKnown bool) { if tx == nil || stateCache == nil { return cache.ReadView{}, false @@ -150,7 +151,10 @@ func stateCacheReadViewFor( stateVersion uint64, stateCache *cache.StateCache, ) (cache.ReadView, bool) { - if stateCache == nil || !hasExactVisibleEnds(debug, kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain) { + if stateCache == nil || + !debug.HasCacheableLatestView(kv.AccountsDomain) || + !debug.HasCacheableLatestView(kv.StorageDomain) || + !debug.HasCacheableLatestView(kv.CodeDomain) { return cache.ReadView{}, false } return stateCache.View(stateCacheGenerationFor(debug, stateVersion)), true @@ -176,22 +180,6 @@ func branchCacheGenerationFor(debug kv.TemporalDebugTx, stateVersion uint64) cac return cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) } -type exactDomainVisibleEnd interface { - HasExactDomainVisibleEnd(domain kv.Domain) bool -} - -// hasExactVisibleEnds checks only whether exact ends are available. Resolving the -// ends would open database cursors, but their numeric frontier values are not -// part of cache identity. -func hasExactVisibleEnds(debug exactDomainVisibleEnd, domains ...kv.Domain) bool { - for _, domain := range domains { - if !debug.HasExactDomainVisibleEnd(domain) { - return false - } - } - return true -} - func IsDomainAheadOfBlocks(ctx context.Context, tx kv.TemporalRwTx, logger log.Logger) bool { doms, err := NewSharedDomains(ctx, tx, logger) if doms != nil { diff --git a/db/state/execctx/domain_shared_test.go b/db/state/execctx/domain_shared_test.go index 0e6969625d8..ec30a11f86b 100644 --- a/db/state/execctx/domain_shared_test.go +++ b/db/state/execctx/domain_shared_test.go @@ -55,13 +55,27 @@ func NewTest(dirs datadir.Dirs) state.AggOpts { //nolint:gocritic } func newTestDb(tb testing.TB, stepSize uint64) kv.TemporalRwDB { + return newTestDbWithOptions(tb, stepSize, nil) +} + +func newTestDbWithoutHistory(tb testing.TB, stepSize uint64) kv.TemporalRwDB { + return newTestDbWithOptions(tb, stepSize, func(opts state.AggOpts) state.AggOpts { + return opts.DisableHistory() + }) +} + +func newTestDbWithOptions(tb testing.TB, stepSize uint64, configure func(state.AggOpts) state.AggOpts) kv.TemporalRwDB { tb.Helper() logger := log.New() dirs := datadir.New(tb.TempDir()) db := mdbx.New(dbcfg.ChainDB, logger).InMem(tb, dirs.Chaindata).GrowthStep(32 * datasize.MB).MapSize(2 * datasize.GB).MustOpen() tb.Cleanup(db.Close) - agg := NewTest(dirs).StepSize(stepSize).Logger(logger).MustOpen(tb.Context(), db) + opts := NewTest(dirs).StepSize(stepSize).Logger(logger) + if configure != nil { + opts = configure(opts) + } + agg := opts.MustOpen(tb.Context(), db) tb.Cleanup(agg.Close) err := agg.OpenFolder() require.NoError(tb, err) diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index b921ad3d0c1..93ddde3ebf0 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -98,6 +98,48 @@ func currentStateCacheView(t *testing.T, db kv.TemporalRoDB, stateCache *cache.S return stateCache.View(currentStateCacheGeneration(t, db)) } +func TestStateCacheReadViewSupportsHistoryDisabledLatestState(t *testing.T) { + t.Parallel() + + db := newTestDbWithoutHistory(t, 16) + tx, err := db.BeginTemporalRo(t.Context()) + require.NoError(t, err) + defer tx.Rollback() + + debug := tx.Debug() + for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { + require.True(t, debug.HasCacheableLatestView(domain)) + _, exact := debug.DomainVisibleEnd(domain) + require.False(t, exact) + } + + stateVersion, err := rawdb.GetStateVersion(tx) + require.NoError(t, err) + generation := cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) + stateCache := newSmallStateCache() + t.Cleanup(stateCache.Close) + publisher := stateCache.Publisher() + publisher.Initialize(generation) + publication := publisher.Begin() + key, value := []byte("account"), []byte("value") + publication.Publish(generation, 1, []cache.Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: value, + }}, false) + + view, identityKnown := execctx.StateCacheReadView(tx, stateCache) + require.True(t, identityKnown) + got, ok := view.Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, value, got) +} + // During an in-flight unwind this SharedDomains is detached from StateCache, // so the assertion compares the bounded database read without observing the // cache generation that still serves readers of the durable state. diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index f99d383ced2..3fd89810789 100644 --- a/execution/exec/blocks_read_ahead.go +++ b/execution/exec/blocks_read_ahead.go @@ -91,8 +91,9 @@ type cachePopulatingGetter struct { } // readAheadGetter uses StateCache only when the transaction's durable state and -// pinned files form an exact identity. Returning the transaction unchanged on -// uncertainty still lets read-ahead warm the database and OS page cache. +// pinned files fully identify its latest-state view. Returning the transaction +// unchanged on uncertainty still lets read-ahead warm the database and OS page +// cache. func readAheadGetter(ttx kv.TemporalTx, sc *cache.StateCache) kv.TemporalGetter { view, identityKnown := execctx.StateCacheReadView(ttx, sc) if !identityKnown { From 7ae65de64e32c825b61e00f5a6e8e6a5bdfd5884 Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:18:57 +0200 Subject: [PATCH 49/50] db: remove unused domain visible end --- db/kv/kv_interface.go | 9 +-- db/kv/remotedb/kv_remote.go | 5 +- db/kv/temporal/kv_temporal.go | 6 -- db/kv/temporal/kv_temporal_test.go | 56 +++---------------- db/state/aggregator.go | 16 +----- db/state/aggregator_align_test.go | 24 +++----- db/state/execctx/branch_cache_flush_test.go | 3 - .../execctx/statecache_readfill_bench_test.go | 23 +------- db/state/execctx/statecache_readfill_test.go | 2 - .../statecache_rpc_integration_test.go | 5 -- db/state/inverted_index.go | 23 +------- db/state/inverted_index_test.go | 6 +- 12 files changed, 26 insertions(+), 152 deletions(-) diff --git a/db/kv/kv_interface.go b/db/kv/kv_interface.go index 283498a94fb..e2fe4a902d2 100644 --- a/db/kv/kv_interface.go +++ b/db/kv/kv_interface.go @@ -520,14 +520,11 @@ type TemporalDebugTx interface { // DomainProgress is a best-effort progress number for reporting: it mixes // an exclusive files end with an inclusive DB txNum (so it is ±1 depending // on which side wins) and falls back to step granularity when history is - // disabled. For an exact bound use DomainVisibleEnd. + // disabled. It must not be used as an exact read-view bound. DomainProgress(domain Domain) (txNum uint64) - // DomainVisibleEnd returns the exact exclusive txNum bound of the tx's - // domain read view. ok is false when the backend cannot provide an exact bound. - DomainVisibleEnd(domain Domain) (visibleEnd uint64, ok bool) // HasCacheableLatestView reports whether the state version and visible value - // files fully identify GetLatest results. Unlike DomainVisibleEnd, this may be - // true when history is disabled. + // files fully identify GetLatest results. This may be true when history is + // disabled because latest DB values do not require a history frontier. HasCacheableLatestView(domain Domain) bool IIProgress(name InvertedIdx) (txNum uint64) StepSize() uint64 diff --git a/db/kv/remotedb/kv_remote.go b/db/kv/remotedb/kv_remote.go index 4d30e16514c..e323f8cf52b 100644 --- a/db/kv/remotedb/kv_remote.go +++ b/db/kv/remotedb/kv_remote.go @@ -255,10 +255,7 @@ func (tx *tx) Retire(ctx context.Context, cutoffs kv.RetireCutoffs) (int, error) } func (tx *tx) DomainFiles(domain ...kv.Domain) kv.VisibleFiles { panic("not implemented") } func (tx *tx) DomainProgress(domain kv.Domain) uint64 { panic("not implemented") } -func (tx *tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return 0, false -} -func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return false } +func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return false } func (tx *tx) GetLatestFromDB(domain kv.Domain, k []byte) (v []byte, step kv.Step, found bool, err error) { panic("not implemented") } diff --git a/db/kv/temporal/kv_temporal.go b/db/kv/temporal/kv_temporal.go index b0d5e11b093..493c694df78 100644 --- a/db/kv/temporal/kv_temporal.go +++ b/db/kv/temporal/kv_temporal.go @@ -727,12 +727,6 @@ func (tx *Tx) DomainProgress(domain kv.Domain) uint64 { func (tx *RwTx) DomainProgress(domain kv.Domain) uint64 { return tx.aggtx.DomainProgress(domain, tx.RwTx) } -func (tx *Tx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return tx.aggtx.DomainVisibleEnd(domain, tx.Tx) -} -func (tx *RwTx) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return tx.aggtx.DomainVisibleEnd(domain, tx.RwTx) -} func (tx *tx) HasCacheableLatestView(domain kv.Domain) bool { return tx.aggtx.HasCacheableLatestView(domain) } diff --git a/db/kv/temporal/kv_temporal_test.go b/db/kv/temporal/kv_temporal_test.go index ef7d09033d8..b05ec725c1a 100644 --- a/db/kv/temporal/kv_temporal_test.go +++ b/db/kv/temporal/kv_temporal_test.go @@ -257,43 +257,9 @@ func TestTemporalTx_PinsBlockFilesView(t *testing.T) { require.NotNil(t, roTx2.(*Tx).blocktx) } -func TestTemporalTx_DomainVisibleEnd(t *testing.T) { - t.Parallel() - ctx := t.Context() - - mdbxDb := memdb.NewTestDB(t, dbcfg.ChainDB) - dirs := datadir.New(t.TempDir()) - agg := state.NewTest(dirs).StepSize(1).MustOpen(ctx, mdbxDb) - defer agg.Close() - temporalDb, err := New(mdbxDb, agg, nil) - require.NoError(t, err) - defer temporalDb.Close() - - acc := common.HexToAddress("0x1234567890123456789012345678901234567890") - slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001") - storageK := append(append([]byte{}, acc[:]...), slot[:]...) - - rwTtx, err := temporalDb.BeginTemporalRw(ctx) - require.NoError(t, err) - defer rwTtx.Rollback() - sd, err := execctx.NewSharedDomains(ctx, rwTtx, log.Root()) - require.NoError(t, err) - defer sd.Close() - require.NoError(t, sd.DomainPut(kv.StorageDomain, rwTtx, storageK, []byte{1}, 1, nil)) - require.NoError(t, sd.Flush(ctx, rwTtx)) - require.NoError(t, rwTtx.Commit()) - - roTtx, err := temporalDb.BeginTemporalRo(ctx) - require.NoError(t, err) - defer roTtx.Rollback() - end, exact := roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) - require.True(t, exact) - require.Equal(t, uint64(2), end) -} - -// ForceReopenUnderlyingFilesTx swaps in a fresh files view, so the visible -// frontier can advance even though the database read view remains unchanged. -func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { +// ForceReopenUnderlyingFilesTx swaps in a fresh files view while keeping the +// database read transaction unchanged. +func TestTemporalTx_ForceReopenRefreshesFilesView(t *testing.T) { t.Parallel() ctx := t.Context() @@ -322,9 +288,7 @@ func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { roTtx, err := temporalDb.BeginTemporalRo(ctx) require.NoError(t, err) defer roTtx.Rollback() - end, ok := roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) - require.True(t, ok) - require.Equal(t, uint64(2), end) + pinnedFilesEnd := roTtx.Debug().TxNumsInFiles(kv.StorageDomain) // Write past the RO tx's MVCC view and move the data into files, which are // visible regardless of the DB read view. @@ -344,16 +308,14 @@ func TestTemporalTx_ForceReopenRefreshesDomainVisibleEnd(t *testing.T) { require.NoError(t, err) defer freshRoTtx.Rollback() filesEnd := freshRoTtx.Debug().TxNumsInFiles(kv.StorageDomain) - require.Greater(t, filesEnd, uint64(2), "the new files must extend past the pinned frontier") + require.Greater(t, filesEnd, pinnedFilesEnd, "the new files must extend past the pinned files view") - end, ok = roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) - require.True(t, ok) - require.Equal(t, uint64(2), end, "the pinned files view cannot see the new files before reopen") + require.Equal(t, pinnedFilesEnd, roTtx.Debug().TxNumsInFiles(kv.StorageDomain), + "the transaction must retain its pinned files view before reopen") roTtx.(*Tx).ForceReopenUnderlyingFilesTx() - end, ok = roTtx.Debug().DomainVisibleEnd(kv.StorageDomain) - require.True(t, ok) - require.Equal(t, filesEnd, end, "the frontier must reflect the fresh files view after reopen") + require.Equal(t, filesEnd, roTtx.Debug().TxNumsInFiles(kv.StorageDomain), + "the transaction must use the fresh files view after reopen") } func TestTemporalTx_RangeAsOf_StorageDomain(t *testing.T) { diff --git a/db/state/aggregator.go b/db/state/aggregator.go index c06ef29d5d4..77f5906f38e 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -2771,25 +2771,11 @@ func (at *AggregatorRoTx) DomainProgress(name kv.Domain, tx kv.Tx) uint64 { return at.d[name].ht.iit.Progress(tx) } -func (at *AggregatorRoTx) hasExactDomainVisibleEnd(name kv.Domain) bool { - d := at.d[name] - return !d.d.HistoryDisabled && d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() -} - // HasCacheableLatestView accepts history-disabled latest state and otherwise // requires the value files to cover history-II. func (at *AggregatorRoTx) HasCacheableLatestView(name kv.Domain) bool { - return at.d[name].d.HistoryDisabled || at.hasExactDomainVisibleEnd(name) -} - -// DomainVisibleEnd returns the exact combined frontier after verifying that -// the values files cover history-II. -func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { - if !at.hasExactDomainVisibleEnd(name) { - return 0, false - } d := at.d[name] - return d.ht.iit.visibleEnd(tx), true + return d.d.HistoryDisabled || d.files.EndTxNum() >= d.ht.iit.files.EndTxNum() } func (at *AggregatorRoTx) IIProgress(name kv.InvertedIdx, tx kv.Tx) uint64 { diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 6b1140ee5a7..6e9c827e3a3 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -17,7 +17,6 @@ package state import ( - "context" "fmt" "testing" @@ -232,14 +231,12 @@ func craftedClampedVisible(t *testing.T, agg *Aggregator) { agg.visible.Store(crafted) } -// A dependency-clamped values view has no exact frontier: reads mix fresh -// DB-resident keys with older file values for gap keys, and raising the -// dependent file's visibility later reveals state without any cache apply — -// nothing would invalidate a fill made during the clamp. DomainVisibleEnd -// must report ok=false so such views never fill. -func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { +// A dependency-clamped values view cannot safely fill a shared latest-state +// cache: later file publication can reveal state without a database version +// change to invalidate those fills. +func TestHasCacheableLatestViewRejectsClampedView(t *testing.T) { t.Parallel() - db, agg := testDbAndAggregatorv3(t, alignStepSize) + _, agg := testDbAndAggregatorv3(t, alignStepSize) generateStateFiles(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) generateCommitmentFile(t, agg.Dirs(), []testFileRange{{0, 1}, {1, 2}}) @@ -250,19 +247,12 @@ func TestDomainVisibleEnd_ClampedViewHasNoExactFrontier(t *testing.T) { at := agg.BeginFilesRo() defer at.Close() - tx, err := db.BeginRo(context.Background()) - require.NoError(t, err) - defer tx.Rollback() - - _, ok := at.DomainVisibleEnd(kv.AccountsDomain, tx) - require.False(t, ok, "a dependency-clamped values view has no exact frontier") require.False(t, at.HasCacheableLatestView(kv.AccountsDomain), "history-enabled views remain uncacheable while values files lag history-II") } -// The forbid assert must also watch the history-II ends: they are the base of -// what DomainVisibleEnd reports, and with values dependency-clamped below the -// ceiling they can lower while every values end stays put. +// Cache eligibility depends on values files covering history-II, so the +// visibility-lowering assert must watch history-II ends as well as values ends. func TestVisibilityLowering_StateCacheGuardsHistoryIIEnd(t *testing.T) { t.Parallel() _, agg := testDbAndAggregatorv3(t, alignStepSize) diff --git a/db/state/execctx/branch_cache_flush_test.go b/db/state/execctx/branch_cache_flush_test.go index 8256c5b13ed..d71a8b4323a 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -99,9 +99,6 @@ func TestBranchCacheReadsWithoutCommitmentHistory(t *testing.T) { roTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer roTx.Rollback() - _, exact := roTx.Debug().DomainVisibleEnd(kv.CommitmentDomain) - require.False(t, exact, - "the default commitment domain has no historical frontier") require.True(t, roTx.Debug().HasCacheableLatestView(kv.CommitmentDomain), "latest commitment state remains cacheable without history") diff --git a/db/state/execctx/statecache_readfill_bench_test.go b/db/state/execctx/statecache_readfill_bench_test.go index 943744a811f..165a11dd47f 100644 --- a/db/state/execctx/statecache_readfill_bench_test.go +++ b/db/state/execctx/statecache_readfill_bench_test.go @@ -103,7 +103,7 @@ func BenchmarkGetLatestColdNegativeRwNoCache(b *testing.B) { var benchmarkTemporalGetter kv.TemporalGetter -func benchmarkCacheGetterConstruction(b *testing.B, resolveVisibleEnds bool) { +func BenchmarkCacheGetterConstruction(b *testing.B) { db := benchSeedDb(b) ctx := b.Context() baseTx, err := db.BeginTemporalRo(ctx) @@ -116,12 +116,6 @@ func benchmarkCacheGetterConstruction(b *testing.B, resolveVisibleEnds bool) { defer stateCache.Close() sd.SetCanonicalCachesForTest(stateCache) - domains := [...]kv.Domain{ - kv.AccountsDomain, - kv.StorageDomain, - kv.CodeDomain, - kv.CommitmentDomain, - } b.ResetTimer() b.StopTimer() for range b.N { @@ -130,23 +124,8 @@ func benchmarkCacheGetterConstruction(b *testing.B, resolveVisibleEnds bool) { b.Fatal(err) } b.StartTimer() - if resolveVisibleEnds { - debug := tx.Debug() - for _, domain := range domains { - debug.DomainVisibleEnd(domain) - } - } benchmarkTemporalGetter = sd.AsGetter(tx) b.StopTimer() tx.Rollback() } } - -func BenchmarkCacheGetterConstruction(b *testing.B) { - b.Run("exactness_check", func(b *testing.B) { - benchmarkCacheGetterConstruction(b, false) - }) - b.Run("visible_end_resolution", func(b *testing.B) { - benchmarkCacheGetterConstruction(b, true) - }) -} diff --git a/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 93ddde3ebf0..55c5a942873 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -109,8 +109,6 @@ func TestStateCacheReadViewSupportsHistoryDisabledLatestState(t *testing.T) { debug := tx.Debug() for _, domain := range []kv.Domain{kv.AccountsDomain, kv.StorageDomain, kv.CodeDomain} { require.True(t, debug.HasCacheableLatestView(domain)) - _, exact := debug.DomainVisibleEnd(domain) - require.False(t, exact) } stateVersion, err := rawdb.GetStateVersion(tx) diff --git a/db/state/execctx/statecache_rpc_integration_test.go b/db/state/execctx/statecache_rpc_integration_test.go index 53ff926c65f..92038a03da2 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -399,11 +399,6 @@ func TestAccountOnlyDeleteDoesNotBlockUnrelatedCodeFill(t *testing.T) { freshTx, err := db.BeginTemporalRo(ctx) require.NoError(t, err) defer freshTx.Rollback() - codeEnd, ok := freshTx.Debug().DomainVisibleEnd(kv.CodeDomain) - require.True(t, ok) - accountsEnd, ok := freshTx.Debug().DomainVisibleEnd(kv.AccountsDomain) - require.True(t, ok) - require.Less(t, codeEnd, accountsEnd) freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) require.NoError(t, err) diff --git a/db/state/inverted_index.go b/db/state/inverted_index.go index e535d609df2..43f3631d58f 100644 --- a/db/state/inverted_index.go +++ b/db/state/inverted_index.go @@ -1250,31 +1250,14 @@ func (ii *InvertedIndex) minTxNumInDB(tx kv.Tx) uint64 { return 0 } -func (ii *InvertedIndex) lastTxNumInDB(tx kv.Tx) (uint64, bool) { +func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { lst, _ := kv.LastKey(tx, ii.KeysTable) if len(lst) == 0 { - return 0, false + return 0 } - return binary.BigEndian.Uint64(lst), true -} - -func (ii *InvertedIndex) maxTxNumInDB(tx kv.Tx) uint64 { - txNum, _ := ii.lastTxNumInDB(tx) - return txNum + return binary.BigEndian.Uint64(lst) } func (iit *InvertedIndexRoTx) Progress(tx kv.Tx) uint64 { return max(iit.files.EndTxNum(), iit.ii.maxTxNumInDB(tx)) } - -// visibleEnd is the exclusive txNum bound of what this view can see: the max -// of its two components, because GetLatest reads their union. Both sides are -// required — on a snapshot-synced or fully-pruned datadir the DB side is empty -// and the files carry the whole bound. -func (iit *InvertedIndexRoTx) visibleEnd(tx kv.Tx) uint64 { - dbEnd, ok := iit.ii.lastTxNumInDB(tx) - if ok && dbEnd < math.MaxUint64 { - dbEnd++ - } - return max(iit.files.EndTxNum(), dbEnd) -} diff --git a/db/state/inverted_index_test.go b/db/state/inverted_index_test.go index 85f77df954c..1cd6497de53 100644 --- a/db/state/inverted_index_test.go +++ b/db/state/inverted_index_test.go @@ -87,7 +87,7 @@ func testDbAndInvertedIndex(tb testing.TB, aggStep uint64, logger log.Logger) (k return db, ii } -func TestInvertedIndexVisibleEnd(t *testing.T) { +func TestInvertedIndexProgress(t *testing.T) { db, ii := testDbAndInvertedIndex(t, 16, log.New()) tx, err := db.BeginRw(t.Context()) require.NoError(t, err) @@ -96,21 +96,17 @@ func TestInvertedIndexVisibleEnd(t *testing.T) { iit := ii.beginForTests() defer iit.Close() require.Zero(t, iit.Progress(tx)) - require.Zero(t, iit.visibleEnd(tx)) var txNum [8]byte require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) require.Zero(t, iit.Progress(tx)) - require.Equal(t, uint64(1), iit.visibleEnd(tx)) binary.BigEndian.PutUint64(txNum[:], 100) require.NoError(t, tx.Put(ii.KeysTable, txNum[:], []byte{1})) require.Equal(t, uint64(100), iit.Progress(tx)) - require.Equal(t, uint64(101), iit.visibleEnd(tx)) iit.files = visibleFiles{{endTxNum: 200}} require.Equal(t, uint64(200), iit.Progress(tx)) - require.Equal(t, uint64(200), iit.visibleEnd(tx)) } func TestInvIndexPruningCorrectness(t *testing.T) { From 9521ae30bf0f2a14f09912f15e8210be1a9d485e Mon Sep 17 00:00:00 2001 From: Andrew Ashikhmin <34320705+yperbasis@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:43:15 +0200 Subject: [PATCH 50/50] execution/cache: fence publishers across reset --- db/state/aggregator.go | 3 +- execution/cache/generation_gate.go | 32 ++++++++++------ execution/cache/generation_gate_test.go | 46 +++++++++++++++++++++++ execution/cache/state_cache.go | 5 ++- execution/commitment/branch_cache.go | 4 +- execution/commitment/branch_cache_test.go | 2 + 6 files changed, 76 insertions(+), 16 deletions(-) diff --git a/db/state/aggregator.go b/db/state/aggregator.go index 77f5906f38e..07435231d35 100644 --- a/db/state/aggregator.go +++ b/db/state/aggregator.go @@ -568,7 +568,8 @@ func (a *Aggregator) BindStateCache(stateCache *cache.StateCache) { // ResetExecutionCaches revokes state backed by execution tables that are about // to be replaced outside SharedDomains.Commit. Both caches remain unpublished -// until a later canonical owner initializes or publishes their new generation. +// until a later canonical owner acquires new publishers and establishes their +// replacement generations. func (a *Aggregator) ResetExecutionCaches() { a.dirtyFilesLock.Lock() defer a.dirtyFilesLock.Unlock() diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go index 0ab9a4cfac9..f53d98faddb 100644 --- a/execution/cache/generation_gate.go +++ b/execution/cache/generation_gate.go @@ -81,8 +81,11 @@ type publishedGeneration struct { // durable database state over one compatible files view. type GenerationGate struct { // current is nil until initialization and while publication is in progress. - current atomic.Pointer[publishedGeneration] - admissionMu sync.RWMutex + current atomic.Pointer[publishedGeneration] + // resetLineage invalidates publisher handles captured before a full reset. + // Publisher reads it without publicationMu, so the counter must be atomic. + resetLineage atomic.Uint64 + admissionMu sync.RWMutex // publicationMu orders durable cache publication with independent changes // to the backing-file view. Begin holds it until Publish or Abort. publicationMu sync.Mutex @@ -163,14 +166,19 @@ func (v GenerationView) Admit(fill func()) bool { return true } -// GenerationPublisher is the mutation capability for one generation gate. +// GenerationPublisher is the mutation capability for one reset lineage. type GenerationPublisher struct { - gate *GenerationGate + gate *GenerationGate + resetLineage uint64 } -// Publisher returns a handle that can initialize and publish the gate. +// Publisher returns a handle bound to the current reset lineage. Reset makes +// existing handles inert so older work cannot re-establish a cleared generation. func (g *GenerationGate) Publisher() GenerationPublisher { - return GenerationPublisher{gate: g} + if g == nil { + return GenerationPublisher{} + } + return GenerationPublisher{gate: g, resetLineage: g.resetLineage.Load()} } // Initialize binds the gate to identity's state version and the newest files @@ -183,7 +191,7 @@ func (p GenerationPublisher) Initialize(identity Generation, clear func()) { gate := p.gate gate.publicationMu.Lock() defer gate.publicationMu.Unlock() - if gate.closed { + if gate.closed || p.resetLineage != gate.resetLineage.Load() { return } gate.admissionMu.Lock() @@ -222,7 +230,7 @@ func (p GenerationPublisher) Begin() *GenerationPublication { } gate := p.gate gate.publicationMu.Lock() - if gate.closed { + if gate.closed || p.resetLineage != gate.resetLineage.Load() { gate.publicationMu.Unlock() return nil } @@ -304,8 +312,9 @@ func (p *GenerationPublication) Publish(identity Generation, apply, clear func() completed = true } -// Reset revokes all views, clears the cache, and leaves it unpublished. The -// next durable publication can start from this empty state. +// Reset revokes all views, clears the cache, and leaves it unpublished. It also +// invalidates existing publisher handles; only a handle acquired afterwards can +// establish the next durable generation. func (g *GenerationGate) Reset(clear func()) { if g == nil { return @@ -317,6 +326,7 @@ func (g *GenerationGate) Reset(clear func()) { } g.admissionMu.Lock() defer g.admissionMu.Unlock() + g.resetLineage.Add(1) g.current.Store(nil) g.files = FilesView{} g.filesKnown = false @@ -343,7 +353,7 @@ func (p GenerationPublisher) BeginBackingChange(files FilesView, reconcile func( } gate := p.gate gate.publicationMu.Lock() - if gate.closed { + if gate.closed || p.resetLineage != gate.resetLineage.Load() { gate.publicationMu.Unlock() return nil } diff --git a/execution/cache/generation_gate_test.go b/execution/cache/generation_gate_test.go index b7b40fc9aaa..cd2a8ef64b6 100644 --- a/execution/cache/generation_gate_test.go +++ b/execution/cache/generation_gate_test.go @@ -89,3 +89,49 @@ func TestGenerationPublicationRejectsOlderStateVersion(t *testing.T) { require.True(t, newerView.Current(), "an older publication must restore the newer token") require.False(t, gate.View(StateGeneration(2, 0, 0, 0)).Current()) } + +func TestGenerationPublisherCannotPublishAcrossReset(t *testing.T) { + var gate GenerationGate + stalePublisher := gate.Publisher() + generation := StateGeneration(1, 0, 0, 0) + stalePublisher.Initialize(generation, nil) + + gate.Reset(nil) + applied := false + publication := stalePublisher.Begin() + if publication != nil { + publication.Publish(generation, func() { applied = true }, nil) + } + + require.False(t, applied, "a publisher created before Reset must not apply updates afterwards") + require.False(t, gate.View(generation).Current(), "a publisher created before Reset must not restore the old generation") +} + +func TestGenerationPublisherCannotInitializeAcrossReset(t *testing.T) { + var gate GenerationGate + stalePublisher := gate.Publisher() + generation := StateGeneration(1, 0, 0, 0) + + gate.Reset(nil) + cleared := false + stalePublisher.Initialize(generation, func() { cleared = true }) + + require.False(t, cleared, "a publisher created before Reset must not initialize or clear the cache afterwards") + require.False(t, gate.View(generation).Current(), "a publisher created before Reset must not initialize a generation") +} + +func TestGenerationPublisherCannotChangeBackingAcrossReset(t *testing.T) { + var gate GenerationGate + stalePublisher := gate.Publisher() + + gate.Reset(nil) + reconciled, cleared := false, false + change := stalePublisher.BeginBackingChange(BranchFilesView(1), func(bool) bool { + reconciled = true + return true + }, func() { cleared = true }) + change.Finish() + + require.False(t, reconciled, "a publisher created before Reset must not reconcile backing files afterwards") + require.False(t, cleared, "a publisher created before Reset must not clear the cache afterwards") +} diff --git a/execution/cache/state_cache.go b/execution/cache/state_cache.go index 9b4a2db9bef..7b9a0db92f9 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -314,8 +314,9 @@ func (c *StateCache) resetProvenanceAndClearLocked() { c.clearLocked() } -// Reset revokes all views, clears entries and file provenance, and leaves the -// cache unpublished until its canonical owner initializes or publishes it. +// Reset revokes all views, clears entries and file provenance, and invalidates +// existing publishers. A canonical owner must acquire a new publisher before it +// can initialize or publish the cache again. func (c *StateCache) Reset() { if c == nil { return diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 942efcbf779..99de4ba6ba6 100644 --- a/execution/commitment/branch_cache.go +++ b/execution/commitment/branch_cache.go @@ -351,8 +351,8 @@ func (c *BranchCache) Close() { activeBranchCaches.Add(-1) } -// Reset clears cached branches and revokes all views until the next durable -// publication. +// Reset clears cached branches, revokes all views, and invalidates existing +// publishers. Durable publication requires a publisher acquired afterwards. func (c *BranchCache) Reset() { c.generation.Reset(c.resetProvenanceAndClear) } diff --git a/execution/commitment/branch_cache_test.go b/execution/commitment/branch_cache_test.go index 1ca89a4da62..10ab99583b8 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -500,6 +500,8 @@ func TestBranchCache_ResetRevokesViewsUntilNextPublication(t *testing.T) { _, _, ok = c.View(testBranchGeneration(1)).Get(key) require.False(t, ok, "Reset must leave the cache unpublished") + require.Nil(t, publisher.Begin(), "a publisher created before Reset must remain inert") + publisher = c.Publisher() publication := publisher.Begin() publication.Publish(testBranchGeneration(2), []BranchUpdate{{ Key: key,