diff --git a/cmd/integration/commands/stages.go b/cmd/integration/commands/stages.go index e55f94ca9b8..3256c7e2649 100644 --- a/cmd/integration/commands/stages.go +++ b/cmd/integration/commands/stages.go @@ -714,15 +714,11 @@ 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 } - 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,13 +820,18 @@ 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 -// 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 { @@ -844,9 +845,9 @@ func execBlocksBatch(ctx context.Context, db kv.TemporalRwDB, st *stagedsync.Syn } defer doms.Close() doms.SetInMemHistoryReads(false) - doms.SetStateCache(stateCache) + doms.SetCanonicalCaches(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/cmd/integration/commands/stages_test.go b/cmd/integration/commands/stages_test.go new file mode 100644 index 00000000000..3d86b491931 --- /dev/null +++ b/cmd/integration/commands/stages_test.go @@ -0,0 +1,96 @@ +// 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) + seedDomains.SetCanonicalCaches(nil) + 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) + unwindDomains.SetCanonicalCaches(nil) + + 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) + nextDomains.SetCanonicalCaches(nil) + 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") +} 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/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 6be7861ae8f..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, an invariant the aggregator enforces once a - // fill-enabled cache is wired over it (ForbidVisibilityLowering). + // 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 @@ -796,6 +792,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 c6bce6a926f..acb561c2e20 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" ) @@ -90,13 +91,14 @@ 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). + // 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 - 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,15 +551,50 @@ 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. -// 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 + } + 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. +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() +} + +// 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) { @@ -706,12 +743,21 @@ 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 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) + // 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() + } a.closeDirtyFiles() a.recalcVisibleFiles(nil) } @@ -744,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() } @@ -1876,12 +1921,56 @@ 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.BackingChange + branch *cache.BackingChange +} + +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 // 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{} @@ -1901,14 +1990,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 shared cache is wired — file-provenance watermarks only advance", d, prevEnd, nextEnd)) } if prev.dhii[d] == nil || next.dhii[d] == nil { continue @@ -1916,18 +2005,22 @@ 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 shared cache is wired — exact cache-view eligibility depends on history-II coverage", 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 + // Reclamation is cheap here under dirtyFilesLock and keeps the hot reader + // Close path likely to remain lock-free. reclaimFiles(a.reclaimRetiredLocked()) } @@ -2675,34 +2768,45 @@ 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) +} + func (at *AggregatorRoTx) Dirs() datadir.Dirs { return at.a.dirs } func (at *AggregatorRoTx) standaloneIIs() []*InvertedIndexRoTx { return at.iis[:at.iisCount] } 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) } -func (at *AggregatorRoTx) DomainVisibleEnd(name kv.Domain, tx kv.Tx) (uint64, bool) { + +// 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] - if d.d.HistoryDisabled { - return 0, false - } - // 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() { + 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) { + 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) } @@ -2738,40 +2842,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 @@ -2779,9 +2868,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) { diff --git a/db/state/aggregator_align_test.go b/db/state/aggregator_align_test.go index 2d4be50b798..94849a33854 100644 --- a/db/state/aggregator_align_test.go +++ b/db/state/aggregator_align_test.go @@ -18,12 +18,15 @@ package state import ( "context" + "fmt" "testing" "github.com/stretchr/testify/require" "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 +58,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() @@ -181,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 @@ -225,6 +249,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 @@ -260,3 +285,107 @@ 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 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(cache.StateGeneration(1, 0, 0, 0)) + execctx.BindStateCacheToAggregator(cacheAggregatorHolder{agg}, stateCache) + + accountKey := make([]byte, 20) + accountKey[0] = 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) + + branchCache := agg.d[kv.CommitmentDomain].branchCache + require.NotNil(t, branchCache) + branchPublisher := branchCache.Publisher() + branchPublisher.Initialize(cache.BranchGeneration(1, 0)) + branchKey := []byte{0x01} + branchView := branchCache.View(cache.BranchGeneration(1, 0)) + 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(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(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") +} + +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(cache.StateGeneration(1, 0, 0, 0)) + accountKey := make([]byte, 20) + accountKey[0] = 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) + + 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(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/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..c86f5a193d4 100644 --- a/db/state/execctx/branch_cache_flush_test.go +++ b/db/state/execctx/branch_cache_flush_test.go @@ -17,16 +17,401 @@ package execctx_test import ( + "bytes" + "encoding/binary" + "errors" "testing" + "time" "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/db/state/kvmetrics" + "github.com/erigontech/erigon/execution/cache" + "github.com/erigontech/erigon/execution/commitment" ) -// Use Commit (not Flush) so the rebuilt branch refreshes the BranchCache entry. +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 +} + +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) + require.NoError(t, err) + 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") +} + +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) + 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) { + 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.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)) + 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.SetCanonicalCachesForTest(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") + }) + } +} + +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) db := newTestDb(t, stepSize) @@ -42,6 +427,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) @@ -67,3 +453,124 @@ 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) + + generation := branchGenerationForTx(t, roTx) + branchCache.Publisher().Initialize(generation) + + key := []byte{0xa0, 0xb0} + published := branchCache.View(generation) + 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) + 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() + + 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() + unwindSD.SetCanonicalCaches(nil) + + provider, ok := unwindTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + oldView := branchCache.View(branchGenerationForTx(t, unwindTx)) + 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() + _, _, ok = branchCache.View(branchGenerationForTx(t, readTx)).Get(cacheOnlyKey) + require.False(t, ok, "the unwound generation must not retain a cache-only discarded branch") +} + +func TestFailedCommitKeepsBranchCacheGeneration(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) + seedSD.SetCanonicalCaches(nil) + 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() + sd.SetCanonicalCaches(nil) + + provider, ok := rwTx.AggTx().(commitment.BranchCacheProvider) + require.True(t, ok) + branchCache := provider.BranchCache() + view := branchCache.View(branchGenerationForTx(t, rwTx)) + 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 keep the previous branch generation") + require.Equal(t, []byte("durable"), value) +} 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/codehash_routing_test.go b/db/state/execctx/codehash_routing_test.go index 211e3cadaea..ee8a796fdca 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() @@ -32,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 @@ -45,7 +46,7 @@ func TestCodeHashForAddr_InBatchAccountWinsOverStaleLRU(t *testing.T) { } var staleArr [32]byte copy(staleArr[:], stale[:]) - sc.View(frontierAt(0)).SeedAddrCodeHash(addr[:], staleArr, 0) + 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} @@ -69,13 +70,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() @@ -97,15 +94,15 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(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)) seedSD.Close() - _, ok := sc.View(nil).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 = sc.View(nil).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,16 +111,17 @@ func TestCodeHashForAddr_CacheSourcedRecordDoesNotSeedMapping(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) - _, 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, db, 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 -// 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() @@ -145,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)) @@ -159,11 +158,11 @@ 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) - h, ok := sc.View(nil).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/domain_shared.go b/db/state/execctx/domain_shared.go index 311cab6301d..180594f951f 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,110 +82,113 @@ 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) +type cacheViews struct { + state cache.ReadView + branch commitment.BranchReadView +} + +type cacheGenerations struct { + state cache.Generation + branch cache.Generation } -func (m *domainVisibleEndMemo) load(tx kv.TemporalTx, domain kv.Domain, viewID uint64, loadedBit, okBit uint32) (uint64, bool) { - m.mu.Lock() - defer m.mu.Unlock() +func (g cacheGenerations) withStateVersion(stateVersion uint64) cacheGenerations { + g.state = g.state.WithStateVersion(stateVersion) + g.branch = g.branch.WithStateVersion(stateVersion) + return g +} - cachedViewID := m.viewID.Load() - state := m.state.Load() - if cachedViewID == viewID && state&loadedBit != 0 { - return m.ends[domain].Load(), state&okBit != 0 +// 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 +// eligibility must always come from the transaction being read. +func (sd *SharedDomains) cacheViewsFor(tx kv.TemporalTx) cacheViews { + if tx == nil { + return cacheViews{} } + stateVersion := sd.baseStateVersion + if tx.ViewID() == sd.baseViewID { + if !sd.baseStateVersionKnown { + return cacheViews{} + } + } else { + var err error + stateVersion, err = rawdb.GetStateVersion(tx) + if err != nil { + return cacheViews{} + } + } + debug := tx.Debug() + var views cacheViews + if stateView, identityKnown := stateCacheReadViewFor(debug, stateVersion, sd.stateCache); identityKnown { + views.state = stateView + } + if sd.branchCache != nil && cacheViewEligible(debug, kv.CommitmentDomain) { + views.branch = sd.branchCache.View(branchCacheGenerationFor(debug, stateVersion)) + } + return views +} - m.seq.Add(1) - defer m.seq.Add(1) - - if cachedViewID != viewID { - state = 0 - m.viewID.Store(viewID) +// 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) { + if tx == nil || stateCache == nil { + return cache.ReadView{}, false } - end, ok := tx.Debug().DomainVisibleEnd(domain) - m.ends[domain].Store(end) - state |= loadedBit - if ok { - state |= okBit + stateVersion, err := rawdb.GetStateVersion(tx) + if err != nil { + return cache.ReadView{}, false } - m.state.Store(state) - return end, ok + return stateCacheReadViewFor(tx.Debug(), stateVersion, stateCache) } -// 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 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 (sd *SharedDomains) domainVisibleEnd(tx kv.TemporalTx, domain kv.Domain) (uint64, bool) { - if _, ok := tx.(kv.TemporalRwTx); ok { - return sd.visibleEnds.get(tx, domain) +func cacheGenerationsFor(debug kv.TemporalDebugTx, stateVersion uint64) cacheGenerations { + return cacheGenerations{ + state: stateCacheGenerationFor(debug, stateVersion), + branch: branchCacheGenerationFor(debug, stateVersion), } - 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 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 (f sdFrontier) DomainVisibleEnd(domain kv.Domain) (uint64, bool) { - return f.sd.domainVisibleEnd(f.tx, domain) +func branchCacheGenerationFor(debug kv.TemporalDebugTx, stateVersion uint64) cache.Generation { + return cache.BranchGeneration(stateVersion, debug.TxNumsInFiles(kv.CommitmentDomain)) } -// 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{} - } - return sd.stateCache.View(sdFrontier{sd: sd, tx: tx}) +type exactDomainVisibleEnd interface { + HasExactDomainVisibleEnd(domain kv.Domain) bool } -// 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) } +// 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 !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) @@ -205,6 +209,15 @@ type SharedDomains struct { logger log.Logger + // 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 + baseCacheGenerations cacheGenerations + baseStateVersionKnown bool + hasSharedBranchCache bool + txNum uint64 currentStep kv.Step // disableInlineTouchKey when true, DomainPut skips the TouchKey call. @@ -214,28 +227,28 @@ 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 is an optional cache for state data (accounts, storage, code); - // cacheApplier is its authoritative writer handle (commit/unwind only). - stateCache *cache.StateCache - cacheApplier cache.Applier + // stateCache 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 + statePublisher cache.Publisher - // Backing frontiers stay fixed while writes and staged unwinds remain in - // mem; both reach the transaction during flush, which resets the memo. - visibleEnds domainVisibleEndMemo + // 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 // codeStore is the optional two-tier (in-mem + MDBX) codehash-keyed code // cache, reached via temporalGetter so an addr-keyed reader can serve a @@ -248,13 +261,11 @@ 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; its generation view then prevents one + // SharedDomains from observing another transaction's cached branches. + branchCache *commitment.BranchCache + branchPublisher commitment.BranchPublisher // collector is the process-level KV-read metrics collector (aggregator // scope). Finished per-worker metrics are sent here (ownership transfer) @@ -273,8 +284,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 } @@ -308,10 +319,17 @@ func NewSharedDomains(ctx context.Context, tx kv.TemporalTx, logger log.Logger, } trieCfg := o.trieCfg + stateVersion, stateVersionErr := rawdb.GetStateVersion(tx) + debug := tx.Debug() + baseCacheGenerations := cacheGenerationsFor(debug, stateVersion) 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: debug.StepSize(), + baseViewID: tx.ViewID(), + baseStateVersion: stateVersion, + baseCacheGenerations: baseCacheGenerations, + baseStateVersionKnown: stateVersionErr == nil, } sd.mem = tx.Debug().NewMemBatch(&sd.metrics) @@ -325,6 +343,10 @@ 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()) + } if p, ok := tx.AggTx().(kvmetrics.MetricsCollectorProvider); ok { sd.collector = p.MetricsCollector() } @@ -400,7 +422,12 @@ func (sd *SharedDomains) Merge(ctx context.Context, sdTxNum uint64, other *Share if err := sd.mem.Merge(other.mem); err != nil { return err } - + if other.clearExecutionCaches { + sd.stateCache = nil + 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 { @@ -521,9 +548,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, @@ -533,7 +560,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, @@ -543,7 +570,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 @@ -552,20 +579,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.views, 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.views, addr) } func (gt *temporalGetter) HasPrefix(name kv.Domain, prefix []byte) (firstKey []byte, firstVal []byte, ok bool, err error) { @@ -577,13 +602,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 @@ -591,7 +616,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 @@ -748,11 +773,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) } @@ -762,28 +785,13 @@ 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)) - } - } - } - // 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) + // The process-global caches still describe the durable database until Commit. + // Detaching keeps this rewound overlay from reading or filling that version. + // 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 } func (sd *SharedDomains) GetMemBatch() kv.TemporalMemBatch { return sd.mem } @@ -840,34 +848,92 @@ 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 for generation-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. Commit rejects +// this capability until SetCanonicalCaches grants publication authority. +func (sd *SharedDomains) SetStateCacheReader(stateCache *cache.StateCache) { if !dbg.UseStateCache || stateCache == nil { return } - sd.stateCache = stateCache - sd.cacheApplier = stateCache.Applier() + sd.setStateCacheReader(stateCache) } -// 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. -func GuardAggregatorForCache(db any, sc *cache.StateCache) { - if sc == nil || !sc.FillsEnabled() { +func (sd *SharedDomains) setStateCacheReader(stateCache *cache.StateCache) { + if stateCache == nil { + return + } + sd.hasStateCache = true + if !sd.clearExecutionCaches { + sd.stateCache = stateCache + } +} + +// 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. +// +// 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) SetCanonicalCaches(stateCache *cache.StateCache) { + if !dbg.UseStateCache { + stateCache = nil + } + sd.setCanonicalCaches(stateCache) +} + +func (sd *SharedDomains) setCanonicalCaches(stateCache *cache.StateCache) { + if stateCache != nil { + sd.hasStateCache = true + } + if !sd.baseStateVersionKnown { + return + } + if sd.branchCache != nil { + sd.branchPublisher = sd.branchCache.Publisher() + sd.branchPublisher.Initialize(sd.baseCacheGenerations.branch) + } + if stateCache == nil { + return + } + sd.setStateCacheReader(stateCache) + sd.statePublisher = stateCache.Publisher() + sd.statePublisher.Initialize(sd.baseCacheGenerations.state) +} + +// 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 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: 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 — file-publication cache binding would be silently dropped", db)) } agg := h.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) { f, ok := agg.(interface{ ForbidVisibilityLowering() }) if !ok { panic(fmt.Sprintf("assert: aggregator %T lacks ForbidVisibilityLowering — the visibility-lowering guard would be silently dropped", agg)) @@ -962,36 +1028,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 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) } 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 { @@ -1007,28 +1052,22 @@ 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 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 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. func (sd *SharedDomains) Commit(ctx context.Context, tx kv.RwTx, validate ...func(tx kv.RwTx) error) error { defer mxFlushTook.ObserveDuration(time.Now()) + 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") + } runValidate := func() error { for _, v := range validate { @@ -1042,7 +1081,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 !stateCacheEnabled && !branchCacheEnabled && sd.codeStore == nil { if err := sd.flushMem(ctx, tx); err != nil { return err } @@ -1052,47 +1091,52 @@ 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.) - 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, 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, txNum uint64) { + branchUpdates = append(branchUpdates, commitment.BranchUpdate{ + Key: bytes.Clone(key), + Value: bytes.Clone(value), + Step: uint64(step), + TxNum: txNum, + }) + }) + var opts []kv.FlushOption - if sd.branchCache != nil { - opts = append(opts, stash(kv.CommitmentDomain)) + if branchCacheEnabled { + opts = append(opts, stashBranch) } - if sd.stateCache != nil { - 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.stateCache != nil || 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, txNum uint64) { + if sd.codeStore != nil && len(value) > 0 { + codeStoreWrites = append(codeStoreWrites, [2][]byte{crypto.Keccak256(value), bytes.Clone(value)}) } - if sd.stateCache != nil { - pending = append(pending, cacheUpdate{ - domain: kv.CodeDomain, - key: append([]byte(nil), k...), - val: append([]byte(nil), v...), - step: step, - txN: txNum, + if stateCacheEnabled { + stateUpdates = append(stateUpdates, cache.Update{ + Domain: kv.CodeDomain, + Key: bytes.Clone(key), + Value: bytes.Clone(value), + Step: step, + TxNum: txNum, }) } })) @@ -1108,80 +1152,126 @@ 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 nextCacheGenerations cacheGenerations + if stateCacheEnabled || branchCacheEnabled { + stateVersion, err := rawdb.GetStateVersion(tx) + if err != nil { + return fmt.Errorf("read plain state version: %w", err) + } + 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 } - 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) + + var statePublication *cache.Publication + var branchPublication *commitment.BranchPublication + defer func() { + statePublication.Abort() + branchPublication.Abort() + }() + // Canonical commits and file-view changes both acquire BranchCache before + // StateCache. Keeping one order prevents their publications from deadlocking. + if branchCacheEnabled { + branchPublication = sd.branchPublisher.Begin() + } + if stateCacheEnabled { + statePublication = sd.statePublisher.Begin() + } + + statePublication.Publish(nextCacheGenerations.state, stateUpdates, sd.clearExecutionCaches) + statePublication = nil + branchPublication.Publish(nextCacheGenerations.branch, branchUpdates, sd.clearExecutionCaches, adaptivePlan) + branchPublication = nil + adaptivePlan.Commit() + adaptivePlan = nil + if sd.clearExecutionCaches && sd.adaptivePinController != nil { + sd.adaptivePinController.Reset() + } + sd.clearExecutionCaches = 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 + } } - continue } - sd.cacheApplier.Apply(u.domain, u.key, u.val, u.txN) + scan(evenFrom, evenTo) + scan(oddFrom, oddTo) + return branches } - return nil + return sd.adaptivePinController.PlanBlock( + sd.txNum, + sd.baseCacheGenerations.branch, + 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.cacheReader()) + return sd.getLatestMetered(domain, tx, k, nil, sd.cacheViewsFor(tx)) } // GetLatestContext is the context-aware read for callers that read on behalf of @@ -1190,15 +1280,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.cacheViewsFor(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 } @@ -1207,7 +1294,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") } @@ -1250,20 +1337,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, 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 := views.state.GetWithStep(domain, k) if ok && !servableUnderBound(cStep, maxStep) { ok = false } @@ -1305,11 +1382,11 @@ 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 := 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). @@ -1320,39 +1397,26 @@ 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 { return nil, 0, fmt.Errorf("storage %x read error: %w", k, err) } - // View freshness is rechecked while the fill is serialized against - // committed cache updates. - if sd.stateCache != nil && sd.stateCache.Caches(domain) { - 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) + // 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)) } - fillView.Fill(domain, k, v, readTxNum) - } - // 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) } return v, step, nil @@ -1379,11 +1443,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.cacheViewsFor(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, views cacheViews, addr []byte) (int, bool, error) { if tx == nil { return 0, false, errors.New("sd.GetCodeSize: unexpected nil tx") } @@ -1391,14 +1455,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 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 { - // 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) + if cv, ok := views.state.GetCodeByHash(codeHash); ok { + views.state.FillCodeSize(codeHash, len(cv)) return len(cv), true, nil } } @@ -1407,7 +1469,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 } @@ -1431,11 +1493,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.cacheViewsFor(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, views cacheViews, addr []byte) ([]byte, bool, error) { if tx == nil { return nil, false, errors.New("sd.GetCode: unexpected nil tx") } @@ -1446,9 +1508,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, txNum); 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 } } @@ -1461,7 +1523,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 } @@ -1478,19 +1540,14 @@ 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 } - // 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) } @@ -1500,9 +1557,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{}) { @@ -1513,12 +1569,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) @@ -1531,25 +1586,15 @@ 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) + // 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 } @@ -1678,12 +1723,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 @@ -1740,9 +1782,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/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..7cdacc6b4f7 100644 --- a/db/state/execctx/export_test.go +++ b/db/state/execctx/export_test.go @@ -9,14 +9,20 @@ 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.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). -func (sd *SharedDomains) SetStateCacheForTest(sc *cache.StateCache) { - sd.stateCache = sc - sd.cacheApplier = sc.Applier() +// 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) SetCanonicalCachesForTest(sc *cache.StateCache) { + sd.setCanonicalCaches(sc) +} + +func (sd *SharedDomains) SetStateCacheReaderForTest(sc *cache.StateCache) { + sd.setStateCacheReader(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 fd9feb429a0..5c85645a967 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() @@ -66,25 +66,25 @@ 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)) - // 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)) } // 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, 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 = sc.View(nil).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/options.go b/db/state/execctx/options.go index 853ad487c54..850d05e1562 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 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 8d13ae8face..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) @@ -49,23 +50,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() @@ -84,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) @@ -104,7 +90,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. +// Baseline without StateCache generation checks or fills. func BenchmarkGetLatestColdNegativeNoCache(b *testing.B) { benchColdNegativeReads(b, false, false) } @@ -114,3 +100,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.SetCanonicalCachesForTest(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/db/state/execctx/statecache_readfill_test.go b/db/state/execctx/statecache_readfill_test.go index 7c198a3034e..7fb23b60501 100644 --- a/db/state/execctx/statecache_readfill_test.go +++ b/db/state/execctx/statecache_readfill_test.go @@ -18,16 +18,17 @@ 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" + "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" @@ -57,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)) @@ -76,93 +77,30 @@ 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()) +func currentStateCacheGeneration(t *testing.T, db kv.TemporalRoDB) cache.Generation { + t.Helper() + tx, err := db.BeginTemporalRo(t.Context()) 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) + defer tx.Rollback() + stateVersion, err := rawdb.GetStateVersion(tx) require.NoError(t, err) - require.Empty(t, value) - require.Equal(t, uint64(2), debug.calls) - require.Greater(t, debug.last, initialEnd) + debug := tx.Debug() + return cache.StateGeneration( + stateVersion, + debug.TxNumsInFiles(kv.AccountsDomain), + debug.TxNumsInFiles(kv.StorageDomain), + debug.TxNumsInFiles(kv.CodeDomain), + ) } -// 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. +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, +// 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. @@ -171,7 +109,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) @@ -180,12 +119,9 @@ 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) // 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 +132,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 @@ -223,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)) @@ -238,10 +175,9 @@ 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) - seed(sc, kv.AccountsDomain, key, nil, 2) old := dbg.AssertStateCache dbg.AssertStateCache = true @@ -252,14 +188,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_UnwindDetachesWithoutRevokingStateCache(t *testing.T) { t.Parallel() const stepSize = uint64(16) @@ -267,6 +199,12 @@ func TestReadFill_DoesNotClobberLiveEntry(t *testing.T) { db := newTestDb(t, stepSize) sc := newSmallStateCache() key, _, v2, diffs := twoStepRows(t, db, sc) + 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) @@ -275,26 +213,63 @@ func TestReadFill_DoesNotClobberLiveEntry(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) - // 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 := durableView.Get(kv.AccountsDomain, sentinelKey) + require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") + _, ok = durableView.Get(kv.AccountsDomain, key) + require.False(t, ok, "the detached SharedDomains must not fill from its rewound database view") } -// 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 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() const stepSize = uint64(16) @@ -302,54 +277,89 @@ 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.SetCanonicalCachesForTest(sc) + sd.SetTxNum(20) + 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) + 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)) + 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.SetCanonicalCachesForTest(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 := durableView.Get(kv.AccountsDomain, sentinelKey) + require.True(t, ok, "an uncommitted unwind must not revoke the durable cache") + _, ok = durableView.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) { + t.Parallel() - 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") + const stepSize = uint64(16) + ctx := t.Context() + db := newTestDb(t, stepSize) + sc := newSmallStateCache() + t.Cleanup(sc.Close) + key, _, v2, diffs := twoStepRows(t, db, sc) + + view := currentStateCacheView(t, db, sc) + got, ok := view.Get(kv.AccountsDomain, key) + require.True(t, ok) + require.Equal(t, v2, got) - 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") + 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) + + got, ok = view.Get(kv.AccountsDomain, key) + require.True(t, ok) + 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 } @@ -357,33 +367,30 @@ 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. -func TestGuardAggregatorForCache(t *testing.T) { +// 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 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") } -// 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 TestBindStateCacheToAggregator_FillsDisabledStillBinds(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) + f := &fakeCacheBinder{} + execctx.BindStateCacheToAggregator(fakeHasAgg{f}, sc) + 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 aea67d930c3..a7ec257cfb9 100644 --- a/db/state/execctx/statecache_rpc_integration_test.go +++ b/db/state/execctx/statecache_rpc_integration_test.go @@ -26,6 +26,9 @@ 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" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/execmodule" @@ -45,6 +48,304 @@ 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.SetCanonicalCachesForTest(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, 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) + require.NoError(t, err) + defer freshTx.Rollback() + freshDomains, err := execctx.NewSharedDomains(ctx, freshTx, log.New()) + require.NoError(t, err) + defer freshDomains.Close() + freshDomains.SetCanonicalCachesForTest(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.SetCanonicalCachesForTest(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.SetCanonicalCachesForTest(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, 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) + 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.SetCanonicalCachesForTest(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.SetCanonicalCachesForTest(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, 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) + require.NoError(t, err) + 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.SetCanonicalCachesForTest(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 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.SetCanonicalCachesForTest(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().Begin().Publish(freshGeneration, nil, true) + 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() @@ -71,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)) @@ -88,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)) @@ -106,71 +408,53 @@ 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) - cached, ok := stateCache.View(nil).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) } -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) + readDomains.SetCanonicalCachesForTest(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, db, 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.SetCanonicalCachesForTest(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, db, 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) { @@ -206,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)) @@ -222,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)) @@ -261,18 +545,15 @@ 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") } // 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() @@ -297,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)) @@ -314,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)) @@ -330,13 +611,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, 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 = stateCache.View(nil).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/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/cache.go b/execution/cache/cache.go index d30c41a0f4d..0a7939a467d 100644 --- a/execution/cache/cache.go +++ b/execution/cache/cache.go @@ -14,60 +14,32 @@ // 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 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. // -// 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. +// 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 generation even though the +// cache is process-global. Multi-version snapshot caching remains the +// responsibility of 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 42e0e740eab..5d982603bd8 100644 --- a/execution/cache/cache_test.go +++ b/execution/cache/cache_test.go @@ -58,6 +58,20 @@ 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(testStateGeneration(stateVersion)) + return stateCache, publisher +} + // ============================================================================= // DomainCache Tests // ============================================================================= @@ -140,7 +154,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 +457,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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(1)).Fill(kv.AccountsDomain, addr, makeValue(1), 0) + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), []Update{{Domain: kv.AccountsDomain, Key: addr}}, false) - _, ok := c.get(kv.AccountsDomain, addr) + _, ok := c.View(testStateGeneration(2)).Get(kv.AccountsDomain, addr) assert.False(t, ok) } @@ -511,53 +530,60 @@ 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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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() + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), nil, true) + view = c.View(testStateGeneration(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 +660,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(testStateGeneration(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 +685,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 +704,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 +723,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) { @@ -870,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") @@ -890,123 +772,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) - +func TestStateCache_UnwindRejectsPreReorgFill(t *testing.T) { + sc, publisher := readyStateCache(t, 1) 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") + fork := makeValue(2) + preReorg := sc.View(testStateGeneration(1)) + preReorg.Fill(kv.AccountsDomain, key, fork, 10) + + publication := publisher.Begin() + publication.Publish(testStateGeneration(2), nil, true) + + preReorg.Fill(kv.AccountsDomain, key, fork, 10) + _, 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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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_FileEndViewCannotFillAtAppliedTx(t *testing.T) { - b := 1 * datasize.MB - sc := NewStateCache(b, b, b, b) - t.Cleanup(sc.Close) - +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(testStateGeneration(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(testStateGeneration(11)) + unpublished.Fill(kv.AccountsDomain, key, makeValue(1), 1) + _, ok := sc.View(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(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(testStateGeneration(1)).SeedAddrCodeHash(addr, h) + _, ok := sc.View(testStateGeneration(1)).GetAddrCodeHash(addr) require.True(t, ok) - sc.apply(kv.CodeDomain, addr, nil, 20) - _, ok = sc.getAddrCodeHash(addr) + publication := publisher.Begin() + 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") } 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(testStateGeneration(2), []Update{{ + Domain: kv.CodeDomain, + Key: addr, + Value: code, + }}, false) + _, ok := sc.View(testStateGeneration(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(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") } @@ -1030,29 +921,8 @@ 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 +// 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) { @@ -1073,103 +943,74 @@ 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(testStateGeneration(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(testStateGeneration(2), []Update{{ + Domain: kv.AccountsDomain, + Key: key, + Value: []byte("applied"), + }}, false) + got, ok := c.View(testStateGeneration(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(testStateGeneration(1)) + publication := publisher.Begin() + publication.Publish(testStateGeneration(1), nil, true) 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(testStateGeneration(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(testStateGeneration(2), []Update{{Domain: kv.CodeDomain, Key: addr, Value: code}}, false) + stale := c.View(testStateGeneration(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(testStateGeneration(3), []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) 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..62f76a43dc1 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. + // 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) @@ -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) +// 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) } // 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,8 @@ 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. It holds every put stripe +// so no put can cross the multi-layer clear. func (c *CodeCache) Clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() @@ -580,7 +503,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 +514,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..0fefe86b9cc 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 @@ -191,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/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/files_publication_test.go b/execution/cache/files_publication_test.go new file mode 100644 index 00000000000..68ebb70e910 --- /dev/null +++ b/execution/cache/files_publication_test.go @@ -0,0 +1,139 @@ +// 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(testStateGeneration(2), []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) + require.True(t, ok) + require.Equal(t, value, got) + + var filesEnd [kv.DomainLen]uint64 + filesEnd[kv.AccountsDomain] = 101 + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + _, ok = view.Get(kv.AccountsDomain, key) + 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) + require.NotNil(t, change) + _, ok = covered.Get(kv.AccountsDomain, key) + require.False(t, ok, "foreign files must revoke the published generation") + change.Finish() + + publication = publisher.Begin() + 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") + + 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 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 + filesEnd[kv.AccountsDomain] = 1 + + change := stateCache.BeginFilesPublication(filesEnd) + require.NotNil(t, change) + require.False(t, stateCache.generation.publicationMu.TryLock(), + "cache publication must stay blocked while the backing-file view changes") + + change.Finish() + locked := stateCache.generation.publicationMu.TryLock() + require.True(t, locked) + if locked { + stateCache.generation.publicationMu.Unlock() + } + + publication := publisher.Begin() + publication.Abort() +} diff --git a/execution/cache/generation_gate.go b/execution/cache/generation_gate.go new file mode 100644 index 00000000000..2b16acae80e --- /dev/null +++ b/execution/cache/generation_gate.go @@ -0,0 +1,340 @@ +// 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 +} + +// 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 + // 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. +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.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 +} + +// 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 { + return GenerationPublisher{gate: g} +} + +// 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 + } + gate := p.gate + gate.publicationMu.Lock() + defer gate.publicationMu.Unlock() + 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.identity == identity { + return + } + + gate.current.Store(nil) + if clear != nil { + clear() + } + gate.current.Store(&publishedGeneration{identity: identity}) +} + +// GenerationPublication represents one pending durable transition. +type GenerationPublication struct { + gate *GenerationGate + previous *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() + gate.current.Store(nil) + return &GenerationPublication{ + gate: gate, + previous: previous, + } +} + +// 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. +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() != nil { + 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()) { + 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() != nil { + panic("cache generation publication changed before publish") + } + if apply != nil { + apply() + } + if gate.filesKnown { + identity.files = gate.files + } else { + gate.files = identity.files + gate.filesKnown = true + } + gate.current.Store(&publishedGeneration{identity: identity}) + 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) + g.files = FilesView{} + g.filesKnown = false + if clear != nil { + clear() + } +} + +// BackingChange keeps cache publication blocked while a new files view becomes +// visible. +type BackingChange struct { + gate *GenerationGate + 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 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 + } + 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() + gate.files = files + gate.filesKnown = true + if current != nil && current.identity.files == files && !incompatible { + return nil + } + var next *publishedGeneration + if current != nil { + next = &publishedGeneration{ + identity: Generation{stateVersion: current.identity.stateVersion, files: files}, + } + gate.current.Store(nil) + } + if incompatible && clear != nil { + clear() + } + keepPublicationLocked = true + return &BackingChange{gate: gate, 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 gate.current.Load() != nil { + panic("cache generation changed during files publication") + } + gate.current.Store(c.next) + c.gate = nil +} + +// Close waits for in-flight fills and revokes current views before the owner +// closes cache storage. +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/generic_cache.go b/execution/cache/generic_cache.go index 926c664c30e..da0ba02e11f 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,20 +51,17 @@ 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 // 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 @@ -83,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 @@ -100,23 +97,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) } @@ -214,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() @@ -271,30 +260,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 +305,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 +341,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 +350,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 +357,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 +366,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 +408,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) @@ -451,7 +417,7 @@ func (c *GenericCache[T]) putStriped(key []byte, value T, txNum uint64, overwrit // 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)] @@ -463,25 +429,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 +449,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 +466,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 +488,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 +499,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..4c0a742906c 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) @@ -126,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. @@ -146,7 +142,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 +160,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,18 +169,18 @@ 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) 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() } @@ -209,11 +205,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 +243,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 +288,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 +299,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 +319,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..7e01f63c3fd 100644 --- a/execution/cache/state_cache.go +++ b/execution/cache/state_cache.go @@ -18,9 +18,7 @@ package cache import ( "bytes" - "math" "strings" - "sync" "github.com/c2h5oh/datasize" @@ -31,66 +29,40 @@ 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). +// 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. 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. - 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. + 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 +} + 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 +77,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 +92,40 @@ 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 +// 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. +func (c *StateCache) BeginFilesPublication(filesEnd [kv.DomainLen]uint64) *BackingChange { + if c == nil { + return nil } - return cache.Get(key) + 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] { + continue + } + c.committedTxNumEnd[domain] = filesEnd[domain] + extended = true + } + return extended + }, c.clearLocked) } -// 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] +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 +134,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 +142,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 +150,108 @@ 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 { - return - } - c.admissionMu.RLock() - defer c.admissionMu.RUnlock() - if visibleEnd < c.appliedEnd[kv.AccountsDomain] { +func (c *StateCache) fill( + generation GenerationView, + domain kv.Domain, + key, value []byte, + step kv.Step, +) { + cache := c.getCache(domain) + if cache == nil { return } - cc.PutAddrCodeHash(addr, h, txNum) -} + value = bytes.Clone(value) -func (c *StateCache) deleteAddrCodeHash(addr []byte) { - cc, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok { - return - } - cc.DeleteAddrCodeHash(addr) + generation.Admit(func() { + cache.PutIfAbsent(key, value, step) + }) } -// 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 { +func (c *StateCache) fillCode( + generation GenerationView, + key, value []byte, + step kv.Step, +) { + codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) + if !ok || len(value) == 0 { return } - cache.Put(key, bytes.Clone(value), txNum) + value = bytes.Clone(value) + codeHash := crypto.Keccak256(value) + + generation.Admit(func() { + 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 { - 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] { +func (c *StateCache) seedAddrCodeHash(generation GenerationView, addr []byte, hash [32]byte) { + codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) + if !ok { return } - cache.PutIfAbsent(key, cloned, readTxNum) + generation.Admit(func() { + 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 GenerationView, codeHash []byte, size int) { codeCache, ok := c.caches[kv.CodeDomain].(*CodeCache) - if !ok || len(value) == 0 { - 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 !ok { return } - codeCache.PutWithCodeHashIfAbsent(key, cloned, codeHash, readTxNum) + generation.Admit(func() { + 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) + if committedEnd := update.TxNum + 1; committedEnd > c.committedTxNumEnd[update.Domain] { + c.committedTxNumEnd[update.Domain] = committedEnd } - 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 +259,33 @@ 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() - } +func (c *StateCache) resetProvenanceAndClearLocked() { + c.committedTxNumEnd = [kv.DomainLen]uint64{} + 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) } -// 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() +// 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 { 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 +295,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 +309,95 @@ func (c *StateCache) PrintStatsAndReset() { code.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. +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 +// 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 +} + +// 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 + } + 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 +} + +// 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 + } + return &Publication{c: p.c, generation: p.c.generation.Publisher().Begin()} +} + +// 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.c = nil +} + +// 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. +// +// 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) { + if p == nil || p.c == nil { + return + } + p.generation.Publish(generation, func() { + if clear { + p.c.resetProvenanceAndClearLocked() + } + for i := range updates { + p.c.applyLocked(updates[i]) + } + }) + p.c = nil +} diff --git a/execution/cache/view.go b/execution/cache/view.go index 0de781920d6..6be18cef922 100644 --- a/execution/cache/view.go +++ b/execution/cache/view.go @@ -16,182 +16,110 @@ 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. +// 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 concurrent publication turns the result into a miss. // -// 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) +// 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 makes callers fall back to the +// database. +type ReadView struct { + c *StateCache + generation GenerationView } -// 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 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{} + } + return ReadView{c: c, generation: c.generation.View(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.Current() +} -// 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 } - return v.c.getWithTxNum(domain, key) + value, step, ok := v.c.getWithStep(domain, key) + if !v.current() { + return nil, 0, false + } + 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 } - return v.c.getCodeByHash(codeHash) + value, ok := v.c.getCodeByHash(codeHash) + if !v.current() { + return nil, false + } + 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/commitment/adaptive_pin.go b/execution/commitment/adaptive_pin.go index f444081f3ed..059a74aa1f5 100644 --- a/execution/commitment/adaptive_pin.go +++ b/execution/commitment/adaptive_pin.go @@ -17,13 +17,15 @@ package commitment import ( - "context" + "bytes" "encoding/hex" + "maps" "sync" "sync/atomic" "time" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/execution/cache" ) // AdaptivePinControllerConfig sets the policy knobs for the adaptive @@ -60,12 +62,13 @@ 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 -// 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) @@ -75,19 +78,120 @@ 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 + // 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 { 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 { @@ -132,10 +236,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(), } } @@ -151,6 +256,38 @@ 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.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) +} + func (c *AdaptivePinController) onCacheMiss(prefix []byte) { hash, ok := ContractHashFromPrefix(prefix) if !ok { @@ -164,19 +301,39 @@ 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. 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, + reader CommitmentReader, + factory ParallelResolverFactory, + provider DbBranchesProvider, +) *AdaptivePinPlan { c.mu.Lock() - defer 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{ + controller: c, + previousStates: previousStates, + observedMisses: observedMisses, + source: source, + cacheClearEpoch: c.cacheClearEpoch, + txNum: txNum, + } // One factory call per block, shared across all contracts. nil falls back to serial. var parallelResolve BatchBranchResolver @@ -194,8 +351,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 { @@ -204,56 +359,110 @@ 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(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 +// been published with the database transaction. +func (p *AdaptivePinPlan) Commit() { + if p == nil || p.controller == nil { + 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)) } - 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 + } + p.discard() +} + +func (p *AdaptivePinPlan) discard() { + c := p.controller + 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() } func (c *AdaptivePinController) snapshotMisses() map[[32]byte]uint64 { @@ -269,71 +478,56 @@ 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[:]) } started := time.Now() - if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, dbBranches, parallelResolve, c.cache, c.logger); err != nil { + if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, dbBranches, parallelResolve, mutations, c.logger); err != nil { recordPreload(started, 0) - for _, prefix := range p.PinnedPrefixes() { - c.cache.Invalidate(prefix) - } + mutations.entries = mutations.entries[:checkpoint] return nil, err } recordPreload(started, p.usedBytes) 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 started := time.Now() - if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, reader, c.cache, c.logger); err != nil { + if _, _, err := p.Run(c.cfg.InitialViewBudgetBytes, reader, mutations, c.logger); err != nil { recordPreload(started, 0) - for _, prefix := range p.PinnedPrefixes() { - c.cache.Invalidate(prefix) - } + mutations.entries = mutations.entries[:checkpoint] return nil, err } recordPreload(started, p.usedBytes) return &adaptiveContractState{ - contractHash: hash, - promotedAtTxNum: txNum, - preload: p, + contractHash: hash, + preload: p, }, nil } @@ -341,31 +535,30 @@ 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 before, started := state.parallel.usedBytes, time.Now() - _, _, err := state.parallel.Run(stepBudget, dbBranches, parallelResolve, c.cache, c.logger) + _, _, err := state.parallel.Run(stepBudget, dbBranches, parallelResolve, mutations, c.logger) recordPreload(started, state.parallel.usedBytes-before) return err } - state.preload.pinTxNum = txNum + state.preload = cloneSerialPreload(state.preload) before, started := state.preload.usedBytes, time.Now() - _, _, err := state.preload.Run(stepBudget, reader, c.cache, c.logger) + _, _, err := state.preload.Run(stepBudget, reader, mutations, c.logger) recordPreload(started, state.preload.usedBytes-before) return err } diff --git a/execution/commitment/adaptive_pin_test.go b/execution/commitment/adaptive_pin_test.go index 3335f1138d2..26cd5d57f61 100644 --- a/execution/commitment/adaptive_pin_test.go +++ b/execution/commitment/adaptive_pin_test.go @@ -17,11 +17,15 @@ package commitment import ( - "context" + "bytes" "testing" "time" + "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" ) // A zero-value field means "unset", so the constructor's fallbacks must resolve @@ -50,6 +54,165 @@ func TestNewAdaptivePinController_ExplicitConfigWins(t *testing.T) { } } +func TestAdaptivePinPlanDoesNotMutateCacheBeforePublication(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) + _, _, 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, testBranchGeneration(1), reader, nil, nil) + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), nil, false, plan) + plan.Commit() + + _, _, 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 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) + 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") +} + // 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. @@ -62,9 +225,10 @@ func TestAdaptivePin_PromoteRecordsPreloadMetrics(t *testing.T) { c := NewAdaptivePinController(NewBranchCache(64), AdaptivePinControllerConfig{}, log.Root()) var h [32]byte copy(h[:], hash) + mutations := adaptiveCacheMutations{} c.mu.Lock() - state, err := c.promoteLocked(context.Background(), h, 1, resolve, nil, nil) + state, err := c.promoteLocked(h, resolve, nil, nil, &mutations) c.mu.Unlock() if err != nil { t.Fatal(err) @@ -90,10 +254,11 @@ func TestAdaptivePin_ExtendRecordsPreloadMetrics(t *testing.T) { c := NewAdaptivePinController(NewBranchCache(64), cfg, log.Root()) var h [32]byte copy(h[:], hash) + mutations := adaptiveCacheMutations{} c.mu.Lock() defer c.mu.Unlock() - state, err := c.promoteLocked(context.Background(), h, 1, resolve, nil, nil) + state, err := c.promoteLocked(h, resolve, nil, nil, &mutations) if err != nil { t.Fatal(err) } @@ -103,7 +268,7 @@ func TestAdaptivePin_ExtendRecordsPreloadMetrics(t *testing.T) { bytesBefore := mxPreloadBytesTotal.GetValue() - if err := c.runExtensionLocked(context.Background(), state, 2, 1<<20, resolve, nil, nil); err != nil { + if err := c.runExtensionLocked(state, 1<<20, resolve, nil, nil, &mutations); err != nil { t.Fatal(err) } diff --git a/execution/commitment/branch_cache.go b/execution/commitment/branch_cache.go index 3332d633763..fbbc9969564 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,21 @@ 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 database and files generation. type BranchCache struct { + generation cache.GenerationGate + + // committedTxNumEnd is only a file-provenance watermark. Cache validity is + // 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. + 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 // read path. @@ -66,10 +75,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 +112,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 @@ -112,42 +119,25 @@ 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 make epoch sampling and publication atomic with 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 - - // 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 { - // 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 - - // 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, @@ -351,10 +341,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() } @@ -362,6 +354,34 @@ func (c *BranchCache) Close() { } } +// Reset clears cached branches and revokes all views until the next durable +// publication. +func (c *BranchCache) Reset() { + c.generation.Reset(c.resetProvenanceAndClear) +} + +func (c *BranchCache) resetProvenanceAndClear() { + c.committedTxNumEnd = 0 + 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. +func (c *BranchCache) BeginFilesPublication(filesEnd uint64) *cache.BackingChange { + if c == nil { + return nil + } + return c.generation.Publisher().BeginBackingChange(cache.BranchFilesView(filesEnd), 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 // tries never spill past the resident trunk pays nothing for it. func (c *BranchCache) tailForWrite() *tailLRU { @@ -626,10 +646,9 @@ 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 +// storage trunk (allocated on demand). 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) { +func (c *BranchCache) PinEntry(prefix []byte, data []byte, step uint64) { if isCommitmentStateKey(prefix) { return } @@ -640,7 +659,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) @@ -664,38 +683,25 @@ 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). +// 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 } - // 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 +713,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,23 +743,9 @@ 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. -func (c *BranchCache) Clear() { +// clear empties the root, trunk, pinned, and tail tiers and resets their stats. +// It holds every put stripe so no Put or PinEntry can cross the clear. +func (c *BranchCache) clear() { c.lockAllPutStripes() defer c.unlockAllPutStripes() @@ -776,14 +763,13 @@ 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) c.tailMisses.Store(0) c.bytesServed.Store(0) - c.staleEvicted.Store(0) - c.coh.Reset() + c.clearEpoch.Add(1) } // Stats returns a one-line summary of the cache tiers' hit/miss counters plus @@ -803,11 +789,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_absorb_test.go b/execution/commitment/branch_cache_absorb_test.go new file mode 100644 index 00000000000..0e4f767bf9c --- /dev/null +++ b/execution/commitment/branch_cache_absorb_test.go @@ -0,0 +1,118 @@ +// 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" + + "github.com/erigontech/erigon/execution/cache" +) + +func TestBranchCacheFilesPublication(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) + view := branchCache.View(testBranchGeneration(2)) + got, _, ok := view.Get(key) + require.True(t, ok) + require.Equal(t, value, got) + + change := branchCache.BeginFilesPublication(101) + require.NotNil(t, change) + _, _, ok = view.Get(key) + 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) + require.NotNil(t, change) + _, _, ok = covered.Get(key) + require.False(t, ok, "foreign files must revoke the published generation") + change.Finish() + + publication = publisher.Begin() + 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") + + 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") +} + +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_test.go b/execution/commitment/branch_cache_test.go index 88a660cfadb..dd6a7729f6a 100644 --- a/execution/commitment/branch_cache_test.go +++ b/execution/commitment/branch_cache_test.go @@ -23,17 +23,22 @@ 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), -// 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 +49,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 +68,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 +85,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 +109,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 +130,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) @@ -146,19 +142,19 @@ 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, 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) 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}) @@ -167,24 +163,18 @@ func TestBranchCache_Clear(t *testing.T) { require.False(t, ok) } -func TestBranchCache_ClearRacingPut_EpochAlias(t *testing.T) { +func TestBranchCache_CloseClearsEntries(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) + c.Put(key, []byte("root"), 0) + + c.Close() _, _, ok := c.Get(key) - require.False(t, ok, "pre-Clear epoch must not alias the live epoch after a later unwind") + require.False(t, ok) } -func clearDuringBlockedBranchCacheWrite(c *BranchCache, block *sync.Mutex, write func()) { +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) @@ -203,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() @@ -213,33 +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() - c.Unwind(300) key := []byte{0x12, 0x34, 0x56} - clearDuringBlockedBranchCacheWrite(c, &c.tailMu, func() { - c.Put(key, []byte("dead-fork-branch"), 0, 200) + 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 before the reset") } -func TestBranchCache_ClearFencesStartedPinEntry(t *testing.T) { +func TestBranchCache_ResetFencesStartedPinEntry(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) + 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 before the reset") } // TestBranchCache_Stats verifies the format of the stats string is @@ -250,8 +238,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 +258,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 +265,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 +296,109 @@ 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_ViewRequiresExactGeneration(t *testing.T) { + c := NewBranchCache(100) + t.Cleanup(c.Close) + publisher := c.Publisher() + publisher.Initialize(testBranchGeneration(7)) + + key := []byte{0xa0, 0xb0} + view := c.View(testBranchGeneration(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(testBranchGeneration(6)).Get(key) + require.False(t, ok, "an older database snapshot must not read the current branch generation") + _, _, 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(testBranchGeneration(1)) + + key := []byte{0xa0, 0xb0} + oldView := c.View(testBranchGeneration(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(testBranchGeneration(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(testBranchGeneration(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(testBranchGeneration(1)) + + key := []byte{0xa0, 0xb0} + view := c.View(testBranchGeneration(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(testBranchGeneration(1)) + + key := []byte{0xa0, 0xb0} + 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(testBranchGeneration(1)).Get(key) + require.False(t, ok, "Reset must leave the cache unpublished") + + publication := publisher.Begin() + publication.Publish(testBranchGeneration(2), []BranchUpdate{{ + Key: key, + Value: []byte("new-layout"), + Step: 2, + }}, false, nil) + 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 new file mode 100644 index 00000000000..39d1806c46f --- /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 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 + generation cache.GenerationView +} + +// 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{} + } + 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) { + 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.generation.Admit(func() { + v.c.Put(prefix, value, step) + }) +} + +// 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 + Step uint64 + TxNum uint64 +} + +// BranchPublisher is the canonical mutation handle for BranchCache. +type BranchPublisher struct { + 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 + } + p.c.generation.Publisher().Initialize(generation, p.c.resetProvenanceAndClear) +} + +// BranchPublication represents one pending durable branch transition. +type BranchPublication struct { + c *BranchCache + generation *cache.GenerationPublication +} + +// Begin revokes current BranchReadViews without changing branch entries. +func (p BranchPublisher) Begin() *BranchPublication { + if p.c == nil { + return nil + } + return &BranchPublication{c: p.c, generation: p.c.generation.Publisher().Begin()} +} + +// Abort restores the previous branch generation after database rollback. +func (p *BranchPublication) Abort() { + if p == nil || p.c == nil { + return + } + p.generation.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. +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) + 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 + } + 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 417c71a05dd..5e0d95d7e6b 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 { @@ -2707,8 +2708,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 f9187a97a1a..e412bf95506 100644 --- a/execution/commitment/preload_parallel.go +++ b/execution/commitment/preload_parallel.go @@ -79,10 +79,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 @@ -155,7 +151,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 { @@ -180,11 +176,9 @@ func (p *ContractTrunkPreloadParallel) Run( endStep = 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 one generation. + cache.PinEntry(pk.key, v, 0) p.pinnedPrefixes = append(p.pinnedPrefixes, bytes.Clone(pk.key)) p.usedBytes += cost p.pinned++ diff --git a/execution/exec/blocks_read_ahead.go b/execution/exec/blocks_read_ahead.go index 0dd5e11964d..3a17cf1d0d4 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/state/execctx" "github.com/erigontech/erigon/execution/cache" "github.com/erigontech/erigon/execution/protocol/rules" "github.com/erigontech/erigon/execution/state" @@ -35,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 } @@ -69,41 +66,34 @@ 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 - stepSize uint64 // for the read txNum upper bound (last txNum of the read's step) + view cache.ReadView } +// 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 } - debug := ttx.Debug() - return &cachePopulatingGetter{TemporalGetter: ttx, view: sc.View(debug), stepSize: debug.StepSize()} + return &cachePopulatingGetter{TemporalGetter: ttx, view: view} } 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 439ac18982e..15743fcbdc9 100644 --- a/execution/exec/blocks_read_ahead_test.go +++ b/execution/exec/blocks_read_ahead_test.go @@ -48,9 +48,22 @@ 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(cache.StateGeneration(1, 0, 0, 0)) + return sc +} + +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() + cacheView(sc, 1).Fill(domain, k, v, step) } func TestBlockReadAheaderCarriesBlockAccessList(t *testing.T) { @@ -68,30 +81,23 @@ func TestBlockReadAheaderCarriesBlockAccessList(t *testing.T) { require.Equal(t, bal, block.BlockAccessList()) } -// 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) -} - -// 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") 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: 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 := sc.View(nil).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) } @@ -103,14 +109,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: cacheView(sc, 1)} _, _, err := cpg.GetLatest(kv.CodeDomain, addr) require.NoError(t, err) - got, ok := sc.View(nil).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") } @@ -122,85 +128,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: cacheView(sc, 1)} _, _, err := cpg.GetLatest(domain, key) require.NoError(t, err) - got, ok := sc.View(nil).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() - 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: cacheView(sc, 1)} _, _, err := cpg.GetLatest(kv.CodeDomain, key) require.NoError(t, err) - got, ok := sc.View(nil).Get(kv.CodeDomain, key) + got, ok := cacheView(sc, 1).Get(kv.CodeDomain, key) require.True(t, ok) require.Equal(t, code, got) - got, ok = sc.View(nil).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() - 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: cacheView(sc, 1)} _, _, err = cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - got, ok = sc.View(nil).Get(kv.AccountsDomain, key) + got, ok = cacheView(sc, 1).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: cacheView(sc, 1), } _, _, err := cpg.GetLatest(kv.AccountsDomain, key) require.NoError(t, err) - _, ok := sc.View(nil).Get(kv.AccountsDomain, key) + _, ok := cacheView(sc, 1).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().Begin().Publish(cache.StateGeneration(2, 0, 0, 0), nil, true) + _, ok = cacheView(sc, 2).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 := cacheView(sc, 1).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: 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 := sc.View(nil).Get(kv.AccountsDomain, key) + _, ok := cacheView(sc, 2).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 edd986a4984..56426536cfa 100644 --- a/execution/execmodule/exec_module.go +++ b/execution/execmodule/exec_module.go @@ -253,7 +253,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) @@ -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) } @@ -393,24 +391,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()) { @@ -439,7 +419,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 } @@ -534,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 @@ -552,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 { @@ -563,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() @@ -587,8 +552,9 @@ 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) + // 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 { doms.Close() @@ -600,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 { @@ -699,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 7186fa73322..4aaeaa79f6a 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) } @@ -360,11 +361,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 @@ -401,11 +397,9 @@ 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. - currentContext.SetStateCache(e.stateCache) + // Canonical execution both reads the process-global state cache and owns + // publication when Commit makes the overlay durable. + currentContext.SetCanonicalCaches(e.stateCache) currentContext.SetCodeStore(e.codeStore) } @@ -591,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.SetStateCache(e.stateCache) + freshSD.SetCanonicalCaches(e.stateCache) freshSD.SetCodeStore(e.codeStore) if err := freshSD.InitBlockOverlay(roTx, roTx.Debug().Dirs().Tmp); err != nil { roTx.Rollback() @@ -709,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) @@ -811,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 9a9c11c2004..fd32f37ab1a 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.SetCanonicalCaches(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) @@ -157,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) } 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: // 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") } 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 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 diff --git a/execution/vm/contract.go b/execution/vm/contract.go index c699e00eb6e..e10c8a9da30 100644 --- a/execution/vm/contract.go +++ b/execution/vm/contract.go @@ -113,8 +113,8 @@ 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 - jumpDestCache.Put(codeHash[:], c.analysis, 0) + // Code analysis is content-addressed and remains valid across state changes. + jumpDestCache.Put(codeHash[:], c.analysis) } return c.analysis.codeSegment(udest)