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)