Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions db/kv/membatchwithdb/carries_overlay_view_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// 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 <http://www.gnu.org/licenses/>.

package membatchwithdb_test

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv/membatchwithdb"
)

// TestCarriesOverlayView pins the predicate overlay wrap points rely on to
// avoid re-wrapping: read views over an overlay are recognized, while raw txs
// and writable batches are not.
func TestCarriesOverlayView(t *testing.T) {
_, rwTx := newTestTx(t)

overlay, err := membatchwithdb.NewMemoryBatch(rwTx, "", log.Root())
require.NoError(t, err)
defer overlay.Close()

require.False(t, membatchwithdb.CarriesOverlayView(rwTx), "a raw tx is not an overlay view")
require.False(t, membatchwithdb.CarriesOverlayView(overlay), "a writable batch is not an overlay view")
require.True(t, membatchwithdb.CarriesOverlayView(overlay.NewReadView(rwTx)))
require.True(t, membatchwithdb.CarriesOverlayView(overlay.NewTemporalReadView(rwTx)))
}
35 changes: 35 additions & 0 deletions db/kv/membatchwithdb/memory_mutation.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type MemoryMutation struct {
db kv.TemporalTx
statelessCursors map[string]kv.RwCursor
DomainReader DomainReader
overlay *MemoryMutation // non-nil marks a read view, pointing at the overlay it was created from
}

// NewMemoryBatch creates a pure Go in-memory batch with no OS-thread affinity.
Expand Down Expand Up @@ -1097,6 +1098,39 @@ func (m *MemoryMutation) NewReadView(tx kv.Tx) kv.TemporalTx {
return m.newReadViewMut(tx)
}

// OverlayViewCarrier is implemented by txs that are pinned overlay views.
// A wrapper that embeds a concrete view type keeps the marker through method
// promotion; one that embeds the bare tx interface must forward OverlayView
// explicitly, or the wrap points will treat it as unpinned.
type OverlayViewCarrier interface {
// OverlayView returns the overlay the tx was pinned to and whether the
// tx is a pinned view at all. A pinned view with a nil overlay resolved
// "no overlay published" and must keep reading committed data only.
OverlayView() (overlay *MemoryMutation, pinned bool)
}

// CarriesOverlayView reports whether tx is already a pinned overlay view, so
// wrap points leave it alone (rationale on rpchelper.PinToOverlay).
func CarriesOverlayView(tx kv.Tx) bool {
_, ok := ViewOverlay(tx)
return ok
}

// ViewOverlay returns the overlay tx was pinned to, and whether tx is a
// pinned view at all.
func ViewOverlay(tx kv.Tx) (*MemoryMutation, bool) {
if c, ok := tx.(OverlayViewCarrier); ok {
return c.OverlayView()
}
return nil, false
}

// OverlayView implements OverlayViewCarrier for read views; a MemoryMutation
// that owns its overlay data is not a view and carries no pin.
func (m *MemoryMutation) OverlayView() (*MemoryMutation, bool) {
return m.overlay, m.overlay != nil
}

// newReadViewMut is the internal constructor that returns the full
// *MemoryMutation. Used by NewTemporalReadView which needs to embed it.
func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation {
Expand All @@ -1114,6 +1148,7 @@ func (m *MemoryMutation) newReadViewMut(tx kv.Tx) *MemoryMutation {
readTx: tx,
db: dbTx,
DomainReader: m.DomainReader,
overlay: m,
}
}

Expand Down
35 changes: 31 additions & 4 deletions node/shards/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,19 @@ type Events struct {
hasReceiptSubscriptions bool
lock sync.RWMutex

// latestSD holds the most recently published SharedDomains from FCU.
// latestPub holds the most recently published SharedDomains from FCU
// together with its publish sequence number, as one atomic snapshot so
// readers can never observe the SD and the sequence out of step.
// Accessible lock-free for the builder and RPC layer.
latestSD atomic.Pointer[execctx.SharedDomains]
latestPub atomic.Pointer[overlayPub]
}

// overlayPub pairs a published SharedDomains with a monotonic publish
// sequence number. The first publish gets seq 1, so seq 0 uniquely means
// "nothing was ever published".
type overlayPub struct {
sd *execctx.SharedDomains
seq uint64
}

func NewEvents() *Events {
Expand Down Expand Up @@ -266,17 +276,34 @@ func (e *Events) AddOverlaySubscription() (chan *execctx.SharedDomains, func())
// PublishOverlay sends the SharedDomains to all in-process subscribers.
// The SD is shared read-only; the background commit goroutine owns its lifecycle.
func (e *Events) PublishOverlay(sd *execctx.SharedDomains) {
e.latestSD.Store(sd)
e.lock.Lock()
defer e.lock.Unlock()
seq := uint64(1)
if prev := e.latestPub.Load(); prev != nil {
seq = prev.seq + 1
}
e.latestPub.Store(&overlayPub{sd: sd, seq: seq})
for _, ch := range e.overlaySubscriptions {
common.PrioritizedSend(ch, sd)
}
}

// LatestSD returns the most recently published SharedDomains, or nil.
func (e *Events) LatestSD() *execctx.SharedDomains {
return e.latestSD.Load()
sd, _ := e.OverlaySnapshot()
return sd
}

// OverlaySnapshot returns the published SharedDomains together with its
// publish sequence number as one coherent pair. Comparing the sequence around
// a tx open detects any publish landing in between — including a
// publish/unpublish cycle that leaves the SD pointer unchanged.
func (e *Events) OverlaySnapshot() (*execctx.SharedDomains, uint64) {
p := e.latestPub.Load()
if p == nil {
return nil, 0
}
return p.sd, p.seq
}

func (e *Events) OnNewPendingLogs(logs types.Logs) {
Expand Down
5 changes: 3 additions & 2 deletions rpc/gasprice/bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/erigontech/erigon/rpc/gasprice/gaspricecfg"
"github.com/erigontech/erigon/rpc/jsonrpc"
"github.com/erigontech/erigon/rpc/rpccfg"
"github.com/erigontech/erigon/rpc/rpchelper"
)

const txsPerBlock = 100
Expand Down Expand Up @@ -128,7 +129,7 @@ func BenchmarkSuggestTipCap(b *testing.B) {
// Fresh cache every iteration → cold path, no cache hits.
cache := jsonrpc.NewGasPriceCache()
oracle := gasprice.NewOracle(
jsonrpc.NewGasPriceOracleBackend(dbArg, tx, baseApi),
jsonrpc.NewGasPriceOracleBackend(dbArg, rpchelper.PinToOverlay(tx, nil), baseApi),
cfg,
cache,
nil,
Expand Down Expand Up @@ -187,7 +188,7 @@ func BenchmarkFeeHistory(b *testing.B) {
// starts cold every time (nil historyCache). This ensures we
// measure DB round-trips, not cache hits.
oracle := gasprice.NewOracle(
jsonrpc.NewGasPriceOracleBackend(m.DB, tx, baseApi),
jsonrpc.NewGasPriceOracleBackend(m.DB, rpchelper.PinToOverlay(tx, nil), baseApi),
gaspricecfg.Config{MaxHeaderHistory: 0, MaxBlockHistory: 0},
gasCache,
nil, // cold: no history cache
Expand Down
63 changes: 56 additions & 7 deletions rpc/gasprice/feehistory.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,17 @@ const (
maxBlockFetchers = 4
)

// cacheKey identifies a processed block in the fee history cache.
// cacheKey identifies a processed block in the fee history cache. Above the
// frozen boundary the header hash is part of the key, so an entry lives
// exactly as long as the block itself: a same-height sibling (reorg, or an
// in-flight block replaced before its commit lands) has a different hash and
// misses. At or below the boundary the mapping is immutable and the hash
// stays zero: the number alone identifies the block, with no per-block
// resolution on the hit path.
// The percentiles string is a binary encoding of the requested percentile slice,
// so identical percentile arrays produce the same key.
type cacheKey struct {
hash common.Hash
number uint64
percentiles string
}
Expand Down Expand Up @@ -359,6 +366,28 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast

// Pre-fetch chain config once using the main backend (safe: single goroutine).
chainconfig := oracle.backend.ChainConfig()
var frozenBound uint64
if oracle.historyCache != nil {
if fb, err := oracle.backend.FrozenBlocks(); err == nil {
frozenBound = fb
}
}

// Unfrozen heights are cached by hash, so a reorged-out block can no longer
// be found under its number. The hashes come from one range scan per request
// — resolving them per block costs a remote round trip each in rpcdaemon
// mode, which the cache-hit path used to be free of. Heights left unresolved
// (scan error, beyond the head) are simply not cached.
var hotFrom uint64
var hotHashes []common.Hash
if oracle.historyCache != nil && lastBlock > frozenBound {
hotFrom = max(oldestBlock, frozenBound+1)
hotHashes, err = oracle.backend.CanonicalHashes(ctx, hotFrom, lastBlock)
if err != nil {
oracle.log.Debug("fee history: canonical range unresolved, serving uncached", "from", hotFrom, "to", lastBlock, "err", err)
hotHashes = nil
}
}

// Launch up to maxBlockFetchers goroutines. Each goroutine opens its own
// TemporalTx via Fork so MDBX transactions are never shared across goroutines.
Expand Down Expand Up @@ -394,24 +423,44 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
}
idx := int(blockNumber - oldestBlock)

// Try the LRU cache first (skip for pending blocks — they are ephemeral).
// The pending block comes from the mining cache and is rebuilt
// continuously, so its results are never memoized.
isPending := pendingBlock != nil && blockNumber >= pendingBlock.NumberU64()
if !isPending && oracle.historyCache != nil {
if cached, ok := oracle.historyCache.get(cacheKey{blockNumber, percentileKey}); ok {
cacheable := !isPending && oracle.historyCache != nil
byHash := false
key := cacheKey{number: blockNumber, percentiles: percentileKey}
if cacheable && blockNumber > frozenBound {
hotIdx := int(blockNumber - hotFrom)
if hotIdx < len(hotHashes) && hotHashes[hotIdx] != (common.Hash{}) {
key.hash = hotHashes[hotIdx]
byHash = true
} else {
cacheable = false
}
}
if cacheable {
if cached, ok := oracle.historyCache.get(key); ok {
blockResults[idx] = blockResult{processed: cached, hasResult: true}
continue
}
}

// Fetch by the resolved pair to skip a second canonical resolution.
fees := &blockFees{blockNumber: blockNumber}
switch {
case isPending:
fees.block, fees.receipts = pendingBlock, pendingReceipts
case len(rewardPercentiles) != 0:
fees.block, fees.err = localBackend.BlockByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
if byHash {
fees.block, fees.err = localBackend.BlockByHashNumber(fetchCtx, key.hash, blockNumber)
} else {
fees.block, fees.err = localBackend.BlockByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
}
if fees.block != nil && fees.err == nil {
fees.receipts, fees.err = localBackend.GetReceiptsGasUsed(fetchCtx, fees.block)
}
case byHash:
fees.header, fees.err = localBackend.HeaderByHashNumber(fetchCtx, key.hash, blockNumber)
default:
fees.header, fees.err = localBackend.HeaderByNumber(fetchCtx, rpc.BlockNumber(blockNumber))
}
Expand All @@ -434,8 +483,8 @@ func (oracle *Oracle) FeeHistory(ctx context.Context, blocks int, unresolvedLast
}

blockResults[idx] = blockResult{processed: fees.results, hasResult: true}
if !isPending && oracle.historyCache != nil {
oracle.historyCache.add(cacheKey{blockNumber, percentileKey}, fees.results)
if cacheable {
oracle.historyCache.add(key, fees.results)
}
}
})
Expand Down
3 changes: 2 additions & 1 deletion rpc/gasprice/feehistory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"github.com/erigontech/erigon/rpc/gasprice/gaspricecfg"
"github.com/erigontech/erigon/rpc/jsonrpc"
"github.com/erigontech/erigon/rpc/rpccfg"
"github.com/erigontech/erigon/rpc/rpchelper"
)

func TestFeeHistory(t *testing.T) {
Expand Down Expand Up @@ -86,7 +87,7 @@ func TestFeeHistory(t *testing.T) {
defer tx.Rollback()

cache := jsonrpc.NewGasPriceCache()
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(m.DB, tx, baseApi), config, cache, gasprice.NewFeeHistoryCache(), log.New())
oracle := gasprice.NewOracle(jsonrpc.NewGasPriceOracleBackend(m.DB, rpchelper.PinToOverlay(tx, nil), baseApi), config, cache, gasprice.NewFeeHistoryCache(), log.New())

first, reward, baseFee, ratio, blobBaseFee, blobBaseFeeRatio, err := oracle.FeeHistory(context.Background(), c.count, c.last, c.percent)

Expand Down
17 changes: 17 additions & 0 deletions rpc/gasprice/gasprice.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,23 @@ type OracleBackend interface {
GetReceiptsGasUsed(ctx context.Context, block *types.Block) (types.Receipts, error)
PendingBlockAndReceipts() (*types.Block, types.Receipts)

// CanonicalHashes returns the canonical hashes of [from, to] on the
// backend's view, one entry per height. Heights the view has no canonical
// marker for (beyond the head, or pruned) get the zero hash. It resolves
// the whole range at once because a per-height lookup is a remote round
// trip in rpcdaemon mode.
CanonicalHashes(ctx context.Context, from, to uint64) ([]common.Hash, error)

// FrozenBlocks returns the frozen (snapshot) boundary: the canonical
// number-to-hash mapping at or below it is immutable.
FrozenBlocks() (uint64, error)

// HeaderByHashNumber and BlockByHashNumber fetch by an already-resolved
// canonical (hash, number) pair, so a cached entry can never name a
// different block than the one processed.
HeaderByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Header, error)
BlockByHashNumber(ctx context.Context, hash common.Hash, number uint64) (*types.Block, error)

// Fork opens a new TemporalTx and returns a goroutine-local backend together
// with a cleanup function (call via defer cleanup()).
// If the backend does not support forking, it returns (nil, nil, nil) and
Expand Down
Loading
Loading